415 lines
22 KiB
Markdown
415 lines
22 KiB
Markdown
# 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
|
|
```
|