2026-07-03 16:11:24 +02:00

36 KiB

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:

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:

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:

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:

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

Case-insensitive partial match using ILIKE:

// 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:

// 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:

// Configuration
static get ENUM_FIELDS() {
  return ['environment', 'status', 'asset_type'];
}

// Usage
GET /api/tour_pages?environment=production
// Generates: WHERE environment = 'production'

Filter by related entity ID or searchable field:

// 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

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

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
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

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

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
class UsersDBApi {
  static async create(options: UserCreateOptions): Promise<UserRecord> {
    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:

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).

// 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:

// 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:

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

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):
// 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, ... });
  1. Via ASSOCIATIONS setter (used by frontend forms):
// 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

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

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