@rip-lang/schema 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/orm.js ADDED
@@ -0,0 +1,916 @@
1
+ // =============================================================================
2
+ // @rip-lang/schema/orm — ActiveRecord-style ORM
3
+ // =============================================================================
4
+ //
5
+ // Rich domain models with inheritance, behavior, schema, and computed fields.
6
+ //
7
+ // Usage:
8
+ // import { Schema } from '@rip-lang/schema/orm'
9
+ //
10
+ // schema = Schema.load './app.schema', import.meta.url
11
+ // schema.connect 'http://localhost:4213'
12
+ //
13
+ // User = schema.model 'User',
14
+ // greet: -> "Hello, #{@name}!"
15
+ // computed:
16
+ // identifier: -> "#{@name} <#{@email}>"
17
+ //
18
+ // Query API:
19
+ // user = await User.find(id)
20
+ // users = await User.all()
21
+ // users = await User.where({ active: true }).all()
22
+ // users = await User.where('score > ?', 90).all()
23
+ // await user.save()
24
+ //
25
+ // =============================================================================
26
+
27
+ import { Schema } from './runtime.js';
28
+ import { toSnakeCase, pluralize } from './emit-sql.js';
29
+ import { Fake } from './faker.js';
30
+ export { Schema, Fake };
31
+
32
+ let _dbUrl = process.env.DB_URL || 'http://localhost:4213';
33
+
34
+ // =============================================================================
35
+ // Query helper
36
+ // =============================================================================
37
+
38
+ async function query(sql, params = []) {
39
+ const body = params.length > 0 ? { sql, params } : { sql };
40
+ const res = await fetch(`${_dbUrl}/sql`, {
41
+ method: 'POST',
42
+ headers: { 'Content-Type': 'application/json' },
43
+ body: JSON.stringify(body),
44
+ });
45
+ const data = await res.json();
46
+ if (data.error) throw new Error(data.error);
47
+ return data;
48
+ }
49
+
50
+ // =============================================================================
51
+ // Temporal filter helper for links
52
+ // =============================================================================
53
+
54
+ function _temporalFilter(at, params) {
55
+ if (at === null) at = new Date().toISOString();
56
+ params.push(at, at);
57
+ return ` AND ("when_from" IS NULL OR "when_from" <= ?) AND ("when_till" IS NULL OR "when_till" >= ?)`;
58
+ }
59
+
60
+ function _toCamelCase(s) {
61
+ return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
62
+ }
63
+
64
+ // =============================================================================
65
+ // Model — Base class for all domain models
66
+ // =============================================================================
67
+
68
+ export class Model {
69
+
70
+ // Class-level configuration (set by subclasses)
71
+ static table = null;
72
+ static database = null;
73
+ static primaryKey = 'id';
74
+ static _schema = {};
75
+ static _computed = {};
76
+ static _hooks = {};
77
+ static _columns = null;
78
+ static _relations = {};
79
+ static _schemaRef = null;
80
+ static _softDelete = false;
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Schema definition (called by subclass)
84
+ // ---------------------------------------------------------------------------
85
+
86
+ static schema(fields) {
87
+ this._schema = fields;
88
+ this._columns = {};
89
+ for (const name in fields) {
90
+ this._columns[name] = fields[name].column || name;
91
+ }
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Computed fields definition (called by subclass)
96
+ // ---------------------------------------------------------------------------
97
+
98
+ static computed(fields) {
99
+ this._computed = fields;
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Load schema from a parsed .schema AST (bridges runtime → ORM)
104
+ // ---------------------------------------------------------------------------
105
+
106
+ static fromSchema(schemaInstance, modelName) {
107
+ const model = schemaInstance.getModel(modelName);
108
+ if (!model) throw new Error(`Model '${modelName}' not found in schema`);
109
+
110
+ // Derive table name: UserProfile → user_profiles, Person → people
111
+ if (!this.table) {
112
+ this.table = pluralize(toSnakeCase(modelName));
113
+ }
114
+
115
+ // Models always have an implicit id primary key (added by emit-sql.js)
116
+ const fields = { id: { type: 'uuid', primary: true } };
117
+
118
+ // Convert runtime field definitions → ORM schema format
119
+ for (const [name, field] of model.fields) {
120
+ const def = {};
121
+ const type = typeof field.type === 'object' && field.type?.array ? 'array' : field.type;
122
+ if (type) def.type = type;
123
+ if (field.required) def.required = true;
124
+ if (field.unique) def.unique = true;
125
+ if (field.constraints) {
126
+ if (field.constraints.min != null) def.min = field.constraints.min;
127
+ if (field.constraints.max != null) def.max = field.constraints.max;
128
+ if (field.constraints.default != null) def.default = field.constraints.default;
129
+ }
130
+ if (field.attrs?.primary) { def.primary = true; }
131
+ // Map camelCase field name → snake_case column name
132
+ const col = toSnakeCase(name);
133
+ if (col !== name) def.column = col;
134
+ fields[name] = def;
135
+ }
136
+
137
+ // Add relationship foreign keys
138
+ if (model.directives?.belongsTo) {
139
+ for (const rel of model.directives.belongsTo) {
140
+ const fk = toSnakeCase(rel.model) + '_id';
141
+ if (!fields[fk]) fields[fk] = { type: 'uuid' };
142
+ }
143
+ }
144
+
145
+ // Add timestamp fields
146
+ if (model.directives?.timestamps) {
147
+ fields.created_at = { type: 'datetime' };
148
+ fields.updated_at = { type: 'datetime' };
149
+ }
150
+
151
+ // Add soft delete field
152
+ if (model.directives?.softDelete) {
153
+ fields.deleted_at = { type: 'datetime' };
154
+ this._softDelete = true;
155
+ }
156
+
157
+ // Store relationship metadata for lazy loading
158
+ const relations = {};
159
+ if (model.directives?.belongsTo) {
160
+ for (const rel of model.directives.belongsTo) {
161
+ const name = rel.model[0].toLowerCase() + rel.model.slice(1);
162
+ relations[name] = { type: 'belongsTo', model: rel.model, foreignKey: toSnakeCase(rel.model) + '_id' };
163
+ }
164
+ }
165
+ if (model.directives?.hasMany) {
166
+ for (const rel of model.directives.hasMany) {
167
+ const name = pluralize(rel.model[0].toLowerCase() + rel.model.slice(1));
168
+ relations[name] = { type: 'hasMany', model: rel.model, foreignKey: toSnakeCase(modelName) + '_id' };
169
+ }
170
+ }
171
+ if (model.directives?.hasOne) {
172
+ for (const rel of model.directives.hasOne) {
173
+ const name = rel.model[0].toLowerCase() + rel.model.slice(1);
174
+ relations[name] = { type: 'hasOne', model: rel.model, foreignKey: toSnakeCase(modelName) + '_id' };
175
+ }
176
+ }
177
+ this._relations = relations;
178
+ this._schemaRef = schemaInstance;
179
+
180
+ // Detect primary key from fields
181
+ for (const [name, def] of Object.entries(fields)) {
182
+ if (def.primary) { this.primaryKey = name; break; }
183
+ }
184
+
185
+ this.schema(fields);
186
+ return this;
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Table name helper
191
+ // ---------------------------------------------------------------------------
192
+
193
+ static tableName() {
194
+ return this.database ? `"${this.database}"."${this.table}"` : `"${this.table}"`;
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Constructor — creates a record instance
199
+ // ---------------------------------------------------------------------------
200
+
201
+ constructor(data = {}, persisted = false) {
202
+ this._data = {};
203
+ this._dirty = {};
204
+ this._persisted = persisted;
205
+
206
+ const schema = this.constructor._schema;
207
+ const columns = this.constructor._columns || {};
208
+
209
+ // Apply schema defaults and initial data
210
+ for (const name in schema) {
211
+ const field = schema[name];
212
+ const col = columns[name] || name;
213
+ if (data[col] != null) {
214
+ this._data[col] = data[col];
215
+ } else if (data[name] != null) {
216
+ this._data[col] = data[name];
217
+ } else if (field.default != null) {
218
+ this._data[col] = typeof field.default === 'function' ? field.default() : field.default;
219
+ }
220
+ }
221
+
222
+ // Define property accessors for schema fields
223
+ for (const name in schema) {
224
+ const col = columns[name] || name;
225
+ Object.defineProperty(this, name, {
226
+ enumerable: true,
227
+ get() { return this._data[col]; },
228
+ set(value) { this._data[col] = value; this._dirty[name] = true; },
229
+ });
230
+ }
231
+
232
+ // Define computed property accessors (reactive getters)
233
+ const computed = this.constructor._computed;
234
+ for (const name in computed) {
235
+ const fn = computed[name];
236
+ Object.defineProperty(this, name, {
237
+ enumerable: true,
238
+ get() { return fn.call(this); },
239
+ });
240
+ }
241
+
242
+ // Define relation methods (lazy loading, with eager cache)
243
+ this._eagerLoaded = null;
244
+ const relations = this.constructor._relations;
245
+ const schemaRef = this.constructor._schemaRef;
246
+ for (const name in relations) {
247
+ const rel = relations[name];
248
+ if (this[name] !== undefined) continue; // don't shadow schema fields (e.g., organization_id)
249
+ Object.defineProperty(this, name, {
250
+ enumerable: false,
251
+ value: async () => {
252
+ // Return eager-loaded data if available (no query)
253
+ if (this._eagerLoaded?.has(name)) return this._eagerLoaded.get(name);
254
+ // Lazy load
255
+ const RelModel = schemaRef._models?.get(rel.model);
256
+ if (!RelModel) throw new Error(`Model '${rel.model}' not registered — define it with schema.model('${rel.model}', ...)`);
257
+ const pk = this._data[this.constructor.primaryKey];
258
+ if (rel.type === 'belongsTo') {
259
+ const fk = this._data[rel.foreignKey];
260
+ return fk != null ? await RelModel.find(fk) : null;
261
+ } else if (rel.type === 'hasMany') {
262
+ return await RelModel.where({ [rel.foreignKey]: pk }).all();
263
+ } else if (rel.type === 'hasOne') {
264
+ return await RelModel.where({ [rel.foreignKey]: pk }).first();
265
+ }
266
+ },
267
+ });
268
+ }
269
+ }
270
+
271
+ // ---------------------------------------------------------------------------
272
+ // Instance state
273
+ // ---------------------------------------------------------------------------
274
+
275
+ get $isNew() { return !this._persisted; }
276
+ get $dirty() { return Object.keys(this._dirty); }
277
+ get $changed() { return Object.keys(this._dirty).length > 0; }
278
+ get $data() { return { ...this._data }; }
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // Validation
282
+ // ---------------------------------------------------------------------------
283
+
284
+ $validate() {
285
+ const errors = [];
286
+ const schema = this.constructor._schema;
287
+ const columns = this.constructor._columns || {};
288
+
289
+ for (const name in schema) {
290
+ const field = schema[name];
291
+ const col = columns[name] || name;
292
+ const value = this._data[col];
293
+
294
+ // Required check
295
+ if (field.required && value == null) {
296
+ errors.push({ field: name, error: 'required', message: `${name} is required` });
297
+ continue;
298
+ }
299
+
300
+ if (value == null) continue; // Skip optional empty fields
301
+
302
+ // Type checks
303
+ switch (field.type) {
304
+ case 'string': case 'text':
305
+ if (typeof value !== 'string') {
306
+ errors.push({ field: name, error: 'type', message: `${name} must be a string` });
307
+ } else {
308
+ if (field.min != null && value.length < field.min)
309
+ errors.push({ field: name, error: 'min', message: `${name} must be at least ${field.min} characters` });
310
+ if (field.max != null && value.length > field.max)
311
+ errors.push({ field: name, error: 'max', message: `${name} must be at most ${field.max} characters` });
312
+ if (field.regex && !field.regex.test(value))
313
+ errors.push({ field: name, error: 'pattern', message: `${name} is invalid` });
314
+ }
315
+ break;
316
+ case 'int': case 'integer':
317
+ if (!Number.isInteger(value)) {
318
+ errors.push({ field: name, error: 'type', message: `${name} must be an integer` });
319
+ } else {
320
+ if (field.min != null && value < field.min)
321
+ errors.push({ field: name, error: 'min', message: `${name} must be >= ${field.min}` });
322
+ if (field.max != null && value > field.max)
323
+ errors.push({ field: name, error: 'max', message: `${name} must be <= ${field.max}` });
324
+ }
325
+ break;
326
+ case 'bool': case 'boolean':
327
+ if (typeof value !== 'boolean')
328
+ errors.push({ field: name, error: 'type', message: `${name} must be a boolean` });
329
+ break;
330
+ case 'email':
331
+ if (typeof value !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
332
+ errors.push({ field: name, error: 'type', message: `${name} must be a valid email` });
333
+ break;
334
+ }
335
+
336
+ // Enum check
337
+ if (field.enum && !field.enum.includes(value))
338
+ errors.push({ field: name, error: 'enum', message: `${name} must be one of: ${field.enum.join(', ')}` });
339
+ }
340
+
341
+ return errors.length > 0 ? errors : null;
342
+ }
343
+
344
+ // ---------------------------------------------------------------------------
345
+ // Lifecycle hooks
346
+ // ---------------------------------------------------------------------------
347
+
348
+ async _runHook(name) {
349
+ const fn = this.constructor._hooks[name];
350
+ if (fn) return await fn.call(this);
351
+ }
352
+
353
+ // ---------------------------------------------------------------------------
354
+ // Persistence
355
+ // ---------------------------------------------------------------------------
356
+
357
+ async save() {
358
+ const isNew = !this._persisted;
359
+
360
+ // Before hooks (can normalize data, return false to abort)
361
+ if (await this._runHook('beforeSave') === false) return this;
362
+ if (isNew && await this._runHook('beforeCreate') === false) return this;
363
+ if (!isNew && await this._runHook('beforeUpdate') === false) return this;
364
+
365
+ // Validate (after hooks have normalized data)
366
+ const errors = this.$validate();
367
+ if (errors) throw new Error(`Validation failed: ${JSON.stringify(errors)}`);
368
+
369
+ const Ctor = this.constructor;
370
+ const tableName = Ctor.tableName();
371
+ const pk = Ctor.primaryKey;
372
+ const schema = Ctor._schema;
373
+ const columns = Ctor._columns || {};
374
+
375
+ // Serialize JSON-typed values for DuckDB
376
+ const _serializeValue = (val, field) => {
377
+ if (field && field.type === 'json' && val != null && typeof val === 'object') {
378
+ return JSON.stringify(val);
379
+ }
380
+ return val;
381
+ };
382
+
383
+ if (this._persisted) {
384
+ // UPDATE — only dirty fields
385
+ const sets = [], values = [];
386
+ for (const name of this.$dirty) {
387
+ const col = columns[name] || name;
388
+ sets.push(`"${col}" = ?`);
389
+ values.push(_serializeValue(this._data[col], schema[name]));
390
+ }
391
+ if (sets.length === 0) return this;
392
+
393
+ const sql = `UPDATE ${tableName} SET ${sets.join(', ')} WHERE "${pk}" = ?`;
394
+ values.push(this._data[pk]);
395
+ await query(sql, values);
396
+ } else {
397
+ // INSERT
398
+ const cols = [], placeholders = [], values = [];
399
+ for (const name in schema) {
400
+ const field = schema[name];
401
+ const col = columns[name] || name;
402
+ if (field.primary && this._data[col] == null) continue; // Skip auto PK
403
+ if (this._data[col] != null) {
404
+ cols.push(`"${col}"`);
405
+ placeholders.push('?');
406
+ values.push(_serializeValue(this._data[col], field));
407
+ }
408
+ }
409
+
410
+ const sql = `INSERT INTO ${tableName} (${cols.join(', ')}) VALUES (${placeholders.join(', ')})`;
411
+ await query(sql, values);
412
+ this._persisted = true;
413
+ }
414
+
415
+ this._dirty = {};
416
+
417
+ // After hooks
418
+ if (isNew) await this._runHook('afterCreate');
419
+ else await this._runHook('afterUpdate');
420
+ await this._runHook('afterSave');
421
+
422
+ return this;
423
+ }
424
+
425
+ async delete() {
426
+ if (!this._persisted) return this;
427
+ if (await this._runHook('beforeDelete') === false) return this;
428
+ const Ctor = this.constructor;
429
+ const pk = Ctor.primaryKey;
430
+ await query(`DELETE FROM ${Ctor.tableName()} WHERE "${pk}" = ?`, [this._data[pk]]);
431
+ this._persisted = false;
432
+ await this._runHook('afterDelete');
433
+ return this;
434
+ }
435
+
436
+ async softDelete() {
437
+ if (!this._persisted) return this;
438
+ const Ctor = this.constructor;
439
+ if (!Ctor._softDelete) throw new Error(`${Ctor.name || 'Model'} does not have @softDelete`);
440
+ if (await this._runHook('beforeDelete') === false) return this;
441
+ const pk = Ctor.primaryKey;
442
+ const now = new Date().toISOString();
443
+ await query(`UPDATE ${Ctor.tableName()} SET "deleted_at" = ? WHERE "${pk}" = ?`, [now, this._data[pk]]);
444
+ this._data.deleted_at = now;
445
+ await this._runHook('afterDelete');
446
+ return this;
447
+ }
448
+
449
+ async restore() {
450
+ if (!this._persisted) return this;
451
+ const Ctor = this.constructor;
452
+ if (!Ctor._softDelete) throw new Error(`${Ctor.name || 'Model'} does not have @softDelete`);
453
+ const pk = Ctor.primaryKey;
454
+ await query(`UPDATE ${Ctor.tableName()} SET "deleted_at" = NULL WHERE "${pk}" = ?`, [this._data[pk]]);
455
+ this._data.deleted_at = null;
456
+ return this;
457
+ }
458
+
459
+ async reload() {
460
+ if (!this._persisted) return this;
461
+ const Ctor = this.constructor;
462
+ const pk = Ctor.primaryKey;
463
+ const record = await Ctor.find(this._data[pk]);
464
+ if (record) {
465
+ this._data = record._data;
466
+ this._dirty = {};
467
+ }
468
+ return this;
469
+ }
470
+
471
+ toJSON() {
472
+ const obj = {};
473
+ for (const name in this.constructor._schema) obj[name] = this[name];
474
+ for (const name in this.constructor._computed) obj[name] = this[name];
475
+ if (this._eagerLoaded) {
476
+ for (const [name, val] of this._eagerLoaded) {
477
+ obj[name] = Array.isArray(val) ? val.map(r => r.toJSON()) : val?.toJSON() ?? null;
478
+ }
479
+ }
480
+ return obj;
481
+ }
482
+
483
+ // ---------------------------------------------------------------------------
484
+ // Links — Universal temporal associations
485
+ // ---------------------------------------------------------------------------
486
+
487
+ async link(role, target, { from = null, till = null } = {}) {
488
+ const sourceType = this.constructor.table;
489
+ const sourceId = this._data[this.constructor.primaryKey];
490
+ const targetType = target.constructor.table;
491
+ const targetId = target._data[target.constructor.primaryKey];
492
+ const sql = `INSERT INTO "links" ("source_type", "source_id", "target_type", "target_id", "role", "when_from", "when_till") VALUES (?, ?, ?, ?, ?, ?, ?)`;
493
+ await query(sql, [sourceType, sourceId, targetType, targetId, role, from, till]);
494
+ return this;
495
+ }
496
+
497
+ async unlink(role, target) {
498
+ const sourceType = this.constructor.table;
499
+ const sourceId = this._data[this.constructor.primaryKey];
500
+ const targetType = target.constructor.table;
501
+ const targetId = target._data[target.constructor.primaryKey];
502
+ const sql = `UPDATE "links" SET "when_till" = CURRENT_TIMESTAMP WHERE "source_type" = ? AND "source_id" = ? AND "target_type" = ? AND "target_id" = ? AND "role" = ? AND "when_till" IS NULL`;
503
+ await query(sql, [sourceType, sourceId, targetType, targetId, role]);
504
+ return this;
505
+ }
506
+
507
+ async links(role, { at = null } = {}) {
508
+ const sourceType = this.constructor.table;
509
+ const sourceId = this._data[this.constructor.primaryKey];
510
+ const params = [sourceType, sourceId];
511
+ let sql = `SELECT * FROM "links" WHERE "source_type" = ? AND "source_id" = ?`;
512
+ if (role) { sql += ` AND "role" = ?`; params.push(role); }
513
+ sql += _temporalFilter(at, params);
514
+ sql += ` ORDER BY "created_at" DESC`;
515
+ const result = await query(sql, params);
516
+ return result.data.map(row => {
517
+ const link = {};
518
+ for (let i = 0; i < result.meta.length; i++) link[_toCamelCase(result.meta[i].name)] = row[i];
519
+ return link;
520
+ });
521
+ }
522
+
523
+ static async linked(role, target, { at = null } = {}) {
524
+ const targetType = target.constructor ? target.constructor.table : target.table;
525
+ const targetId = target.constructor ? target._data[target.constructor.primaryKey] : target._data?.[target.primaryKey];
526
+ const sourceType = this.table;
527
+ const params = [sourceType, targetType, targetId, role];
528
+ let sql = `SELECT "source_id" FROM "links" WHERE "source_type" = ? AND "target_type" = ? AND "target_id" = ? AND "role" = ?`;
529
+ sql += _temporalFilter(at, params);
530
+ const result = await query(sql, params);
531
+ if (result.rows === 0) return [];
532
+ const ids = result.data.map(row => row[0]);
533
+ return this.findMany(ids);
534
+ }
535
+
536
+ // ---------------------------------------------------------------------------
537
+ // Class methods — Query API
538
+ // ---------------------------------------------------------------------------
539
+
540
+ static _materialize(meta, row) {
541
+ const data = {};
542
+ for (let i = 0; i < meta.length; i++) data[meta[i].name] = row[i];
543
+ return new this(data, true);
544
+ }
545
+
546
+ static async find(id) {
547
+ const pk = this.primaryKey;
548
+ const soft = this._softDelete ? ' AND "deleted_at" IS NULL' : '';
549
+ const sql = `SELECT * FROM ${this.tableName()} WHERE "${pk}" = ?${soft} LIMIT 1`;
550
+ const result = await query(sql, [id]);
551
+ if (result.rows === 0) return null;
552
+ return this._materialize(result.meta, result.data[0]);
553
+ }
554
+
555
+ static async findMany(ids) {
556
+ if (ids.length === 0) return [];
557
+ const pk = this.primaryKey;
558
+ const placeholders = ids.map(() => '?').join(', ');
559
+ const soft = this._softDelete ? ' AND "deleted_at" IS NULL' : '';
560
+ const sql = `SELECT * FROM ${this.tableName()} WHERE "${pk}" IN (${placeholders})${soft}`;
561
+ const result = await query(sql, ids);
562
+ return result.data.map(row => this._materialize(result.meta, row));
563
+ }
564
+
565
+ static async all(limit = null) {
566
+ const q = new Query(this);
567
+ if (limit != null) q.limit(limit);
568
+ return q.all();
569
+ }
570
+
571
+ static async first() {
572
+ return new Query(this).first();
573
+ }
574
+
575
+ static where(conditions, ...params) {
576
+ return new Query(this).where(conditions, ...params);
577
+ }
578
+
579
+ static withDeleted() {
580
+ return new Query(this, { includeDeleted: true });
581
+ }
582
+
583
+ static include(...names) {
584
+ return new Query(this).include(...names);
585
+ }
586
+
587
+ static count(conditions = null) {
588
+ const q = new Query(this);
589
+ if (conditions != null) q.where(conditions);
590
+ return q.count();
591
+ }
592
+
593
+ static build(data = {}) {
594
+ return new this(data, false);
595
+ }
596
+
597
+ static async create(data = {}) {
598
+ return await this.build(data).save();
599
+ }
600
+
601
+ // ---------------------------------------------------------------------------
602
+ // Factory — schema-driven fake data generation
603
+ // ---------------------------------------------------------------------------
604
+ // User.factory() → create 1 (persisted, single)
605
+ // User.factory(0) → build 1 (not persisted, single)
606
+ // User.factory(3) → create 3 (persisted, array)
607
+ // User.factory(-3) → build 3 (not persisted, array)
608
+ // User.factory(3, {}) → create 3 with overrides (array)
609
+ // ---------------------------------------------------------------------------
610
+
611
+ static _fake(overrides = {}) {
612
+ const data = {};
613
+ const schema = this._schema;
614
+ const schemaRef = this._schemaRef;
615
+ const s = this._factorySeq = (this._factorySeq || 0) + 1;
616
+
617
+ for (const name in schema) {
618
+ if (overrides[name] != null) { data[name] = overrides[name]; continue; }
619
+
620
+ const field = schema[name];
621
+ if (field.primary) continue;
622
+ if (name === 'created_at' || name === 'updated_at' || name === 'deleted_at') continue;
623
+
624
+ // Use schema default if available
625
+ if (field.default != null) {
626
+ data[name] = typeof field.default === 'function' ? field.default() : field.default;
627
+ continue;
628
+ }
629
+
630
+ // Skip optional fields sometimes (30% chance of null)
631
+ if (!field.required && Math.random() < 0.3) continue;
632
+
633
+ // FK fields — leave for caller to set via overrides
634
+ if (field.type === 'uuid') continue;
635
+
636
+ // Skip nested/composite types (e.g. Address) — can't fake a struct
637
+ if (schemaRef?.types?.has(field.type)) continue;
638
+
639
+ // Resolve enum values if applicable
640
+ const enumVals = schemaRef?.enums?.has(field.type) ? schemaRef.enums.get(field.type) : null;
641
+
642
+ data[name] = Fake.value(name, field, s, enumVals);
643
+ }
644
+ return data;
645
+ }
646
+
647
+ static async factory(num, overrides = {}) {
648
+ // Custom faker on the model takes priority
649
+ const fake = (this._faker)
650
+ ? (ov) => ({ ...this._faker(ov), ...ov })
651
+ : (ov) => this._fake(ov);
652
+
653
+ if (num == null) {
654
+ return this.create(fake(overrides));
655
+ } else if (num === 0) {
656
+ return this.build(fake(overrides));
657
+ } else if (num > 0) {
658
+ return Promise.all(Array.from({ length: num }, () => this.create(fake(overrides))));
659
+ } else {
660
+ return Array.from({ length: -num }, () => this.build(fake(overrides)));
661
+ }
662
+ }
663
+
664
+ }
665
+
666
+ // =============================================================================
667
+ // Query — Chainable query builder
668
+ // =============================================================================
669
+
670
+ class Query {
671
+ constructor(model, { includeDeleted = false } = {}) {
672
+ this._model = model;
673
+ this._where = [];
674
+ this._params = [];
675
+ this._order = null;
676
+ this._limit = null;
677
+ this._offset = null;
678
+ this._includes = [];
679
+ this._includeDeleted = includeDeleted;
680
+
681
+ // Auto-filter soft-deleted records unless explicitly included
682
+ if (model._softDelete && !includeDeleted) {
683
+ this._where.push('"deleted_at" IS NULL');
684
+ }
685
+ }
686
+
687
+ withDeleted() {
688
+ // Remove the auto-added soft-delete filter
689
+ this._where = this._where.filter(w => w !== '"deleted_at" IS NULL');
690
+ this._includeDeleted = true;
691
+ return this;
692
+ }
693
+
694
+ include(...names) {
695
+ this._includes.push(...names.flat());
696
+ return this;
697
+ }
698
+
699
+ // Batch-load included relations (2-query strategy, eliminates N+1)
700
+ async _loadIncludes(records) {
701
+ if (this._includes.length === 0 || records.length === 0) return records;
702
+
703
+ const model = this._model;
704
+ const relations = model._relations;
705
+ const schemaRef = model._schemaRef;
706
+
707
+ for (const relName of this._includes) {
708
+ const rel = relations[relName];
709
+ if (!rel) throw new Error(`Unknown relation '${relName}' on ${model.name || 'Model'}`);
710
+
711
+ const RelModel = schemaRef._models?.get(rel.model);
712
+ if (!RelModel) throw new Error(`Model '${rel.model}' not registered`);
713
+
714
+ if (rel.type === 'belongsTo') {
715
+ const fkValues = [...new Set(records.map(r => r._data[rel.foreignKey]).filter(v => v != null))];
716
+ if (fkValues.length === 0) {
717
+ for (const r of records) { (r._eagerLoaded ||= new Map()).set(relName, null); }
718
+ continue;
719
+ }
720
+ const related = await RelModel.findMany(fkValues);
721
+ const byPk = new Map(related.map(r => [r._data[RelModel.primaryKey], r]));
722
+ for (const r of records) {
723
+ (r._eagerLoaded ||= new Map()).set(relName, byPk.get(r._data[rel.foreignKey]) || null);
724
+ }
725
+
726
+ } else if (rel.type === 'hasMany' || rel.type === 'hasOne') {
727
+ const pk = model.primaryKey;
728
+ const pkValues = records.map(r => r._data[pk]);
729
+ const placeholders = pkValues.map(() => '?').join(', ');
730
+ const soft = RelModel._softDelete ? ' AND "deleted_at" IS NULL' : '';
731
+ const sql = `SELECT * FROM ${RelModel.tableName()} WHERE "${rel.foreignKey}" IN (${placeholders})${soft}`;
732
+ const result = await query(sql, pkValues);
733
+ const allRelated = result.data.map(row => RelModel._materialize(result.meta, row));
734
+
735
+ if (rel.type === 'hasMany') {
736
+ const byFk = new Map();
737
+ for (const r of allRelated) {
738
+ const fk = r._data[rel.foreignKey];
739
+ if (!byFk.has(fk)) byFk.set(fk, []);
740
+ byFk.get(fk).push(r);
741
+ }
742
+ for (const r of records) {
743
+ (r._eagerLoaded ||= new Map()).set(relName, byFk.get(r._data[pk]) || []);
744
+ }
745
+ } else {
746
+ const byFk = new Map();
747
+ for (const r of allRelated) {
748
+ const fk = r._data[rel.foreignKey];
749
+ if (!byFk.has(fk)) byFk.set(fk, r); // first match wins
750
+ }
751
+ for (const r of records) {
752
+ (r._eagerLoaded ||= new Map()).set(relName, byFk.get(r._data[pk]) || null);
753
+ }
754
+ }
755
+ }
756
+ }
757
+ return records;
758
+ }
759
+
760
+ where(conditions, ...params) {
761
+ if (typeof conditions === 'string') {
762
+ this._where.push(conditions);
763
+ this._params.push(...params);
764
+ } else if (typeof conditions === 'object') {
765
+ const columns = this._model._columns || {};
766
+ for (const key in conditions) {
767
+ const col = columns[key] || key;
768
+ this._where.push(`"${col}" = ?`);
769
+ this._params.push(conditions[key]);
770
+ }
771
+ }
772
+ return this;
773
+ }
774
+
775
+ orderBy(column, direction = 'ASC') {
776
+ const columns = this._model._columns || {};
777
+ const col = columns[column] || column;
778
+ this._order = `"${col}" ${direction.toUpperCase()}`;
779
+ return this;
780
+ }
781
+
782
+ limit(n) {
783
+ this._limit = n;
784
+ return this;
785
+ }
786
+
787
+ offset(n) {
788
+ this._offset = n;
789
+ return this;
790
+ }
791
+
792
+ toSQL() {
793
+ let sql = `SELECT * FROM ${this._model.tableName()}`;
794
+ if (this._where.length > 0) sql += ` WHERE ${this._where.join(' AND ')}`;
795
+ if (this._order) sql += ` ORDER BY ${this._order}`;
796
+ if (this._limit != null) sql += ` LIMIT ${this._limit}`;
797
+ if (this._offset != null) sql += ` OFFSET ${this._offset}`;
798
+ return { sql, params: this._params };
799
+ }
800
+
801
+ async all() {
802
+ const { sql, params } = this.toSQL();
803
+ const result = await query(sql, params);
804
+ const records = result.data.map(row => this._model._materialize(result.meta, row));
805
+ return this._loadIncludes(records);
806
+ }
807
+
808
+ async first() {
809
+ const rows = await this.limit(1).all();
810
+ return rows[0] || null;
811
+ }
812
+
813
+ async count() {
814
+ let sql = `SELECT COUNT(*) FROM ${this._model.tableName()}`;
815
+ if (this._where.length > 0) sql += ` WHERE ${this._where.join(' AND ')}`;
816
+ const result = await query(sql, this._params);
817
+ return result.data[0][0];
818
+ }
819
+ }
820
+
821
+ // =============================================================================
822
+ // makeCallable — User(25) → User.find(25)
823
+ // =============================================================================
824
+
825
+ export function makeCallable(ModelClass) {
826
+ const callable = function(idOrIds) {
827
+ if (Array.isArray(idOrIds)) {
828
+ return idOrIds.length === 0 ? ModelClass.all() : ModelClass.findMany(idOrIds);
829
+ }
830
+ return idOrIds != null ? ModelClass.find(idOrIds) : ModelClass.all();
831
+ };
832
+
833
+ // Copy static methods and properties (walk prototype chain to include inherited)
834
+ const seen = new Set(['prototype', 'length', 'name']);
835
+ let cls = ModelClass;
836
+ while (cls && cls !== Function.prototype) {
837
+ for (const key of Object.getOwnPropertyNames(cls)) {
838
+ if (seen.has(key)) continue;
839
+ seen.add(key);
840
+ const desc = Object.getOwnPropertyDescriptor(cls, key);
841
+ if (typeof desc.value === 'function') {
842
+ callable[key] = desc.value.bind(ModelClass);
843
+ } else if (desc) {
844
+ Object.defineProperty(callable, key, desc);
845
+ }
846
+ }
847
+ cls = Object.getPrototypeOf(cls);
848
+ }
849
+
850
+ return callable;
851
+ }
852
+
853
+ // =============================================================================
854
+ // Extend Schema with ORM capabilities
855
+ // =============================================================================
856
+
857
+ Schema.prototype.connect = function(url) {
858
+ _dbUrl = url;
859
+ };
860
+
861
+ Schema.prototype.model = function(modelName, options = {}) {
862
+ if (!this._models) this._models = new Map();
863
+
864
+ const HOOKS = new Set([
865
+ 'beforeSave', 'afterSave', 'beforeCreate', 'afterCreate',
866
+ 'beforeUpdate', 'afterUpdate', 'beforeDelete', 'afterDelete',
867
+ ]);
868
+ const { computed: computedDefs, faker: fakerFn, ...rest } = options;
869
+
870
+ // Separate hooks from instance methods
871
+ const hooks = {}, methods = {};
872
+ for (const [name, fn] of Object.entries(rest)) {
873
+ if (HOOKS.has(name)) hooks[name] = fn; else methods[name] = fn;
874
+ }
875
+
876
+ // Create a new class extending Model
877
+ const ModelClass = class extends Model {};
878
+
879
+ // Add instance methods to the prototype
880
+ for (const [name, fn] of Object.entries(methods)) {
881
+ ModelClass.prototype[name] = fn;
882
+ }
883
+
884
+ // Load schema (table, fields, types, constraints, timestamps, FKs, relations)
885
+ ModelClass.fromSchema(this, modelName);
886
+
887
+ // Add computed properties (getters, no parens needed)
888
+ if (computedDefs) ModelClass.computed(computedDefs);
889
+
890
+ // Set custom faker if provided
891
+ if (fakerFn) ModelClass._faker = fakerFn;
892
+
893
+ // Register lifecycle hooks
894
+ ModelClass._hooks = hooks;
895
+
896
+ // Return callable and register in schema for relation lookups
897
+ const callable = makeCallable(ModelClass);
898
+ this._models.set(modelName, callable);
899
+ return callable;
900
+ };
901
+
902
+ // ---------------------------------------------------------------------------
903
+ // Transactions — atomic multi-model operations
904
+ // ---------------------------------------------------------------------------
905
+
906
+ Schema.prototype.transaction = async function(fn) {
907
+ await query('BEGIN TRANSACTION');
908
+ try {
909
+ const result = await fn();
910
+ await query('COMMIT');
911
+ return result;
912
+ } catch (err) {
913
+ await query('ROLLBACK');
914
+ throw err;
915
+ }
916
+ };