# Objection.js Model System Architecture ## System Overview ``` ┌─────────────────────────────────────────────────────────────────┐ │ HTTP Request Flow │ └────────────────────────────┬────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────┐ │ Record Controller │ │ (e.g. ObjectController) │ │ │ │ - createRecord(data) │ │ - getRecord(id) │ │ - updateRecord(id, data) │ │ - deleteRecord(id) │ └──────────────┬──────────────────┘ │ ▼ ┌──────────────────────────────────────┐ │ ObjectService │ │ (CRUD with Model/Knex Fallback) │ │ │ │ - createRecord() ┐ │ │ - getRecords() ├─→ Try Model │ │ - getRecord() │ Else Knex │ │ - updateRecord() │ │ │ - deleteRecord() ┘ │ └────────────┬─────────────┬──────────┘ │ │ ┌───────────▼──┐ ┌──────▼─────────┐ │ ModelService │ │ TenantDB │ │ │ │ Service │ │ - getModel │ │ │ │ - getBound │ │ - getTenantKnex│ │ Model │ │ │ │ - Registry │ │ - resolveTenant│ └───────────┬──┘ │ ID │ │ └────────────────┘ ▼ ┌────────────────────────────┐ │ ModelRegistry │ │ (Per-Tenant) │ │ │ │ Map │ │ - getModel(apiName) │ │ - registerModel(api, cls) │ │ - getAllModelNames() │ └────────────────────────────┘ │ ▼ ┌────────────────────────────────────┐ │ DynamicModelFactory │ │ │ │ createModel(ObjectMetadata) │ │ Returns: ModelClass │ │ │ │ ┌──────────────────────────────┐ │ │ │ DynamicModel extends Model │ │ │ │ (Created Class) │ │ │ │ │ │ │ │ tableName: "accounts" │ │ │ │ jsonSchema: { ... } │ │ │ │ │ │ │ │ $beforeInsert() { │ │ │ │ - Generate id (UUID) │ │ │ │ - Set created_at │ │ │ │ - Set updated_at │ │ │ │ } │ │ │ │ │ │ │ │ $beforeUpdate() { │ │ │ │ - Set updated_at │ │ │ │ } │ │ │ └──────────────────────────────┘ │ └────────────────────────────────────┘ │ ┌──────────────┴──────────────┐ │ │ ▼ ▼ ┌───────────────┐ ┌─────────────────┐ │ Model Class │ │ Knex (Fallback)│ │ (Objection) │ │ │ │ │ │ - query() │ │ - query() │ │ - insert() │ │ - insert() │ │ - update() │ │ - update() │ │ - delete() │ │ - delete() │ │ - select() │ │ │ │ │ │ Hooks: │ └─────────────────┘ │ - Before ops │ │ │ - Timestamps │ │ │ - Validation │ │ └───────────────┘ │ │ │ └──────────────┬──────────┘ │ ▼ ┌────────────────────┐ │ Database (MySQL) │ │ │ │ - Read/Write │ │ - Transactions │ │ - Constraints │ └────────────────────┘ ``` ## Data Flow: Create Record ``` ┌────────────────────────────────────────────────────────────────┐ │ User sends: POST /api/records/Account │ │ Body: { "name": "Acme", "revenue": 1000000 } │ └────────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────┐ │ ObjectService.createRecord() │ │ - Resolve tenantId │ │ - Get Knex connection │ │ - Verify object exists │ └────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────┐ │ Try to use Objection Model │ │ │ │ Model = modelService.getModel( │ │ tenantId, │ │ "Account" │ │ ) │ └────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────┐ │ Get Bound Model (with Knex) │ │ │ │ boundModel = await modelService │ │ .getBoundModel(tenantId, api) │ │ │ │ Model now has database context │ └────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────┐ │ Set system field: ownerId │ │ │ │ recordData = { │ │ ...userProvidedData, │ │ ownerId: currentUserId │ │ } │ └────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────┐ │ Call Model Insert │ │ │ │ record = await boundModel │ │ .query() │ │ .insert(recordData) │ └────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────┐ │ Model Hook: $beforeInsert() │ │ (Runs before DB insert) │ │ │ │ $beforeInsert() { │ │ if (!this.id) { │ │ this.id = UUID() │ │ } │ │ if (!this.created_at) { │ │ this.created_at = now() │ │ } │ │ if (!this.updated_at) { │ │ this.updated_at = now() │ │ } │ │ } │ └────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────┐ │ Database INSERT │ │ │ │ INSERT INTO accounts ( │ │ id, │ │ name, │ │ revenue, │ │ ownerId, │ │ created_at, │ │ updated_at, │ │ tenantId │ │ ) VALUES (...) │ └────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────┐ │ Database returns inserted record │ │ │ │ { │ │ id: "uuid...", │ │ name: "Acme", │ │ revenue: 1000000, │ │ ownerId: "user-uuid", │ │ created_at: "2025-01-26...", │ │ updated_at: "2025-01-26...", │ │ tenantId: "tenant-uuid" │ │ } │ └────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────┐ │ Return to HTTP Response │ │ (All fields populated) │ └────────────────────────────────────┘ ``` ## Data Flow: Update Record ``` ┌────────────────────────────────────────────────────────────────┐ │ User sends: PATCH /api/records/Account/account-id │ │ Body: { "revenue": 1500000 } │ └────────────────────────────────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────┐ │ ObjectService.updateRecord() │ │ - Verify user owns record │ │ - Filter system fields: │ │ - Delete allowedData.ownerId │ │ - Delete allowedData.id │ │ - Delete allowedData.created_at│ │ - Delete allowedData.tenantId │ └────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────┐ │ allowedData = { │ │ revenue: 1500000 │ │ } │ │ │ │ (ownerId, id, created_at, │ │ tenantId removed) │ └────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────┐ │ Get Bound Model │ │ Call Model Update │ │ │ │ await boundModel │ │ .query() │ │ .where({ id: recordId }) │ │ .update(allowedData) │ └────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────┐ │ Model Hook: $beforeUpdate() │ │ (Runs before DB update) │ │ │ │ $beforeUpdate() { │ │ this.updated_at = now() │ │ } │ └────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────┐ │ Database UPDATE │ │ │ │ UPDATE accounts SET │ │ revenue = 1500000, │ │ updated_at = now() │ │ WHERE id = account-id │ └────────────────────────────────────┘ │ ▼ ┌────────────────────────────────────┐ │ Fetch Updated Record │ │ Return to HTTP Response │ │ │ │ { │ │ id: "uuid...", │ │ name: "Acme", │ │ revenue: 1500000, ← CHANGED │ │ ownerId: "user-uuid", │ │ created_at: "2025-01-26...", │ │ updated_at: "2025-01-26...", │ │ ↑ UPDATED to newer time │ │ tenantId: "tenant-uuid" │ │ } │ └────────────────────────────────────┘ ``` ## Per-Tenant Model Isolation ``` Central System ┌───────────────────────────────────────────────────────┐ │ ModelService │ │ tenantRegistries = Map │ └───────────────────────────────────────────────────────┘ │ │ │ ┌────────▼──────┐ ┌─────▼──────┐ ┌────▼───────┐ │Tenant UUID: t1│ │Tenant UUID: │ │Tenant UUID:│ │ │ │ t2 │ │ t3 │ │ ModelRegistry │ │ModelRegistry│ │ModelRegistry│ │ │ │ │ │ │ │Account Model │ │Deal Model │ │Account Model│ │Contact Model │ │Case Model │ │Product Model│ │Product Model │ │Product Model│ │Seller Model │ │ │ │ │ │ │ │Isolated from │ │Isolated from│ │Isolated from│ │t2, t3 │ │t1, t3 │ │t1, t2 │ └───────────────┘ └─────────────┘ └─────────────┘ ``` When tenant1 creates Account: - Account model registered in tenant1's ModelRegistry - Account model NOT visible to tenant2 or tenant3 - Each tenant's models use their own Knex connection ## Field Type to JSON Schema Mapping ``` DynamicModelFactory.fieldToJsonSchema(): TEXT, EMAIL, URL, PHONE → { type: 'string' } LONG_TEXT → { type: 'string' } BOOLEAN → { type: 'boolean', default: false } NUMBER, DECIMAL, CURRENCY → { type: 'number' } INTEGER → { type: 'integer' } DATE → { type: 'string', format: 'date' } DATE_TIME → { type: 'string', format: 'date-time' } LOOKUP, BELONGS_TO → { type: 'string' } PICKLIST, MULTI_PICKLIST → { type: 'string' } ``` System fields (always in JSON schema): ``` id → { type: 'string' } tenantId → { type: 'string' } ownerId → { type: 'string' } name → { type: 'string' } created_at → { type: 'string', format: 'date-time' } updated_at → { type: 'string', format: 'date-time' } Note: System fields NOT in "required" array So users can create records without providing them ``` ## Fallback to Knex ``` try { const model = modelService.getModel(tenantId, apiName); if (model) { boundModel = await modelService.getBoundModel(...); return await boundModel.query().insert(data); } } catch (error) { logger.warn(`Model unavailable, using Knex fallback`); } // Fallback: Direct Knex const tableName = getTableName(apiName); return await knex(tableName).insert({ id: knex.raw('(UUID())'), ...data, created_at: knex.fn.now(), updated_at: knex.fn.now() }); ``` Why fallback? - Model might not be created yet (old objects) - Model creation might have failed (logged with warning) - Ensures system remains functional even if model layer broken - Zero data loss - data written same way to database ## Performance Characteristics ``` Operation Overhead When? ───────────────────────────────────────────────────── Model creation ~10-50ms Once per object definition Model caching lookup ~0ms Every request Model binding to Knex ~1-2ms Every CRUD operation $beforeInsert hook <1ms Every insert $beforeUpdate hook <1ms Every update JSON schema validation ~1-2ms If validation enabled Database round trip 10-100ms Always Total per CRUD: - First request after model creation: 20-55ms - Subsequent requests: 11-102ms (same as Knex fallback) ``` Memory usage: ``` Per Model Class: - Model definition: ~2-5KB - JSON schema: ~1-2KB - Hooks and methods: ~3-5KB ───────────────────────────── Total per model: ~6-12KB For 100 objects: ~600KB-1.2MB For 1000 objects: ~6-12MB Memory efficient compared to database size ```