# Backend DB API Module ## Overview The DB API module provides the data access layer that sits between services and Sequelize models. It encapsulates query logic, filtering, pagination, and data transformations using a declarative configuration pattern via the `GenericDBApi` base class. **Location:** `backend/src/db/api/` **Files:** 20 files (1 base class + 18 entity APIs + 1 utility) | File | Class/Purpose | LOC | Extends GenericDBApi | |------|---------------|-----|---------------------| | `base.api.ts` | `GenericDBApi` - Base class | 726 | - | | `users.ts` | `UsersDBApi` - User accounts | 979 | No (custom) | | `projects.ts` | `ProjectsDBApi` - Projects | ~320 | Yes | | `tour_pages.ts` | `Tour_pagesDBApi` - Tour pages | ~350 | Yes | | `assets.ts` | `AssetsDBApi` - Media assets | ~92 | Yes | | `asset_variants.ts` | `Asset_variantsDBApi` - Asset variants | 82 | Yes | | `roles.ts` | `RolesDBApi` - RBAC roles | 71 | Yes | | `permissions.ts` | `PermissionsDBApi` - RBAC permissions | 53 | Yes | | `project_memberships.ts` | `Project_membershipsDBApi` - Team access | 86 | Yes | | `element_type_defaults.ts` | `Element_type_defaultsDBApi` - Global defaults | ~409 | Yes | | `project_element_defaults.ts` | `Project_element_defaultsDBApi` - Project defaults | ~410 | Yes | | `project_audio_tracks.ts` | `Project_audio_tracksDBApi` - Audio tracks | ~199 | Yes | | `project_transition_settings.ts` | `Project_transition_settingsDBApi` - Project transition settings | ~277 | Yes | | `global_transition_defaults.ts` | `Global_transition_defaultsDBApi` - Global transition defaults | ~155 | Yes | | `global_ui_control_defaults.ts` | `Global_ui_control_defaultsDBApi` - Global UI control defaults | ~160 | Yes | | `project_ui_control_settings.ts` | `Project_ui_control_settingsDBApi` - Project UI control settings | ~150 | Yes | | `publish_events.ts` | `Publish_eventsDBApi` - Publishing history | 101 | Yes | | `pwa_caches.ts` | `Pwa_cachesDBApi` - PWA manifests | 76 | Yes | | `access_logs.ts` | `Access_logsDBApi` - Audit trail | 88 | Yes | | `presigned_url_requests.ts` | `Presigned_url_requestsDBApi` - S3 URL audit | 90 | Yes | | `file.ts` | `FileDBApi` - Polymorphic file attachments | ~95 | No (custom) | | `runtime-context.ts` | Runtime context helpers | 57 | - | --- ## Architecture ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ DB API Layer Architecture │ └─────────────────────────────────────────────────────────────────────────┘ ┌─────────────────────┐ │ Service Layer │ │ (services/*.js) │ └──────────┬──────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────────┐ │ DB API Layer │ │ │ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ │ GenericDBApi (base.api.ts) │ │ │ │ │ │ │ │ Static Getters (Configuration): │ │ │ │ • MODEL, TABLE_NAME │ │ │ │ • SEARCHABLE_FIELDS, RANGE_FIELDS, ENUM_FIELDS, UUID_FIELDS │ │ │ │ • ASSOCIATIONS, RELATION_FILTERS │ │ │ │ • FIND_BY_INCLUDES, FIND_ALL_INCLUDES │ │ │ │ • JSON_FIELDS, FIELD_DEFAULTS, FIELD_TRANSFORMERS │ │ │ │ • CSV_FIELDS, AUTOCOMPLETE_FIELD │ │ │ │ │ │ │ │ Methods (CRUD + Query): │ │ │ │ • create(), bulkImport(), update() │ │ │ │ • deleteByIds(), remove() │ │ │ │ • findBy(), findAll(), findAllAutocomplete() │ │ │ │ • getFieldMapping(), toCSV() │ │ │ └─────────────────────────────────────────────────────────────────┘ │ │ │ │ │ ┌──────────────┼──────────────┐ │ │ │ │ │ │ │ ▼ ▼ ▼ │ │ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ │ │ Simple Entity │ │ Runtime-Aware │ │ Fully Custom │ │ │ │ API │ │ API │ │ API │ │ │ │ │ │ │ │ │ │ │ │ • PermissionsDB │ │ • Tour_pagesDB │ │ • UsersDBApi │ │ │ │ • RolesDBApi │ │ • ProjectsDBApi │ │ • FileDBApi │ │ │ │ • AssetsDBApi │ │ • AudioTracksDB │ │ │ │ │ │ │ │ │ │ │ │ │ │ Override only: │ │ Override: │ │ Fully custom │ │ │ │ - Static getters │ │ - Static getters │ │ implementation │ │ │ │ - getFieldMapping│ │ - findBy() │ │ │ │ │ │ │ │ - findAll() │ │ │ │ │ └──────────────────┘ └──────────────────┘ └──────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ │ Supporting Utilities │ │ │ │ runtime-context.ts - Environment/project filtering helpers │ │ │ │ ../utils.js - UUID validation, ILIKE query building │ │ │ └─────────────────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────┐ │ Sequelize Models │ │ (models/*.js) │ └─────────────────────┘ ``` --- ## GenericDBApi Base Class **Location:** `backend/src/db/api/base.api.ts` The base class provides a Template Method pattern where subclasses configure behavior through static getters and optionally override methods for custom logic. ### Static Getters (Configuration) | Getter | Type | Default | Description | |--------|------|---------|-------------| | `MODEL` | Model | (required) | Sequelize model reference | | `TABLE_NAME` | string | From MODEL | Database table name | | `SEARCHABLE_FIELDS` | string[] | `[]` | Fields for ILIKE text search | | `RANGE_FIELDS` | string[] | `[]` | Fields for range queries (min/max) | | `ENUM_FIELDS` | string[] | `[]` | Fields for exact match filtering | | `UUID_FIELDS` | string[] | `[]` | UUID foreign key fields (validated before query) | | `RELATION_FILTERS` | object[] | `[]` | Related entity filter configs | | `ASSOCIATIONS` | object[] | `[]` | M:N or belongsTo setters | | `FIND_BY_INCLUDES` | object[] | `[]` | Includes for findBy() | | `FIND_ALL_INCLUDES` | object[] | `[]` | Includes for findAll() | | `CSV_FIELDS` | string[] | `['id', 'createdAt']` | Fields for CSV export | | `AUTOCOMPLETE_FIELD` | string | `'name'` | Field for autocomplete | | `JSON_FIELDS` | string[] | `[]` | Fields to auto-stringify | | `FIELD_DEFAULTS` | object | `{}` | Default values for fields | | `FIELD_TRANSFORMERS` | object | `{}` | Custom field transformations | ### Methods #### getFieldMapping(data) Transforms input data for database operations using declarative configuration: ```javascript static getFieldMapping(data) { if (!data) return data; const mapped = { ...data }; // 1. Apply field defaults for (const [field, config] of Object.entries(this.FIELD_DEFAULTS)) { if (mapped[field] === undefined) { mapped[field] = config.default; } else if (mapped[field] === null && config.nullDefault !== undefined) { mapped[field] = config.nullDefault; } } // 2. Auto-stringify JSON fields for (const field of this.JSON_FIELDS) { if (mapped[field] !== undefined && mapped[field] !== null) { if (typeof mapped[field] !== 'string') { mapped[field] = JSON.stringify(mapped[field]); } } } // 3. Apply custom transformers for (const [field, transformer] of Object.entries(this.FIELD_TRANSFORMERS)) { if (mapped[field] !== undefined) { mapped[field] = transformer(mapped[field]); } } return mapped; } ``` #### create(options) Creates a new record with associations: ```javascript static async create({ data, currentUser = { id: null }, transaction, runtimeContext }) { const mappedData = this.getFieldMapping(data); const record = await this.MODEL.create( { ...mappedData, importHash: data.importHash || null, createdById: currentUser.id, updatedById: currentUser.id, }, { transaction }, ); // Handle M:N and belongsTo associations for (const assoc of this.ASSOCIATIONS) { if (data[assoc.field] !== undefined) { const setter = record[assoc.setter]; await setter.call( record, data[assoc.field] || (assoc.isArray ? [] : null), { transaction }, ); } } return record; } ``` #### update({ id, data, currentUser, transaction, runtimeContext }) Updates a record with partial data: ```javascript static async update({ id, data, currentUser = { id: null }, transaction }) { const record = await this.MODEL.findByPk(id, { transaction }); if (!record) { throw { status: 404, message: `${this.TABLE_NAME} not found` }; } const updatePayload = { updatedById: currentUser.id }; const mappedData = this.getFieldMapping(data); // Only update defined fields for (const [key, value] of Object.entries(mappedData)) { if (value !== undefined) { updatePayload[key] = value; } } await record.update(updatePayload, { transaction }); // Update associations for (const assoc of this.ASSOCIATIONS) { if (data[assoc.field] !== undefined) { const setter = record[assoc.setter]; await setter.call(record, data[assoc.field], { transaction }); } } return record; } ``` #### findAll(filter, options) Full-featured query with filtering, pagination, and sorting: ```javascript static async findAll(filter = {}, options = {}) { const limit = filter.limit || 0; const currentPage = +filter.page || 0; const offset = currentPage * limit; let where = {}; let include = [...this.FIND_ALL_INCLUDES]; // ID exact match (with UUID validation) if (filter.id) { if (!Utils.isValidUuid(filter.id)) { return { rows: [], count: 0 }; } where.id = filter.id; } // Text search (ILIKE) for (const field of this.SEARCHABLE_FIELDS) { if (filter[field]) { where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]); } } // Range queries (fieldRange: [min, max]) for (const field of this.RANGE_FIELDS) { const rangeKey = `${field}Range`; if (filter[rangeKey]) { const [start, end] = filter[rangeKey]; if (start) where[field] = { ...where[field], [Op.gte]: start }; if (end) where[field] = { ...where[field], [Op.lte]: end }; } } // Enum exact match for (const field of this.ENUM_FIELDS) { if (filter[field] !== undefined) { where[field] = filter[field]; } } // Relation filters (search by related entity ID or name) for (const rel of this.RELATION_FILTERS) { if (filter[rel.filterKey]) { // ... adds required include with where clause } } // createdAt date range if (filter.createdAtRange) { // ... applies date range } const queryOptions = { where, include, distinct: true, order: filter.field && filter.sort ? [[filter.field, filter.sort]] : [['createdAt', 'desc']], transaction: options.transaction, }; if (!options.countOnly) { queryOptions.limit = limit ? Number(limit) : undefined; queryOptions.offset = offset ? Number(offset) : undefined; } const { rows, count } = await this.MODEL.findAndCountAll(queryOptions); return { rows: options.countOnly ? [] : rows, count }; } ``` #### Other Methods | Method | Description | |--------|-------------| | `bulkImport(data, options)` | Bulk create with timestamps offset | | `deleteByIds({ ids, currentUser, transaction, runtimeContext })` | Soft delete multiple records | | `remove({ id, currentUser, transaction, runtimeContext })` | Soft delete single record | | `findBy(where, options)` | Find single record by criteria | | `findAllAutocomplete({ query, limit, offset }, options)` | Autocomplete search | | `toCSV(rows)` | Convert rows to CSV string | --- ## Query Filter Patterns ### SEARCHABLE_FIELDS - Text Search Case-insensitive partial match using ILIKE: ```javascript // Configuration static get SEARCHABLE_FIELDS() { return ['name', 'description', 'email']; } // Usage GET /api/users?name=john // Generates: WHERE LOWER(users.name) LIKE '%john%' ``` ### RANGE_FIELDS - Range Queries Min/max range filtering: ```javascript // Configuration static get RANGE_FIELDS() { return ['sort_order', 'price', 'created_at']; } // Usage GET /api/assets?sort_orderRange=[0,10] // Generates: WHERE sort_order >= 0 AND sort_order <= 10 GET /api/users?createdAtRange=[2024-01-01,2024-12-31] // Generates: WHERE createdAt >= '2024-01-01' AND createdAt <= '2024-12-31' ``` ### ENUM_FIELDS - Exact Match Direct equality filtering: ```javascript // Configuration static get ENUM_FIELDS() { return ['environment', 'status', 'asset_type']; } // Usage GET /api/tour_pages?environment=production // Generates: WHERE environment = 'production' ``` ### RELATION_FILTERS - Related Entity Search Filter by related entity ID or searchable field: ```javascript // Configuration static get RELATION_FILTERS() { return [ { filterKey: 'project', // Query param name model: db.projects, // Related model as: 'project', // Association alias searchField: 'name', // Field for text search }, ]; } // Usage GET /api/assets?project=abc123 // Filters by project ID: abc123 GET /api/assets?project=my-tour // Filters by project name containing "my-tour" GET /api/assets?project=abc123|my-tour // Pipe-separated: matches either ``` --- ## API Categories ### 1. Simple Entity APIs Extend `GenericDBApi` with minimal configuration. Only override static getters and `getFieldMapping()`. **Example: PermissionsDBApi** ```typescript class PermissionsDBApi extends GenericDBApi { static override get MODEL(): unknown { return db.permissions; } static override get TABLE_NAME(): string { return 'permissions'; } static override get SEARCHABLE_FIELDS(): string[] { return ['name']; } static override get CSV_FIELDS(): string[] { return ['id', 'name', 'createdAt']; } static override get AUTOCOMPLETE_FIELD(): string { return 'name'; } static override getFieldMapping(data: PermissionData): PermissionFieldMapping { return { id: data.id || undefined, name: data.name || null, }; } } ``` **Entities using this pattern:** - `PermissionsDBApi` - `AssetsDBApi` - `Asset_variantsDBApi` - `Publish_eventsDBApi` - `Pwa_cachesDBApi` - `Access_logsDBApi` - `Presigned_url_requestsDBApi` ### 2. Runtime-Aware APIs Override `findBy()` and `findAll()` to apply runtime environment/project filters for public presentation access. **Example: Tour_pagesDBApi** ```javascript class Tour_pagesDBApi extends GenericDBApi { // ... configuration getters ... static async findBy(where, options = {}) { const queryWhere = applyRuntimeEnvironment({ ...where }, options); const projectInclude = applyRuntimeProjectFilter( { model: db.projects, as: 'project' }, options, ); const record = await this.MODEL.findOne({ where: queryWhere, include: [projectInclude], }); return record ? record.get({ plain: true }) : null; } static async findAll(filter = {}, options = {}) { // ... standard filtering logic ... // Apply runtime environment filter where = applyRuntimeEnvironment(where, options); // Apply project slug filter to include include[0] = applyRuntimeProjectFilter(include[0], options); // ... execute query ... } } ``` **Example: ProjectsDBApi** `ProjectsDBApi` uses runtime slug filtering but with two unique behaviors: 1. **Auto-snapshot on create**: Copies global element defaults to new projects 2. **Slug filter skipped for ID lookups**: Prevents stale `X-Runtime-Project-Slug` headers from breaking ID-based queries ```javascript class ProjectsDBApi extends GenericDBApi { // ... configuration getters ... static async findBy(where, options = {}) { const runtimeProjectSlug = getRuntimeProjectSlug(options); const queryWhere = { ...where }; // Runtime access: filter by project slug // Skip if finding by ID (unambiguous lookup) if (runtimeProjectSlug && !where.id) { queryWhere.slug = runtimeProjectSlug; } const record = await this.MODEL.findOne({ where: queryWhere, include: options.include ?? this.DEFAULT_INCLUDES, }); return record ? record.get({ plain: true }) : null; } static async create(options) { const { transaction } = options; // Create the project using parent's create const project = await super.create(options); // Auto-snapshot global element defaults to the new project await Project_element_defaultsDBApi.snapshotGlobalDefaults(project.id, { ...options, transaction, }); return project; } static async findAll(filter = {}, options = {}) { // ... standard filtering logic ... // Runtime access: filter by project slug (no ID bypass needed for list) const runtimeProjectSlug = getRuntimeProjectSlug(options); if (runtimeProjectSlug) { where.slug = runtimeProjectSlug; } // ... execute query ... } } ``` **Entities using this pattern:** - `Tour_pagesDBApi` - Environment filtering via `applyRuntimeEnvironment()` - `ProjectsDBApi` - Slug filtering with ID bypass and auto-snapshot on create - `Project_audio_tracksDBApi` - Environment filtering ### 3. APIs with Custom Methods Extend base functionality with domain-specific methods. **Example: Element_type_defaultsDBApi** ```javascript class Element_type_defaultsDBApi extends GenericDBApi { // ... configuration ... // Self-initialization pattern static async ensureInitialized() { if (!this.initializationPromise) { this.initializationPromise = (async () => { const count = await this.MODEL.count(); if (count > 0) return; // Seed default rows await this.MODEL.bulkCreate( this.DEFAULT_ROWS.map((item) => this.getFieldMapping(item)), ); })(); } await this.initializationPromise; } // Override all methods to ensure initialization static async create(options) { await this.ensureInitialized(); return super.create(options); } // ... similar overrides for other methods ... // Default data configuration static get DEFAULT_ROWS() { return [ { element_type: 'navigation_next', name: 'Navigation Forward Button', ... }, { element_type: 'tooltip', name: 'Tooltip', ... }, // ... 11 default element types ]; } } ``` **Example: Project_element_defaultsDBApi** ```javascript class Project_element_defaultsDBApi extends GenericDBApi { // ... configuration ... // Snapshot global defaults to a project static async snapshotGlobalDefaults(projectId, options = {}) { const globalDefaults = await Element_type_defaultsDBApi.findAll({}); return this.MODEL.bulkCreate( globalDefaults.rows.map((global) => ({ projectId, element_type: global.element_type, settings_json: global.default_settings_json, source_element_id: global.id, snapshot_version: 1, })), { transaction: options.transaction }, ); } // Reset project default to current global static async resetToGlobal(id, options = {}) { const projectDefault = await this.MODEL.findByPk(id); const globalDefault = await Element_type_defaultsDBApi.MODEL.findOne({ where: { element_type: projectDefault.element_type }, }); await projectDefault.update({ settings_json: globalDefault.default_settings_json, snapshot_version: projectDefault.snapshot_version + 1, }); return projectDefault.reload(); } // Compare project default with global static async getDiffFromGlobal(id) { // ... returns diff object } } ``` ### 4. Fully Custom APIs Don't extend `GenericDBApi` due to significantly different requirements. **Example: UsersDBApi** Complex user management with: - Password hashing (bcrypt) - File avatar handling - Token generation for email verification and password reset - OAuth authentication support ```typescript class UsersDBApi { static async create(options: UserCreateOptions): Promise { const { data, currentUser = { id: null }, transaction } = options; const users = await db.users.create({ firstName: data.firstName || null, lastName: data.lastName || null, email: data.email || null, password: data.password || null, // Already hashed by service // ... }, { transaction }); // Auto-assign default role if (!data.app_role) { const role = await db.roles.findOne({ where: { name: 'User' } }); await users.setApp_role(role, { transaction }); } // Handle avatar file await FileDBApi.replaceRelationFiles( { belongsTo: 'users', belongsToColumn: 'avatar', belongsToId: users.id }, data.data.avatar, options, ); return users; } static async update({ id, data, currentUser, transaction, runtimeContext }) { const users = await db.users.findByPk(id, { transaction }); // Hash password if changed if (data.password) { data.password = bcrypt.hashSync(data.password, config.bcrypt.saltRounds); } else { data.password = users.password; } // ... update fields ... } // Token generation static async generateEmailVerificationToken(email, options) { return this._generateToken( ['emailVerificationToken', 'emailVerificationTokenExpiresAt'], email, options, ); } static async _generateToken(keyNames, email, options) { const users = await db.users.findOne({ where: { email: email.toLowerCase() } }); const token = crypto.randomBytes(20).toString('hex'); const tokenExpiresAt = Date.now() + (24 * 60 * 60 * 1000); // 24 hours await users.update({ [keyNames[0]]: token, [keyNames[1]]: tokenExpiresAt, }); return token; } // Token lookup static async findByPasswordResetToken(token, options) { return db.users.findOne({ where: { passwordResetToken: token, passwordResetTokenExpiresAt: { [Op.gt]: Date.now() }, }, }); } }; ``` **Example: FileDBApi** Polymorphic file attachment handling: ```typescript export default class FileDBApi { static async replaceRelationFiles(relation, rawFiles, options = {}) { assert(relation.belongsTo); assert(relation.belongsToColumn); assert(relation.belongsToId); const files = Array.isArray(rawFiles) ? rawFiles : rawFiles ? [rawFiles] : []; await this._removeLegacyFiles(relation, files, options); await this._addFiles(relation, files, options); } static async _addFiles(relation, files, options) { const inexistentFiles = files.filter((file) => !!file.new); for (const file of inexistentFiles) { await db.file.create({ belongsTo: relation.belongsTo, belongsToColumn: relation.belongsToColumn, belongsToId: relation.belongsToId, name: file.name, publicUrl: file.publicUrl, privateUrl: file.privateUrl, }, { transaction: options.transaction }); } } static async _removeLegacyFiles(relation, files, options) { const filesToDelete = await db.file.findAll({ where: { belongsTo: relation.belongsTo, belongsToId: relation.belongsToId, belongsToColumn: relation.belongsToColumn, id: { [Op.notIn]: files.filter((f) => !f.new).map((f) => f.id) }, }, }); for (const file of filesToDelete) { await services.deleteFile(file.privateUrl); await file.destroy({ transaction: options.transaction }); } } }; ``` --- ## Runtime Context Helpers **Location:** `backend/src/db/api/runtime-context.ts` Utilities for filtering queries based on runtime presentation context (public tour access). ```javascript // Get runtime environment from options.runtimeContext function getRuntimeEnvironment(options = {}) { const runtimeContext = options.runtimeContext || null; if (!runtimeContext) return null; // SECURITY: Only allow 'production' and 'stage' from header if (runtimeContext.headerEnvironment === 'production') return 'production'; if (runtimeContext.headerEnvironment === 'stage') return 'stage'; return null; } // Get project slug from runtime context function getRuntimeProjectSlug(options = {}) { const runtimeContext = options.runtimeContext || null; return runtimeContext?.headerProjectSlug || null; } // Apply environment filter to where clause function applyRuntimeEnvironment(where = {}, options = {}) { const environment = getRuntimeEnvironment(options); if (!environment) return where; return { ...where, environment }; } // Apply project slug filter to include function applyRuntimeProjectFilter(projectInclude = {}, options = {}) { const projectSlug = getRuntimeProjectSlug(options); if (!projectSlug) return projectInclude; return { ...projectInclude, required: true, where: { ...(projectInclude.where || {}), slug: projectSlug }, }; } ``` **Usage:** ```javascript // In runtime-aware API static async findAll(filter = {}, options = {}) { let where = { /* ... */ }; let include = [{ model: db.projects, as: 'project' }]; // Apply runtime filters where = applyRuntimeEnvironment(where, options); include[0] = applyRuntimeProjectFilter(include[0], options); // ... execute query } ``` --- ## DB Utils **Location:** `backend/src/db/utils.js` Utility functions for UUID validation and query building: ```javascript const validator = require('validator'); const { v4: uuidv4 } = require('uuid'); module.exports = class Utils { // Check if value is a valid UUID static isValidUuid(value) { return Boolean(value && validator.isUUID(String(value))); } // Generate a new UUID v4 static generateUuid() { return uuidv4(); } // Filter array to only valid UUIDs static filterValidUuids(values) { return values.filter((v) => this.isValidUuid(v)); } // Case-insensitive LIKE query static ilike(model, column, value) { return Sequelize.where( Sequelize.fn('lower', Sequelize.col(`${model}.${column}`)), { [Sequelize.Op.like]: `%${value}%`.toLowerCase() }, ); } }; ``` **UUID Utility Functions:** | Function | Purpose | Returns | |----------|---------|---------| | `isValidUuid(value)` | Check if valid UUID | `boolean` | | `generateUuid()` | Create new UUID v4 | `string` | | `filterValidUuids(values)` | Filter array to valid UUIDs only | `string[]` | | `ilike(model, column, value)` | Case-insensitive search | Sequelize where clause | **UUID Validation Behavior:** - Invalid single ID filter (`?id=xxx`) → returns `{ rows: [], count: 0 }` immediately - Invalid UUID in relation filter (`?project=uuid|name`) → filters out invalid UUIDs for ID search, keeps all terms for text search - Invalid UUID field filter (`?projectId=xxx`) → returns `{ rows: [], count: 0 }` immediately --- ## Declarative Configuration Examples ### Simple Entity ```javascript class AssetsDBApi extends GenericDBApi { static get MODEL() { return db.assets; } static get TABLE_NAME() { return 'assets'; } static get SEARCHABLE_FIELDS() { return ['name', 'cdn_url', 'storage_key', 'mime_type', 'checksum']; } static get RANGE_FIELDS() { return ['size_mb', 'width_px', 'height_px', 'duration_sec']; } static get ENUM_FIELDS() { return ['asset_type', 'type', 'is_public']; } // UUID foreign key fields - validated before querying static get UUID_FIELDS() { return ['projectId']; } static get ASSOCIATIONS() { return [{ field: 'project', setter: 'setProject', isArray: false }]; } static get FIND_BY_INCLUDES() { return [ { association: 'asset_variants_asset' }, { association: 'project' }, ]; } static get RELATION_FILTERS() { return [ { filterKey: 'project', model: db.projects, as: 'project', searchField: 'name' }, ]; } static getFieldMapping(data) { return { name: data.name || null, asset_type: data.asset_type || null, type: data.type || 'general', cdn_url: data.cdn_url || null, storage_key: data.storage_key || null, mime_type: data.mime_type || null, size_mb: data.size_mb || null, width_px: data.width_px || null, height_px: data.height_px || null, duration_sec: data.duration_sec || null, checksum: data.checksum || null, is_public: data.is_public || false, }; } } ``` ### Foreign Key Fields in getFieldMapping **Important:** Foreign key fields (like `assetId`, `projectId`) must be included in `getFieldMapping()` if they are passed directly to `create()` or `update()`. Without this, the foreign key value is silently dropped and the record is created without the relationship. **Two ways to handle foreign keys:** 1. **Direct field mapping** (preferred for programmatic use): ```javascript // In getFieldMapping() static getFieldMapping(data) { return { assetId: data.assetId || null, // Include FK field directly variant_type: data.variant_type || null, // ... }; } // In service await Asset_variantsDBApi.create({ assetId: asset.id, ... }); ``` 2. **Via ASSOCIATIONS setter** (used by frontend forms): ```javascript // ASSOCIATIONS config uses 'asset' (relation name) static get ASSOCIATIONS() { return [{ field: 'asset', setter: 'setAsset', isArray: false }]; } // Service passes 'asset' instead of 'assetId' await Asset_variantsDBApi.create({ asset: asset.id, ... }); // GenericDBApi.create() calls record.setAsset(asset.id) with record as `this` ``` **Common mistake:** Passing `assetId` when only `asset` association is configured (or vice versa) results in orphaned records with NULL foreign keys. **Implementation detail:** Sequelize association mixins rely on the model instance as `this`. `GenericDBApi` must call association setters as bound record methods (`setter.call(record, ...)`) rather than detached functions; otherwise belongsTo setters such as `setProject`/`setAsset` receive an undefined source instance and fail before the foreign key can be saved. ### Entity with M:N Relations ```typescript class RolesDBApi extends GenericDBApi { static override get MODEL(): unknown { return db.roles; } static override get ASSOCIATIONS(): RoleAssociationConfig[] { return [{ field: 'permissions', setter: 'setPermissions', isArray: true }]; } static override get FIND_BY_INCLUDES(): unknown[] { return [ { association: 'users_app_role' }, { association: 'permissions' }, ]; } static override get FIND_ALL_INCLUDES(): unknown[] { return [ { model: db.permissions, as: 'permissions', required: false }, ]; } static get RELATION_FILTERS() { return [ { filterKey: 'permissions', model: db.permissions, as: 'permissions_filter', searchField: 'name' }, ]; } } ``` ### Entity with JSON Fields and Defaults ```javascript class Element_type_defaultsDBApi extends GenericDBApi { // Auto-stringify JSON fields static get JSON_FIELDS() { return ['default_settings_json']; } // Field defaults static get FIELD_DEFAULTS() { return { element_type: { default: null }, name: { default: null }, sort_order: { default: 0 }, }; } static getFieldMapping(data) { // Apply base class transformations first const mapped = super.getFieldMapping(data); return { id: mapped.id || undefined, element_type: mapped.element_type, name: mapped.name, sort_order: mapped.sort_order, default_settings_json: mapped.default_settings_json, }; } } ``` --- ## API Summary Table | API | Pattern | Getters | Custom Methods | Notes | |-----|---------|---------|----------------|-------| | `PermissionsDBApi` | Simple | 6 | 0 | Minimal config | | `AssetsDBApi` | Simple | 9 | 0 | With associations | | `RolesDBApi` | Simple | 10 | 0 | M:N permissions | | `ProjectsDBApi` | Runtime-aware | 10 | 3 | Auto-snapshot on create, slug filter skipped for ID lookups | | `Tour_pagesDBApi` | Runtime-aware | 9 | 0 | Environment filtering | | `Project_audio_tracksDBApi` | Runtime-aware | 8 | 0 | Environment filtering | | `Element_type_defaultsDBApi` | Self-init | 9 | 1 | Default seeding | | `Project_element_defaultsDBApi` | Extended | 10 | 4 | Snapshot, reset, diff | | `UsersDBApi` | Fully custom | - | 12 | Auth, tokens, files | | `FileDBApi` | Fully custom | - | 3 | Polymorphic files | --- ## Best Practices ### When to Extend GenericDBApi - Standard CRUD operations needed - Filtering by searchable/range/enum fields - Pagination and sorting - CSV export - Autocomplete ### When to Override Methods - Custom runtime environment filtering - Special business logic during create/update - Complex association handling - Self-initialization patterns ### When to Use Fully Custom API - Complex authentication logic (password hashing, tokens) - Polymorphic associations (FileDBApi) - Significantly different query patterns - Multiple specialized methods --- ## Related Documentation - [DB Models Module](./db-models.md) - Sequelize model definitions - [Factories Module](./factories.md) - Service/router factories that use APIs - [Services Module](./services.md) - Business logic layer - [Middleware Module](./middleware.md) - Runtime context middleware