Files
neo/docs/OBJECTION_ARCHITECTURE.md
Francisco Gaona 516e132611 WIP - move docs
2025-12-24 21:46:05 +01:00

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