Files
neo/docs/OBJECTION_ARCHITECTURE.md
2026-01-05 07:48:22 +01:00

22 KiB

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<apiName, ModelClass>  │
         │  - getModel(apiName)       │
         │  - registerModel(api, cls) │
         │  - getAllModelNames()      │
         └────────────────────────────┘
                     │
                     ▼
    ┌────────────────────────────────────┐
    │   DynamicModelFactory              │
    │                                    │
    │   createModel(ObjectMetadata)      │
    │   Returns: ModelClass<any>         │
    │                                    │
    │   ┌──────────────────────────────┐ │
    │   │   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<tenantId, ModelRegistry>      │
    └───────────────────────────────────────────────────────┘
             │              │              │
    ┌────────▼──────┐ ┌─────▼──────┐ ┌────▼───────┐
    │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