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
SEARCHABLE_FIELDS - Text Search
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'
RELATION_FILTERS - Related Entity Search
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:
PermissionsDBApiAssetsDBApiAsset_variantsDBApiPublish_eventsDBApiPwa_cachesDBApiAccess_logsDBApiPresigned_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:
- Auto-snapshot on create: Copies global element defaults to new projects
- Slug filter skipped for ID lookups: Prevents stale
X-Runtime-Project-Slugheaders 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 viaapplyRuntimeEnvironment()ProjectsDBApi- Slug filtering with ID bypass and auto-snapshot on createProject_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:
- 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, ... });
- 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
Related Documentation
- DB Models Module - Sequelize model definitions
- Factories Module - Service/router factories that use APIs
- Services Module - Business logic layer
- Middleware Module - Runtime context middleware