@zero-server/sdk 0.9.1 → 0.9.2

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.
Files changed (126) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +460 -443
  3. package/index.js +414 -412
  4. package/lib/app.js +1172 -1172
  5. package/lib/auth/authorize.js +399 -399
  6. package/lib/auth/enrollment.js +367 -367
  7. package/lib/auth/index.js +57 -57
  8. package/lib/auth/jwt.js +731 -731
  9. package/lib/auth/oauth.js +362 -362
  10. package/lib/auth/session.js +588 -588
  11. package/lib/auth/trustedDevice.js +409 -409
  12. package/lib/auth/twoFactor.js +1150 -1150
  13. package/lib/auth/webauthn.js +946 -946
  14. package/lib/body/index.js +14 -14
  15. package/lib/body/json.js +109 -109
  16. package/lib/body/multipart.js +440 -440
  17. package/lib/body/raw.js +71 -71
  18. package/lib/body/rawBuffer.js +160 -160
  19. package/lib/body/sendError.js +25 -25
  20. package/lib/body/text.js +75 -75
  21. package/lib/body/typeMatch.js +41 -41
  22. package/lib/body/urlencoded.js +235 -235
  23. package/lib/cli.js +845 -845
  24. package/lib/cluster.js +666 -666
  25. package/lib/debug.js +372 -372
  26. package/lib/env/index.js +465 -465
  27. package/lib/errors.js +683 -683
  28. package/lib/fetch/index.js +256 -256
  29. package/lib/grpc/balancer.js +378 -378
  30. package/lib/grpc/call.js +708 -708
  31. package/lib/grpc/client.js +764 -764
  32. package/lib/grpc/codec.js +1221 -1221
  33. package/lib/grpc/credentials.js +398 -398
  34. package/lib/grpc/frame.js +262 -262
  35. package/lib/grpc/health.js +287 -287
  36. package/lib/grpc/index.js +121 -121
  37. package/lib/grpc/metadata.js +461 -461
  38. package/lib/grpc/proto.js +821 -821
  39. package/lib/grpc/reflection.js +590 -590
  40. package/lib/grpc/server.js +445 -445
  41. package/lib/grpc/status.js +118 -118
  42. package/lib/grpc/watch.js +173 -173
  43. package/lib/http/index.js +10 -10
  44. package/lib/http/request.js +727 -727
  45. package/lib/http/response.js +799 -799
  46. package/lib/lifecycle.js +557 -557
  47. package/lib/middleware/compress.js +230 -230
  48. package/lib/middleware/cookieParser.js +237 -237
  49. package/lib/middleware/cors.js +93 -93
  50. package/lib/middleware/csrf.js +137 -137
  51. package/lib/middleware/errorHandler.js +101 -101
  52. package/lib/middleware/helmet.js +175 -175
  53. package/lib/middleware/index.js +19 -17
  54. package/lib/middleware/logger.js +74 -74
  55. package/lib/middleware/rateLimit.js +88 -88
  56. package/lib/middleware/requestId.js +53 -53
  57. package/lib/middleware/static.js +326 -326
  58. package/lib/middleware/timeout.js +71 -71
  59. package/lib/middleware/validator.js +255 -255
  60. package/lib/observe/health.js +326 -326
  61. package/lib/observe/index.js +50 -50
  62. package/lib/observe/logger.js +359 -359
  63. package/lib/observe/metrics.js +805 -805
  64. package/lib/observe/tracing.js +592 -592
  65. package/lib/orm/adapters/json.js +290 -290
  66. package/lib/orm/adapters/memory.js +764 -764
  67. package/lib/orm/adapters/mongo.js +764 -764
  68. package/lib/orm/adapters/mysql.js +933 -933
  69. package/lib/orm/adapters/postgres.js +1144 -1144
  70. package/lib/orm/adapters/redis.js +1534 -1534
  71. package/lib/orm/adapters/sql-base.js +212 -212
  72. package/lib/orm/adapters/sqlite.js +858 -858
  73. package/lib/orm/audit.js +649 -649
  74. package/lib/orm/cache.js +394 -394
  75. package/lib/orm/geo.js +387 -387
  76. package/lib/orm/index.js +784 -784
  77. package/lib/orm/migrate.js +432 -432
  78. package/lib/orm/model.js +1706 -1706
  79. package/lib/orm/plugin.js +375 -375
  80. package/lib/orm/procedures.js +836 -836
  81. package/lib/orm/profiler.js +233 -233
  82. package/lib/orm/query.js +1772 -1772
  83. package/lib/orm/replicas.js +241 -241
  84. package/lib/orm/schema.js +307 -307
  85. package/lib/orm/search.js +380 -380
  86. package/lib/orm/seed/data/commerce.js +136 -136
  87. package/lib/orm/seed/data/internet.js +111 -111
  88. package/lib/orm/seed/data/locations.js +204 -204
  89. package/lib/orm/seed/data/names.js +338 -338
  90. package/lib/orm/seed/data/person.js +128 -128
  91. package/lib/orm/seed/data/phone.js +211 -211
  92. package/lib/orm/seed/data/words.js +134 -134
  93. package/lib/orm/seed/factory.js +178 -178
  94. package/lib/orm/seed/fake.js +1186 -1186
  95. package/lib/orm/seed/index.js +18 -18
  96. package/lib/orm/seed/rng.js +70 -70
  97. package/lib/orm/seed/seeder.js +124 -124
  98. package/lib/orm/seed/unique.js +68 -68
  99. package/lib/orm/snapshot.js +366 -366
  100. package/lib/orm/tenancy.js +605 -605
  101. package/lib/orm/views.js +350 -350
  102. package/lib/router/index.js +436 -436
  103. package/lib/sse/index.js +8 -8
  104. package/lib/sse/stream.js +349 -349
  105. package/lib/ws/connection.js +451 -451
  106. package/lib/ws/handshake.js +125 -125
  107. package/lib/ws/index.js +14 -14
  108. package/lib/ws/room.js +223 -223
  109. package/package.json +73 -73
  110. package/types/app.d.ts +223 -223
  111. package/types/auth.d.ts +520 -520
  112. package/types/cluster.d.ts +75 -75
  113. package/types/env.d.ts +80 -80
  114. package/types/errors.d.ts +316 -316
  115. package/types/fetch.d.ts +43 -43
  116. package/types/grpc.d.ts +432 -432
  117. package/types/index.d.ts +384 -384
  118. package/types/lifecycle.d.ts +60 -60
  119. package/types/middleware.d.ts +320 -320
  120. package/types/observe.d.ts +304 -304
  121. package/types/orm.d.ts +1887 -1887
  122. package/types/request.d.ts +109 -109
  123. package/types/response.d.ts +157 -157
  124. package/types/router.d.ts +78 -78
  125. package/types/sse.d.ts +78 -78
  126. package/types/websocket.d.ts +126 -126
package/lib/orm/model.js CHANGED
@@ -1,1706 +1,1706 @@
1
- /**
2
- * @module orm/model
3
- * @description Base Model class for defining database-backed entities.
4
- * Provides static CRUD methods, instance-level save/update/delete,
5
- * lifecycle hooks, relationship definitions, computed/virtual columns,
6
- * attribute casting, model events & observers, and advanced relationships.
7
- *
8
- * @example
9
- * const { Model, Database } = require('@zero-server/sdk');
10
- *
11
- * class User extends Model {
12
- * static table = 'users';
13
- * static schema = {
14
- * id: { type: 'integer', primaryKey: true, autoIncrement: true },
15
- * name: { type: 'string', required: true, maxLength: 100 },
16
- * email: { type: 'string', required: true, unique: true },
17
- * role: { type: 'string', enum: ['user','admin'], default: 'user' },
18
- * };
19
- * static timestamps = true; // auto createdAt/updatedAt
20
- * static softDelete = true; // deletedAt instead of real delete
21
- * }
22
- *
23
- * db.register(User);
24
- *
25
- * const user = await User.create({ name: 'Alice', email: 'a@b.com' });
26
- * const users = await User.find({ role: 'admin' });
27
- * const u = await User.findById(1);
28
- * await u.update({ name: 'Alice2' });
29
- * await u.delete();
30
- */
31
- const { validate } = require('./schema');
32
- const Query = require('./query');
33
- const crypto = require('crypto');
34
- const log = require('../debug')('zero:orm');
35
- const { ValidationError, DatabaseError } = require('../errors');
36
- const { EventEmitter } = require('events');
37
-
38
- class Model
39
- {
40
- /**
41
- * Table name — override in subclass.
42
- * @type {string}
43
- */
44
- static table = '';
45
-
46
- /**
47
- * Column schema — override in subclass.
48
- * @type {Object<string, object>}
49
- */
50
- static schema = {};
51
-
52
- /**
53
- * Enable auto timestamps (createdAt, updatedAt).
54
- * @type {boolean}
55
- */
56
- static timestamps = false;
57
-
58
- /**
59
- * Enable soft deletes (deletedAt instead of real deletion).
60
- * @type {boolean}
61
- */
62
- static softDelete = false;
63
-
64
- /**
65
- * Fields to hide from toJSON() serialization.
66
- * Useful for excluding passwords, tokens, internal fields.
67
- * @type {string[]}
68
- *
69
- * @example
70
- * class User extends Model {
71
- * static hidden = ['password', 'resetToken'];
72
- * }
73
- */
74
- static hidden = [];
75
-
76
- /**
77
- * Named query scopes — reusable query conditions.
78
- * Each scope is a function that receives a Query and returns it.
79
- * @type {Object<string, Function>}
80
- *
81
- * @example
82
- * class User extends Model {
83
- * static scopes = {
84
- * active: q => q.where('active', true),
85
- * admins: q => q.where('role', 'admin'),
86
- * olderThan: (q, age) => q.where('age', '>', age),
87
- * };
88
- * }
89
- *
90
- * // Use:
91
- * await User.scope('active').scope('admins').limit(5);
92
- * await User.scope('olderThan', 30);
93
- */
94
- static scopes = {};
95
-
96
- /**
97
- * Lifecycle hooks.
98
- * Override these in subclasses: `static beforeCreate(data) { return data; }`
99
- * @type {object}
100
- */
101
- static hooks = {};
102
-
103
- // -- Computed & Virtual Columns ---------------------
104
-
105
- /**
106
- * Computed column definitions — virtual columns derived from other fields.
107
- * Not stored in the database; calculated on the fly.
108
- * Each entry maps a column name to a getter function `(instance) => value`.
109
- * @type {Object<string, Function>}
110
- *
111
- * @example
112
- * class User extends Model {
113
- * static computed = {
114
- * fullName: (user) => `${user.firstName} ${user.lastName}`,
115
- * isAdmin: (user) => user.role === 'admin',
116
- * };
117
- * }
118
- *
119
- * const user = await User.findById(1);
120
- * user.fullName // => 'Alice Smith'
121
- */
122
- static computed = {};
123
-
124
- /**
125
- * Attribute casts — automatic type transformations on get/set.
126
- * Maps column names to cast types or custom cast objects.
127
- *
128
- * Built-in cast types:
129
- * - `'json'` — JSON.parse on get, JSON.stringify on set
130
- * - `'boolean'` — Cast to true/false
131
- * - `'integer'` — parseInt
132
- * - `'float'` — parseFloat
133
- * - `'date'` — Cast to Date object
134
- * - `'string'` — Cast to String
135
- * - `'array'` — JSON parse/stringify for array data
136
- *
137
- * Custom casts:
138
- * - `{ get: (v) => transformed, set: (v) => transformed }`
139
- *
140
- * @type {Object<string, string|{ get: Function, set: Function }>}
141
- *
142
- * @example
143
- * class Settings extends Model {
144
- * static casts = {
145
- * preferences: 'json',
146
- * isActive: 'boolean',
147
- * loginCount: 'integer',
148
- * tags: 'array',
149
- * metadata: {
150
- * get: (v) => v ? JSON.parse(v) : {},
151
- * set: (v) => JSON.stringify(v),
152
- * },
153
- * };
154
- * }
155
- */
156
- static casts = {};
157
-
158
- /**
159
- * Custom attribute accessors (getters).
160
- * These transform values when reading from the model instance.
161
- * Each entry maps a column name to a function `(value, instance) => transformedValue`.
162
- * @type {Object<string, Function>}
163
- *
164
- * @example
165
- * class User extends Model {
166
- * static accessors = {
167
- * email: (val) => val ? val.toLowerCase() : val,
168
- * name: (val) => val ? val.trim() : val,
169
- * };
170
- * }
171
- */
172
- static accessors = {};
173
-
174
- /**
175
- * Custom attribute mutators (setters).
176
- * These transform values before writing to the model instance.
177
- * Each entry maps a column name to a function `(value, instance) => transformedValue`.
178
- * @type {Object<string, Function>}
179
- *
180
- * @example
181
- * class User extends Model {
182
- * static mutators = {
183
- * email: (val) => val ? val.toLowerCase().trim() : val,
184
- * password: (val) => hashSync(val),
185
- * };
186
- * }
187
- */
188
- static mutators = {};
189
-
190
- // -- Model Events -----------------------------------
191
-
192
- /**
193
- * Internal event emitter for model-level events.
194
- * @type {EventEmitter|null}
195
- * @private
196
- */
197
- static _emitter = null;
198
-
199
- /**
200
- * Registered observers for this model.
201
- * @type {object[]}
202
- * @private
203
- */
204
- static _observers = [];
205
-
206
- /**
207
- * Relationship definitions.
208
- * @type {object}
209
- * @private
210
- */
211
- static _relations = {};
212
-
213
- /**
214
- * Database adapter reference — set by Database.register().
215
- * @type {object|null}
216
- * @private
217
- */
218
- static _adapter = null;
219
-
220
- // -- Constructor ------------------------------------
221
-
222
- /**
223
- * @constructor
224
- * Create a model instance from a data row.
225
- * Generally you won't call this directly — use static methods.
226
- *
227
- * @param {object} data - Row data.
228
- */
229
- constructor(data = {})
230
- {
231
- /** @type {boolean} Whether this instance exists in the database. */
232
- this._persisted = false;
233
-
234
- /** @type {object} The original data snapshot for dirty tracking. */
235
- this._original = {};
236
-
237
- // Assign data to instance (filter prototype pollution keys)
238
- const mutators = this.constructor.mutators || {};
239
- const casts = this.constructor.casts || {};
240
- for (const key of Object.keys(data))
241
- {
242
- if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
243
- let val = data[key];
244
- // Apply mutator if defined
245
- if (typeof mutators[key] === 'function')
246
- {
247
- val = mutators[key](val, this);
248
- }
249
- // Apply cast set if defined
250
- else if (casts[key])
251
- {
252
- val = Model._applyCastSet(val, casts[key]);
253
- }
254
- this[key] = val;
255
- }
256
- }
257
-
258
- // -- Instance Methods -------------------------------
259
-
260
- /**
261
- * Save this instance to the database. Insert if new, update if persisted.
262
- * @returns {Promise<Model>} `this`
263
- */
264
- async save()
265
- {
266
- const ctor = this.constructor;
267
- if (this._persisted)
268
- {
269
- const pk = ctor._primaryKey();
270
- const changes = this._dirtyFields();
271
- if (Object.keys(changes).length === 0) return this;
272
-
273
- if (ctor.timestamps && ctor._fullSchema().updatedAt)
274
- {
275
- changes.updatedAt = new Date();
276
- }
277
-
278
- await ctor._runHook('beforeUpdate', changes);
279
- const { valid, errors, sanitized } = validate(changes, ctor._fullSchema(), { partial: true });
280
- if (!valid) throw new ValidationError('Validation failed: ' + errors.join(', '), errors);
281
-
282
- try { await ctor._adapter.update(ctor.table, pk, this[pk], sanitized); }
283
- catch (e) { log.error('%s update failed: %s', ctor.table, e.message); throw e; }
284
- log.debug('%s update id=%s', ctor.table, this[pk]);
285
- Object.assign(this, sanitized);
286
- await ctor._runHook('afterUpdate', this);
287
- this._snapshot();
288
- }
289
- else
290
- {
291
- const data = this._toData();
292
-
293
- if (ctor.timestamps)
294
- {
295
- const now = new Date();
296
- if (ctor._fullSchema().createdAt && !data.createdAt) data.createdAt = now;
297
- if (ctor._fullSchema().updatedAt && !data.updatedAt) data.updatedAt = now;
298
- }
299
-
300
- await ctor._runHook('beforeCreate', data);
301
- const { valid, errors, sanitized } = validate(data, ctor._fullSchema());
302
- if (!valid) throw new ValidationError('Validation failed: ' + errors.join(', '), errors);
303
-
304
- let result;
305
- try { result = await ctor._adapter.insert(ctor.table, sanitized); }
306
- catch (e) { log.error('%s insert failed: %s', ctor.table, e.message); throw e; }
307
- log.debug('%s insert', ctor.table);
308
- const pk = ctor._primaryKey();
309
- if (result && result[pk] !== undefined) this[pk] = result[pk];
310
- Object.assign(this, sanitized);
311
- this._persisted = true;
312
- await ctor._runHook('afterCreate', this);
313
- this._snapshot();
314
- }
315
- return this;
316
- }
317
-
318
- /**
319
- * Update specific fields on this instance.
320
- * @param {object} data - Fields to update.
321
- * @returns {Promise<Model>} `this`
322
- */
323
- async update(data)
324
- {
325
- Object.assign(this, this.constructor._stripGuarded(data));
326
- return this.save();
327
- }
328
-
329
- /**
330
- * Delete this instance from the database.
331
- * If softDelete is enabled, sets deletedAt instead.
332
- * @returns {Promise<void>}
333
- */
334
- async delete()
335
- {
336
- const ctor = this.constructor;
337
- const pk = ctor._primaryKey();
338
-
339
- await ctor._runHook('beforeDelete', this);
340
-
341
- if (ctor.softDelete)
342
- {
343
- this.deletedAt = new Date();
344
- try { await ctor._adapter.update(ctor.table, pk, this[pk], { deletedAt: this.deletedAt }); }
345
- catch (e) { log.error('%s soft-delete failed: %s', ctor.table, e.message); throw e; }
346
- }
347
- else
348
- {
349
- try { await ctor._adapter.remove(ctor.table, pk, this[pk]); }
350
- catch (e) { log.error('%s delete failed: %s', ctor.table, e.message); throw e; }
351
- }
352
-
353
- log.debug('%s delete id=%s', ctor.table, this[pk]);
354
-
355
- await ctor._runHook('afterDelete', this);
356
- this._persisted = false;
357
- }
358
-
359
- /**
360
- * Restore a soft-deleted record.
361
- * @returns {Promise<Model>} `this`
362
- */
363
- async restore()
364
- {
365
- const ctor = this.constructor;
366
- if (!ctor.softDelete) throw new Error('Model does not use soft deletes');
367
- const pk = ctor._primaryKey();
368
- this.deletedAt = null;
369
- try { await ctor._adapter.update(ctor.table, pk, this[pk], { deletedAt: null }); }
370
- catch (e) { log.error('%s restore failed: %s', ctor.table, e.message); throw e; }
371
- return this;
372
- }
373
-
374
- /**
375
- * Increment a numeric field atomically.
376
- *
377
- * @param {string} field - Column name to increment.
378
- * @param {number} [by=1] - Amount to increment by.
379
- * @returns {Promise<Model>} `this`
380
- *
381
- * @example
382
- * await post.increment('views');
383
- * await product.increment('stock', 10);
384
- */
385
- async increment(field, by = 1)
386
- {
387
- const ctor = this.constructor;
388
- const pk = ctor._primaryKey();
389
- this[field] = (Number(this[field]) || 0) + by;
390
- const update = { [field]: this[field] };
391
- if (ctor.timestamps && ctor._fullSchema().updatedAt)
392
- {
393
- update.updatedAt = new Date();
394
- this.updatedAt = update.updatedAt;
395
- }
396
- await ctor._adapter.update(ctor.table, pk, this[pk], update);
397
- log.debug('%s increment %s by %d', ctor.table, field, by);
398
- this._snapshot();
399
- return this;
400
- }
401
-
402
- /**
403
- * Decrement a numeric field atomically.
404
- *
405
- * @param {string} field - Column name to decrement.
406
- * @param {number} [by=1] - Amount to decrement by.
407
- * @returns {Promise<Model>} `this`
408
- *
409
- * @example
410
- * await product.decrement('stock');
411
- * await account.decrement('balance', 50);
412
- */
413
- async decrement(field, by = 1)
414
- {
415
- return this.increment(field, -by);
416
- }
417
-
418
- /**
419
- * Reload this instance from the database.
420
- * @returns {Promise<Model>} `this`
421
- */
422
- async reload()
423
- {
424
- const ctor = this.constructor;
425
- const pk = ctor._primaryKey();
426
- const fresh = await ctor.findById(this[pk]);
427
- if (!fresh) throw new Error('Record not found');
428
- Object.assign(this, fresh);
429
- this._snapshot();
430
- return this;
431
- }
432
-
433
- /**
434
- * Convert to plain object (for JSON serialization).
435
- * Respects `static hidden = [...]` to exclude sensitive fields.
436
- * Includes computed columns and applies accessor transformations.
437
- * @returns {object} Plain data object with hidden fields excluded.
438
- */
439
- toJSON()
440
- {
441
- const data = {};
442
- const ctor = this.constructor;
443
- const schema = ctor._fullSchema();
444
- const hidden = ctor.hidden || [];
445
- const accessors = ctor.accessors || {};
446
- const casts = ctor.casts || {};
447
- for (const key of Object.keys(schema))
448
- {
449
- if (this[key] !== undefined && !hidden.includes(key))
450
- {
451
- let val = this[key];
452
- // Apply accessor if defined
453
- if (typeof accessors[key] === 'function')
454
- {
455
- val = accessors[key](val, this);
456
- }
457
- // Apply cast get if defined (and no accessor)
458
- else if (casts[key])
459
- {
460
- val = Model._applyCastGet(val, casts[key]);
461
- }
462
- data[key] = val;
463
- }
464
- }
465
- // Include computed columns
466
- const computed = ctor.computed || {};
467
- for (const [name, fn] of Object.entries(computed))
468
- {
469
- if (!hidden.includes(name) && typeof fn === 'function')
470
- {
471
- data[name] = fn(this);
472
- }
473
- }
474
- return data;
475
- }
476
-
477
- // -- Internal Instance Helpers ----------------------
478
-
479
- /** @private Snapshot current data for dirty tracking. */
480
- _snapshot()
481
- {
482
- this._original = { ...this._toData() };
483
- }
484
-
485
- /** @private Get only data columns (exclude internal props). */
486
- _toData()
487
- {
488
- const data = {};
489
- const schema = this.constructor._fullSchema();
490
- for (const key of Object.keys(schema))
491
- {
492
- if (this[key] !== undefined) data[key] = this[key];
493
- }
494
- return data;
495
- }
496
-
497
- /** @private Get fields that changed since last snapshot. */
498
- _dirtyFields()
499
- {
500
- const data = this._toData();
501
- const changes = {};
502
- for (const [k, v] of Object.entries(data))
503
- {
504
- if (v !== this._original[k]) changes[k] = v;
505
- }
506
- return changes;
507
- }
508
-
509
- // -- Static CRUD ------------------------------------
510
-
511
- /**
512
- * Create and persist a new record.
513
- *
514
- * @param {object} data - Record data.
515
- * @returns {Promise<Model>} The created instance.
516
- */
517
- static async create(data)
518
- {
519
- const instance = new this(this._stripGuarded(data));
520
- return instance.save();
521
- }
522
-
523
- /**
524
- * Create multiple records at once.
525
- * Uses batch INSERT when the adapter supports it (much faster for SQL databases).
526
- *
527
- * @param {object[]} dataArray - Array of record data.
528
- * @returns {Promise<Model[]>} Created model instances.
529
- */
530
- static async createMany(dataArray)
531
- {
532
- if (!dataArray.length) return [];
533
-
534
- // Validate, apply hooks & timestamps for each row
535
- const fullSchema = this._fullSchema();
536
- const sanitizedRows = [];
537
- for (const data of dataArray)
538
- {
539
- const row = this._stripGuarded({ ...data });
540
- if (this.timestamps)
541
- {
542
- const now = new Date();
543
- if (fullSchema.createdAt && !row.createdAt) row.createdAt = now;
544
- if (fullSchema.updatedAt && !row.updatedAt) row.updatedAt = now;
545
- }
546
- await this._runHook('beforeCreate', row);
547
- const { valid, errors, sanitized } = validate(row, fullSchema);
548
- if (!valid) throw new ValidationError('Validation failed: ' + errors.join(', '), errors);
549
- sanitizedRows.push(sanitized);
550
- }
551
-
552
- // Use batch insertMany if adapter supports it
553
- if (typeof this._adapter.insertMany === 'function')
554
- {
555
- let results;
556
- try { results = await this._adapter.insertMany(this.table, sanitizedRows); }
557
- catch (e) { log.error('%s insertMany failed: %s', this.table, e.message); throw e; }
558
-
559
- const instances = results.map(row => {
560
- const inst = this._fromRow(row);
561
- return inst;
562
- });
563
-
564
- for (const inst of instances) await this._runHook('afterCreate', inst);
565
- return instances;
566
- }
567
-
568
- // Fallback: individual inserts
569
- return Promise.all(dataArray.map(d => this.create(d)));
570
- }
571
-
572
- /**
573
- * Find records matching conditions.
574
- *
575
- * @param {object} [conditions={}] - WHERE conditions `{ key: value }`.
576
- * @returns {Promise<Model[]>} Matching records.
577
- */
578
- static async find(conditions = {})
579
- {
580
- const q = this.query().where(conditions);
581
- return q.exec();
582
- }
583
-
584
- /**
585
- * Find a single record matching conditions.
586
- *
587
- * @param {object} conditions - WHERE conditions.
588
- * @returns {Promise<Model|null>} First matching record, or null.
589
- */
590
- static async findOne(conditions)
591
- {
592
- return this.query().where(conditions).first();
593
- }
594
-
595
- /**
596
- * Find a record by primary key.
597
- *
598
- * @param {*} id - Primary key value.
599
- * @returns {Promise<Model|null>} Matching record, or null.
600
- */
601
- static async findById(id)
602
- {
603
- const pk = this._primaryKey();
604
- return this.query().where(pk, id).first();
605
- }
606
-
607
- /**
608
- * Find one or create if not found.
609
- *
610
- * @param {object} conditions - Search conditions.
611
- * @param {object} [defaults={}] - Additional data for creation.
612
- * @returns {Promise<{ instance: Model, created: boolean }>}
613
- */
614
- static async findOrCreate(conditions, defaults = {})
615
- {
616
- const existing = await this.findOne(conditions);
617
- if (existing) return { instance: existing, created: false };
618
- const instance = await this.create({ ...conditions, ...defaults });
619
- return { instance, created: true };
620
- }
621
-
622
- /**
623
- * Update records matching conditions.
624
- *
625
- * @param {object} conditions - WHERE conditions.
626
- * @param {object} data - Fields to update.
627
- * @returns {Promise<number>} Number of updated records.
628
- */
629
- static async updateWhere(conditions, data)
630
- {
631
- data = this._stripGuarded(data);
632
- if (this.timestamps && this._fullSchema().updatedAt)
633
- {
634
- data.updatedAt = new Date();
635
- }
636
- await this._runHook('beforeUpdate', data);
637
- try { return await this._adapter.updateWhere(this.table, conditions, data); }
638
- catch (e) { log.error('%s updateWhere failed: %s', this.table, e.message); throw e; }
639
- }
640
-
641
- /**
642
- * Delete records matching conditions.
643
- *
644
- * @param {object} conditions - WHERE conditions.
645
- * @returns {Promise<number>} Number of deleted records.
646
- */
647
- static async deleteWhere(conditions)
648
- {
649
- if (this.softDelete)
650
- {
651
- try { return await this._adapter.updateWhere(this.table, conditions, { deletedAt: new Date() }); }
652
- catch (e) { log.error('%s deleteWhere (soft) failed: %s', this.table, e.message); throw e; }
653
- }
654
- try { return await this._adapter.deleteWhere(this.table, conditions); }
655
- catch (e) { log.error('%s deleteWhere failed: %s', this.table, e.message); throw e; }
656
- }
657
-
658
- /**
659
- * Count records matching conditions.
660
- *
661
- * @param {object} [conditions={}] - WHERE conditions.
662
- * @returns {Promise<number>} Number of matching records.
663
- */
664
- static async count(conditions = {})
665
- {
666
- return this.query().where(conditions).count();
667
- }
668
-
669
- /**
670
- * Check whether any records matching conditions exist.
671
- *
672
- * @param {object} [conditions={}] - WHERE conditions.
673
- * @returns {Promise<boolean>} True if any matching records exist.
674
- *
675
- * @example
676
- * if (await User.exists({ email: 'a@b.com' })) { ... }
677
- */
678
- static async exists(conditions = {})
679
- {
680
- return this.query().where(conditions).exists();
681
- }
682
-
683
- /**
684
- * Insert or update a record matching conditions.
685
- * If a matching record exists, update it. Otherwise, create a new one.
686
- *
687
- * @param {object} conditions - Search conditions (unique fields).
688
- * @param {object} data - Data to set (merged with conditions on create).
689
- * @returns {Promise<{ instance: Model, created: boolean }>}
690
- *
691
- * @example
692
- * const { instance, created } = await User.upsert(
693
- * { email: 'a@b.com' },
694
- * { name: 'Alice', role: 'admin' }
695
- * );
696
- */
697
- static async upsert(conditions, data = {})
698
- {
699
- const existing = await this.findOne(conditions);
700
- if (existing)
701
- {
702
- await existing.update(data);
703
- return { instance: existing, created: false };
704
- }
705
- const instance = await this.create({ ...conditions, ...data });
706
- return { instance, created: true };
707
- }
708
-
709
- /**
710
- * Start a query with a named scope applied.
711
- *
712
- * @param {string} name - Scope name (from `static scopes`).
713
- * @param {...*} [args] - Additional arguments passed to the scope function.
714
- * @returns {Query} Scoped query builder.
715
- *
716
- * @example
717
- * await User.scope('active').where('role', 'admin');
718
- * await User.scope('olderThan', 21).limit(10);
719
- */
720
- static scope(name, ...args)
721
- {
722
- if (!this.scopes || typeof this.scopes[name] !== 'function')
723
- {
724
- throw new Error(`Unknown scope "${name}" on ${this.name}`);
725
- }
726
- const q = this.query();
727
- this.scopes[name](q, ...args);
728
- return q;
729
- }
730
-
731
- /**
732
- * Start a fluent query builder.
733
- *
734
- * @returns {Query} New fluent query builder.
735
- *
736
- * @example
737
- * const results = await User.query()
738
- * .where('age', '>', 18)
739
- * .orderBy('name')
740
- * .limit(10);
741
- */
742
- static query()
743
- {
744
- if (!this._adapter) throw new Error(`Model "${this.name}" is not registered with a database`);
745
- const q = new Query(this, this._adapter);
746
-
747
- // Auto-exclude soft-deleted records
748
- if (this.softDelete)
749
- {
750
- q.whereNull('deletedAt');
751
- }
752
-
753
- return q;
754
- }
755
-
756
- // -- LINQ-Inspired Static Shortcuts -----------------
757
-
758
- /**
759
- * Find the first record matching optional conditions.
760
- *
761
- * @param {object} [conditions={}] - WHERE conditions.
762
- * @returns {Promise<Model|null>} First matching record, or null.
763
- *
764
- * @example
765
- * const admin = await User.first({ role: 'admin' });
766
- * const oldest = await User.first(); // first by PK
767
- */
768
- static async first(conditions = {})
769
- {
770
- return this.query().where(conditions).first();
771
- }
772
-
773
- /**
774
- * Find the last record matching optional conditions.
775
- *
776
- * @param {object} [conditions={}] - WHERE conditions.
777
- * @returns {Promise<Model|null>} Last matching record, or null.
778
- *
779
- * @example
780
- * const newest = await User.last();
781
- * const lastAdmin = await User.last({ role: 'admin' });
782
- */
783
- static async last(conditions = {})
784
- {
785
- return this.query().where(conditions).last();
786
- }
787
-
788
- /**
789
- * Rich pagination with metadata.
790
- * Returns `{ data, total, page, perPage, pages, hasNext, hasPrev }`.
791
- *
792
- * @param {number} page - 1-indexed page number.
793
- * @param {number} [perPage=20] - Items per page.
794
- * @param {object} [conditions={}] - Optional WHERE conditions.
795
- * @returns {Promise<object>} Pagination result with data, total, page, perPage, pages, hasNext, hasPrev.
796
- *
797
- * @example
798
- * const result = await User.paginate(2, 10, { role: 'admin' });
799
- * // { data: [...], total: 53, page: 2, perPage: 10,
800
- * // pages: 6, hasNext: true, hasPrev: true }
801
- */
802
- static async paginate(page, perPage = 20, conditions = {})
803
- {
804
- return this.query().where(conditions).paginate(page, perPage);
805
- }
806
-
807
- /**
808
- * Process all matching records in batches.
809
- * Calls `fn(batch, batchIndex)` for each chunk.
810
- *
811
- * @param {number} size - Batch size.
812
- * @param {Function} fn - Called with (batch: Model[], index: number).
813
- * @param {object} [conditions={}] - Optional WHERE conditions.
814
- * @returns {Promise<void>}
815
- *
816
- * @example
817
- * await User.chunk(100, async (users, i) => {
818
- * for (const u of users) await u.update({ migrated: true });
819
- * }, { active: true });
820
- */
821
- static async chunk(size, fn, conditions = {})
822
- {
823
- return this.query().where(conditions).chunk(size, fn);
824
- }
825
-
826
- /**
827
- * Get all records, optionally filtered.
828
- * Alias for find() — for LINQ-familiarity.
829
- *
830
- * @param {object} [conditions={}] - WHERE conditions.
831
- * @returns {Promise<Model[]>} All matching records.
832
- */
833
- static async all(conditions = {})
834
- {
835
- return this.find(conditions);
836
- }
837
-
838
- /**
839
- * Get a random record.
840
- *
841
- * @param {object} [conditions={}] - Optional WHERE conditions.
842
- * @returns {Promise<Model|null>} Random matching record, or null.
843
- *
844
- * @example
845
- * const luckyUser = await User.random();
846
- * const randomAdmin = await User.random({ role: 'admin' });
847
- */
848
- static async random(conditions = {})
849
- {
850
- const total = await this.count(conditions);
851
- if (total === 0) return null;
852
- const idx = Math.floor(Math.random() * total);
853
- return this.query().where(conditions).offset(idx).first();
854
- }
855
-
856
- /**
857
- * Pluck values for a single column across all matching records.
858
- *
859
- * @param {string} field - Column name to extract.
860
- * @param {object} [conditions={}] - Optional WHERE conditions.
861
- * @returns {Promise<Array>} Values for the specified column.
862
- *
863
- * @example
864
- * const emails = await User.pluck('email');
865
- * const adminNames = await User.pluck('name', { role: 'admin' });
866
- */
867
- static async pluck(field, conditions = {})
868
- {
869
- return this.query().where(conditions).pluck(field);
870
- }
871
-
872
- // -- Relationships ----------------------------------
873
-
874
- /**
875
- * Define a hasMany relationship.
876
- * @param {Function} RelatedModel - The related Model class.
877
- * @param {string} foreignKey - Foreign key column on the related table.
878
- * @param {string} [localKey] - Local key (default: primary key).
879
- */
880
- static hasMany(RelatedModel, foreignKey, localKey)
881
- {
882
- const pk = localKey || this._primaryKey();
883
- if (!this._relations) this._relations = {};
884
- this._relations[RelatedModel.name] = { type: 'hasMany', model: RelatedModel, foreignKey, localKey: pk };
885
- }
886
-
887
- /**
888
- * Define a hasOne relationship.
889
- * @param {Function} RelatedModel - The related Model class.
890
- * @param {string} foreignKey - Foreign key column on the related table.
891
- * @param {string} [localKey] - Local key (default: primary key).
892
- */
893
- static hasOne(RelatedModel, foreignKey, localKey)
894
- {
895
- const pk = localKey || this._primaryKey();
896
- if (!this._relations) this._relations = {};
897
- this._relations[RelatedModel.name] = { type: 'hasOne', model: RelatedModel, foreignKey, localKey: pk };
898
- }
899
-
900
- /**
901
- * Define a belongsTo relationship.
902
- * @param {Function} RelatedModel - The related Model class.
903
- * @param {string} foreignKey - Foreign key column on THIS table.
904
- * @param {string} [otherKey] - Key on the related table (default: its primary key).
905
- */
906
- static belongsTo(RelatedModel, foreignKey, otherKey)
907
- {
908
- const ok = otherKey || RelatedModel._primaryKey();
909
- if (!this._relations) this._relations = {};
910
- this._relations[RelatedModel.name] = { type: 'belongsTo', model: RelatedModel, foreignKey, localKey: ok };
911
- }
912
-
913
- /**
914
- * Define a many-to-many relationship through a junction/pivot table.
915
- *
916
- * @param {Function} RelatedModel - The related Model class.
917
- * @param {object} opts - Relationship options.
918
- * @param {string} opts.through - Junction table name (e.g. 'user_roles').
919
- * @param {string} opts.foreignKey - Column on the junction table referencing THIS model.
920
- * @param {string} opts.otherKey - Column on the junction table referencing the related model.
921
- * @param {string} [opts.localKey] - Local key (default: primary key).
922
- * @param {string} [opts.relatedKey] - Related model key (default: its primary key).
923
- *
924
- * @example
925
- * User.belongsToMany(Role, {
926
- * through: 'user_roles',
927
- * foreignKey: 'userId',
928
- * otherKey: 'roleId'
929
- * });
930
- * const roles = await user.load('Role'); // returns Role[]
931
- */
932
- static belongsToMany(RelatedModel, opts = {})
933
- {
934
- if (!opts.through || !opts.foreignKey || !opts.otherKey)
935
- {
936
- throw new Error('belongsToMany requires through, foreignKey, and otherKey');
937
- }
938
- const pk = opts.localKey || this._primaryKey();
939
- const rpk = opts.relatedKey || RelatedModel._primaryKey();
940
- if (!this._relations) this._relations = {};
941
- this._relations[RelatedModel.name] = {
942
- type: 'belongsToMany',
943
- model: RelatedModel,
944
- through: opts.through,
945
- foreignKey: opts.foreignKey,
946
- otherKey: opts.otherKey,
947
- localKey: pk,
948
- relatedKey: rpk,
949
- };
950
- }
951
-
952
- /**
953
- * Load a related model for this instance.
954
- *
955
- * @param {string} relationName - Name of the related Model class or relation alias.
956
- * @returns {Promise<Model|Model[]|null>} The related model(s) or null.
957
- */
958
- async load(relationName)
959
- {
960
- const ctor = this.constructor;
961
- const rel = ctor._relations && ctor._relations[relationName];
962
- if (!rel) throw new Error(`Unknown relation "${relationName}" on ${ctor.name}`);
963
-
964
- switch (rel.type)
965
- {
966
- case 'hasMany':
967
- return rel.model.find({ [rel.foreignKey]: this[rel.localKey] });
968
- case 'hasOne':
969
- return rel.model.findOne({ [rel.foreignKey]: this[rel.localKey] });
970
- case 'belongsTo':
971
- return rel.model.findOne({ [rel.localKey]: this[rel.foreignKey] });
972
- case 'belongsToMany':
973
- {
974
- // Query the junction table to find related IDs
975
- const junctionRows = await ctor._adapter.execute({
976
- action: 'select',
977
- table: rel.through,
978
- fields: [rel.otherKey],
979
- where: [{ field: rel.foreignKey, op: '=', value: this[rel.localKey], logic: 'AND' }],
980
- orderBy: [], joins: [], groupBy: [], having: [],
981
- limit: null, offset: null, distinct: false,
982
- });
983
- if (!junctionRows.length) return [];
984
- const relatedIds = junctionRows.map(r => r[rel.otherKey]);
985
- return rel.model.query().whereIn(rel.relatedKey, relatedIds).exec();
986
- }
987
- case 'morphOne':
988
- {
989
- const typeCol = `${rel.morphName}_type`;
990
- const idCol = `${rel.morphName}_id`;
991
- return rel.model.findOne({ [typeCol]: ctor.name, [idCol]: this[rel.localKey] });
992
- }
993
- case 'morphMany':
994
- {
995
- const typeCol = `${rel.morphName}_type`;
996
- const idCol = `${rel.morphName}_id`;
997
- return rel.model.find({ [typeCol]: ctor.name, [idCol]: this[rel.localKey] });
998
- }
999
- case 'hasManyThrough':
1000
- {
1001
- // Get intermediate records
1002
- const throughRecords = await rel.through.find({ [rel.firstKey]: this[rel.localKey] });
1003
- if (!throughRecords.length) return [];
1004
- const throughIds = throughRecords.map(r => r[rel.secondLocalKey]);
1005
- return rel.model.query().whereIn(rel.secondKey, throughIds).exec();
1006
- }
1007
- default:
1008
- throw new Error(`Unknown relation type "${rel.type}"`);
1009
- }
1010
- }
1011
-
1012
- // -- Internal Static Helpers ------------------------
1013
-
1014
- /**
1015
- * Strip guarded fields from a data object.
1016
- * Guarded fields are defined in the schema with `guarded: true`.
1017
- * They cannot be set via mass-assignment (create / update with object).
1018
- *
1019
- * @param {object} data - The input data.
1020
- * @returns {object} A copy of data without guarded fields.
1021
- * @private
1022
- */
1023
- static _stripGuarded(data)
1024
- {
1025
- const schema = this.schema;
1026
- const guardedKeys = Object.entries(schema)
1027
- .filter(([, def]) => def.guarded)
1028
- .map(([name]) => name);
1029
- if (guardedKeys.length === 0) return data;
1030
- const cleaned = { ...data };
1031
- for (const key of guardedKeys) delete cleaned[key];
1032
- return cleaned;
1033
- }
1034
-
1035
- /**
1036
- * Get the full schema including auto-fields.
1037
- * @returns {object} Schema with auto-generated timestamp and soft-delete columns.
1038
- * @private
1039
- */
1040
- static _fullSchema()
1041
- {
1042
- const s = { ...this.schema };
1043
- if (this.timestamps)
1044
- {
1045
- if (!s.createdAt) s.createdAt = { type: 'datetime', default: () => new Date() };
1046
- if (!s.updatedAt) s.updatedAt = { type: 'datetime', default: () => new Date() };
1047
- }
1048
- if (this.softDelete)
1049
- {
1050
- if (!s.deletedAt) s.deletedAt = { type: 'datetime', nullable: true };
1051
- }
1052
- return s;
1053
- }
1054
-
1055
- /**
1056
- * Get the primary key column name(s).
1057
- * Returns a single string for simple PKs, or an array for composite PKs.
1058
- * @returns {string|string[]} Primary key column name(s).
1059
- * @private
1060
- */
1061
- static _primaryKey()
1062
- {
1063
- const pks = [];
1064
- for (const [name, def] of Object.entries(this.schema))
1065
- {
1066
- if (def.primaryKey) pks.push(name);
1067
- }
1068
- if (pks.length === 0) return 'id'; // convention
1069
- if (pks.length === 1) return pks[0];
1070
- return pks; // composite PK
1071
- }
1072
-
1073
- /**
1074
- * Create a model instance from a raw database row.
1075
- * @param {object} row - Data row object.
1076
- * @returns {Model} Hydrated model instance.
1077
- * @private
1078
- */
1079
- static _fromRow(row)
1080
- {
1081
- const instance = new this(row);
1082
- instance._persisted = true;
1083
- instance._snapshot();
1084
- return instance;
1085
- }
1086
-
1087
- /**
1088
- * Run a lifecycle hook if defined.
1089
- * Also emits model events and notifies observers.
1090
- * @param {string} hookName - Lifecycle hook name.
1091
- * @param {*} data - Record data object.
1092
- * @returns {Promise<*>} Resolved value.
1093
- * @private
1094
- */
1095
- static async _runHook(hookName, data)
1096
- {
1097
- // Check for static hook on class
1098
- if (typeof this[hookName] === 'function')
1099
- {
1100
- await this[hookName](data);
1101
- }
1102
- // Check hooks object
1103
- else if (this.hooks && typeof this.hooks[hookName] === 'function')
1104
- {
1105
- await this.hooks[hookName](data);
1106
- }
1107
-
1108
- // Emit model event
1109
- this._emit(hookName, data);
1110
-
1111
- // Notify observers
1112
- this._notifyObservers(hookName, data);
1113
-
1114
- return data;
1115
- }
1116
-
1117
- /**
1118
- * Sync the table schema with the database (create table if not exists).
1119
- * @returns {Promise<void>}
1120
- */
1121
- static async sync()
1122
- {
1123
- if (!this._adapter) throw new Error(`Model "${this.name}" is not registered with a database`);
1124
- return this._adapter.createTable(this.table, this._fullSchema());
1125
- }
1126
-
1127
- /**
1128
- * Drop the table.
1129
- * @returns {Promise<void>}
1130
- */
1131
- static async drop()
1132
- {
1133
- if (!this._adapter) throw new Error(`Model "${this.name}" is not registered with a database`);
1134
- return this._adapter.dropTable(this.table);
1135
- }
1136
-
1137
- // -- Attribute Casting Helpers ----------------------
1138
-
1139
- /**
1140
- * Apply a cast transformation on get (reading from model).
1141
- * @param {*} value - Raw stored value.
1142
- * @param {string|object} cast - Cast type or custom cast object.
1143
- * @returns {*} Transformed value.
1144
- * @private
1145
- */
1146
- static _applyCastGet(value, cast)
1147
- {
1148
- if (value === null || value === undefined) return value;
1149
- if (typeof cast === 'object' && typeof cast.get === 'function')
1150
- {
1151
- return cast.get(value);
1152
- }
1153
- switch (cast)
1154
- {
1155
- case 'json':
1156
- case 'array':
1157
- return typeof value === 'string' ? JSON.parse(value) : value;
1158
- case 'boolean':
1159
- if (typeof value === 'boolean') return value;
1160
- if (typeof value === 'number') return value !== 0;
1161
- if (typeof value === 'string') return ['true', '1', 'yes'].includes(value.toLowerCase());
1162
- return Boolean(value);
1163
- case 'integer':
1164
- return parseInt(value, 10) || 0;
1165
- case 'float':
1166
- return parseFloat(value) || 0;
1167
- case 'date':
1168
- return value instanceof Date ? value : new Date(value);
1169
- case 'string':
1170
- return String(value);
1171
- default:
1172
- return value;
1173
- }
1174
- }
1175
-
1176
- /**
1177
- * Apply a cast transformation on set (writing to model).
1178
- * @param {*} value - Input value.
1179
- * @param {string|object} cast - Cast type or custom cast object.
1180
- * @returns {*} Transformed value for storage.
1181
- * @private
1182
- */
1183
- static _applyCastSet(value, cast)
1184
- {
1185
- if (value === null || value === undefined) return value;
1186
- if (typeof cast === 'object' && typeof cast.set === 'function')
1187
- {
1188
- return cast.set(value);
1189
- }
1190
- switch (cast)
1191
- {
1192
- case 'json':
1193
- case 'array':
1194
- return typeof value === 'string' ? value : JSON.stringify(value);
1195
- case 'boolean':
1196
- if (typeof value === 'boolean') return value;
1197
- if (typeof value === 'number') return value !== 0;
1198
- if (typeof value === 'string') return ['true', '1', 'yes'].includes(value.toLowerCase());
1199
- return Boolean(value);
1200
- case 'integer':
1201
- return parseInt(value, 10) || 0;
1202
- case 'float':
1203
- return parseFloat(value) || 0;
1204
- case 'date':
1205
- return value instanceof Date ? value : new Date(value);
1206
- case 'string':
1207
- return String(value);
1208
- default:
1209
- return value;
1210
- }
1211
- }
1212
-
1213
- /**
1214
- * Get an attribute value with accessor/cast applied.
1215
- *
1216
- * @param {string} key - Attribute name.
1217
- * @returns {*} Transformed value.
1218
- *
1219
- * @example
1220
- * const email = user.getAttribute('email');
1221
- */
1222
- getAttribute(key)
1223
- {
1224
- const ctor = this.constructor;
1225
- const accessors = ctor.accessors || {};
1226
- const casts = ctor.casts || {};
1227
- const computed = ctor.computed || {};
1228
-
1229
- // Check computed first
1230
- if (typeof computed[key] === 'function')
1231
- {
1232
- return computed[key](this);
1233
- }
1234
-
1235
- let val = this[key];
1236
-
1237
- // Apply accessor
1238
- if (typeof accessors[key] === 'function')
1239
- {
1240
- return accessors[key](val, this);
1241
- }
1242
-
1243
- // Apply cast get
1244
- if (casts[key])
1245
- {
1246
- return Model._applyCastGet(val, casts[key]);
1247
- }
1248
-
1249
- return val;
1250
- }
1251
-
1252
- /**
1253
- * Set an attribute value with mutator/cast applied.
1254
- *
1255
- * @param {string} key - Attribute name.
1256
- * @param {*} value - Value to set.
1257
- * @returns {Model} `this` for chaining.
1258
- *
1259
- * @example
1260
- * user.setAttribute('email', 'ALICE@EXAMPLE.COM');
1261
- * // If mutator lowercases: user.email => 'alice@example.com'
1262
- */
1263
- setAttribute(key, value)
1264
- {
1265
- const ctor = this.constructor;
1266
- const mutators = ctor.mutators || {};
1267
- const casts = ctor.casts || {};
1268
-
1269
- if (typeof mutators[key] === 'function')
1270
- {
1271
- this[key] = mutators[key](value, this);
1272
- }
1273
- else if (casts[key])
1274
- {
1275
- this[key] = Model._applyCastSet(value, casts[key]);
1276
- }
1277
- else
1278
- {
1279
- this[key] = value;
1280
- }
1281
- return this;
1282
- }
1283
-
1284
- // -- Model Events -----------------------------------
1285
-
1286
- /**
1287
- * Get or create the EventEmitter for this model class.
1288
- * @returns {EventEmitter} The model's event emitter.
1289
- * @private
1290
- */
1291
- static _getEmitter()
1292
- {
1293
- if (!this.hasOwnProperty('_emitter') || !this._emitter)
1294
- {
1295
- this._emitter = new EventEmitter();
1296
- }
1297
- return this._emitter;
1298
- }
1299
-
1300
- /**
1301
- * Register an event listener on this model.
1302
- * Supported events: `creating`, `created`, `updating`, `updated`,
1303
- * `deleting`, `deleted`, `saving`, `saved`.
1304
- *
1305
- * @param {string} event - Event name.
1306
- * @param {Function} listener - Callback `(data) => {}`.
1307
- * @returns {typeof Model} The model class (for chaining).
1308
- *
1309
- * @example
1310
- * User.on('created', (user) => {
1311
- * console.log('New user:', user.name);
1312
- * });
1313
- *
1314
- * User.on('updating', (changes) => {
1315
- * console.log('Updating fields:', Object.keys(changes));
1316
- * });
1317
- */
1318
- static on(event, listener)
1319
- {
1320
- this._getEmitter().on(event, listener);
1321
- return this;
1322
- }
1323
-
1324
- /**
1325
- * Register a one-time event listener.
1326
- *
1327
- * @param {string} event - Event name.
1328
- * @param {Function} listener - Callback function.
1329
- * @returns {typeof Model} The model class (for chaining).
1330
- */
1331
- static once(event, listener)
1332
- {
1333
- this._getEmitter().once(event, listener);
1334
- return this;
1335
- }
1336
-
1337
- /**
1338
- * Remove an event listener.
1339
- *
1340
- * @param {string} event - Event name.
1341
- * @param {Function} listener - Callback to remove.
1342
- * @returns {typeof Model} The model class (for chaining).
1343
- */
1344
- static off(event, listener)
1345
- {
1346
- this._getEmitter().off(event, listener);
1347
- return this;
1348
- }
1349
-
1350
- /**
1351
- * Remove all listeners for an event, or all listeners entirely.
1352
- *
1353
- * @param {string} [event] - Event name. If omitted, removes all listeners.
1354
- * @returns {typeof Model} The model class (for chaining).
1355
- */
1356
- static removeAllListeners(event)
1357
- {
1358
- if (event !== undefined)
1359
- {
1360
- this._getEmitter().removeAllListeners(event);
1361
- }
1362
- else
1363
- {
1364
- this._getEmitter().removeAllListeners();
1365
- }
1366
- return this;
1367
- }
1368
-
1369
- /**
1370
- * Emit a model event.
1371
- * @param {string} event - Event name.
1372
- * @param {*} data - Event data.
1373
- * @private
1374
- */
1375
- static _emit(event, data)
1376
- {
1377
- // Map hook names to event names
1378
- const eventMap = {
1379
- beforeCreate: 'creating',
1380
- afterCreate: 'created',
1381
- beforeUpdate: 'updating',
1382
- afterUpdate: 'updated',
1383
- beforeDelete: 'deleting',
1384
- afterDelete: 'deleted',
1385
- };
1386
- const eventName = eventMap[event];
1387
- if (eventName && this.hasOwnProperty('_emitter') && this._emitter)
1388
- {
1389
- this._emitter.emit(eventName, data);
1390
- }
1391
- }
1392
-
1393
- // -- Observers --------------------------------------
1394
-
1395
- /**
1396
- * Register an observer for this model.
1397
- * An observer is an object with methods named after lifecycle events:
1398
- * `creating`, `created`, `updating`, `updated`, `deleting`, `deleted`.
1399
- *
1400
- * @param {object} observer - Observer object with event handler methods.
1401
- * @returns {typeof Model} The model class (for chaining).
1402
- *
1403
- * @example
1404
- * const UserObserver = {
1405
- * created(user) { console.log('New user:', user.name); },
1406
- * updating(changes) { console.log('Updating:', changes); },
1407
- * deleted(user) { console.log('Deleted user:', user.id); },
1408
- * };
1409
- *
1410
- * User.observe(UserObserver);
1411
- */
1412
- static observe(observer)
1413
- {
1414
- if (!this.hasOwnProperty('_observers'))
1415
- {
1416
- this._observers = [];
1417
- }
1418
- this._observers.push(observer);
1419
- return this;
1420
- }
1421
-
1422
- /**
1423
- * Remove an observer from this model.
1424
- *
1425
- * @param {object} observer - Observer to remove.
1426
- * @returns {typeof Model} The model class (for chaining).
1427
- */
1428
- static unobserve(observer)
1429
- {
1430
- if (this.hasOwnProperty('_observers'))
1431
- {
1432
- this._observers = this._observers.filter(o => o !== observer);
1433
- }
1434
- return this;
1435
- }
1436
-
1437
- /**
1438
- * Notify all registered observers of a lifecycle event.
1439
- * @param {string} hookName - Hook name (e.g. 'beforeCreate').
1440
- * @param {*} data - Event data.
1441
- * @private
1442
- */
1443
- static _notifyObservers(hookName, data)
1444
- {
1445
- const eventMap = {
1446
- beforeCreate: 'creating',
1447
- afterCreate: 'created',
1448
- beforeUpdate: 'updating',
1449
- afterUpdate: 'updated',
1450
- beforeDelete: 'deleting',
1451
- afterDelete: 'deleted',
1452
- };
1453
- const eventName = eventMap[hookName];
1454
- if (!eventName) return;
1455
-
1456
- const observers = this.hasOwnProperty('_observers') ? this._observers : [];
1457
- for (const observer of observers)
1458
- {
1459
- if (typeof observer[eventName] === 'function')
1460
- {
1461
- observer[eventName](data);
1462
- }
1463
- }
1464
- }
1465
-
1466
- // -- Advanced Relationships -------------------------
1467
-
1468
- /**
1469
- * Define a polymorphic one-to-one relationship (morphOne).
1470
- * The related table uses two columns: a type column and an ID column.
1471
- *
1472
- * @param {Function} RelatedModel - The related Model class.
1473
- * @param {string} morphName - Base name for the polymorphic columns (e.g. 'commentable').
1474
- * @param {string} [localKey] - Local key (default: primary key).
1475
- *
1476
- * @example
1477
- * // Image can belong to either User or Post
1478
- * User.morphOne(Image, 'imageable');
1479
- * // Related table has: imageable_type, imageable_id columns
1480
- * const avatar = await user.load('Image'); // Image where imageable_type='User', imageable_id=user.id
1481
- */
1482
- static morphOne(RelatedModel, morphName, localKey)
1483
- {
1484
- const pk = localKey || this._primaryKey();
1485
- if (!this._relations) this._relations = {};
1486
- this._relations[RelatedModel.name] = {
1487
- type: 'morphOne',
1488
- model: RelatedModel,
1489
- morphName,
1490
- localKey: pk,
1491
- };
1492
- }
1493
-
1494
- /**
1495
- * Define a polymorphic one-to-many relationship (morphMany).
1496
- * The related table uses two columns: a type column and an ID column.
1497
- *
1498
- * @param {Function} RelatedModel - The related Model class.
1499
- * @param {string} morphName - Base name for the polymorphic columns (e.g. 'commentable').
1500
- * @param {string} [localKey] - Local key (default: primary key).
1501
- *
1502
- * @example
1503
- * // Comments can belong to either Post or Video
1504
- * Post.morphMany(Comment, 'commentable');
1505
- * const comments = await post.load('Comment');
1506
- */
1507
- static morphMany(RelatedModel, morphName, localKey)
1508
- {
1509
- const pk = localKey || this._primaryKey();
1510
- if (!this._relations) this._relations = {};
1511
- this._relations[RelatedModel.name] = {
1512
- type: 'morphMany',
1513
- model: RelatedModel,
1514
- morphName,
1515
- localKey: pk,
1516
- };
1517
- }
1518
-
1519
- /**
1520
- * Define a has-many-through relationship.
1521
- * Accesses distant relations through an intermediate table.
1522
- *
1523
- * @param {Function} RelatedModel - The distant related Model class.
1524
- * @param {Function} ThroughModel - The intermediate Model class.
1525
- * @param {string} firstKey - FK on the through table referencing this model.
1526
- * @param {string} secondKey - FK on the related table referencing the through table.
1527
- * @param {string} [localKey] - Local key (default: primary key).
1528
- * @param {string} [secondLocalKey] - Key on the through table matched by secondKey (default: through model PK).
1529
- *
1530
- * @example
1531
- * // Country → User → Post
1532
- * // A country has many posts through users
1533
- * Country.hasManyThrough(Post, User, 'countryId', 'userId');
1534
- * const posts = await country.load('Post');
1535
- */
1536
- static hasManyThrough(RelatedModel, ThroughModel, firstKey, secondKey, localKey, secondLocalKey)
1537
- {
1538
- const pk = localKey || this._primaryKey();
1539
- const throughPk = secondLocalKey || ThroughModel._primaryKey();
1540
- if (!this._relations) this._relations = {};
1541
- this._relations[RelatedModel.name] = {
1542
- type: 'hasManyThrough',
1543
- model: RelatedModel,
1544
- through: ThroughModel,
1545
- firstKey,
1546
- secondKey,
1547
- localKey: pk,
1548
- secondLocalKey: throughPk,
1549
- };
1550
- }
1551
-
1552
- /**
1553
- * Define a self-referential relationship for tree/graph structures.
1554
- * Sets up both parent and children relationships.
1555
- *
1556
- * @param {object} opts - Relationship options.
1557
- * @param {string} opts.foreignKey - FK column referencing self (e.g. 'parentId').
1558
- * @param {string} [opts.localKey] - Local key (default: primary key).
1559
- * @param {string} [opts.parentName='parent'] - Name for the parent relationship.
1560
- * @param {string} [opts.childrenName='children'] - Name for the children relationship.
1561
- *
1562
- * @example
1563
- * Category.selfReferential({
1564
- * foreignKey: 'parentId',
1565
- * parentName: 'parent',
1566
- * childrenName: 'children',
1567
- * });
1568
- *
1569
- * const parent = await category.load('parent');
1570
- * const children = await category.load('children');
1571
- * const tree = await Category.tree(); // full tree structure
1572
- */
1573
- static selfReferential(opts = {})
1574
- {
1575
- if (!opts.foreignKey) throw new Error('selfReferential requires foreignKey');
1576
- const pk = opts.localKey || this._primaryKey();
1577
- const parentName = opts.parentName || 'parent';
1578
- const childrenName = opts.childrenName || 'children';
1579
-
1580
- if (!this._relations) this._relations = {};
1581
-
1582
- // Parent relationship (belongsTo self)
1583
- this._relations[parentName] = {
1584
- type: 'belongsTo',
1585
- model: this,
1586
- foreignKey: opts.foreignKey,
1587
- localKey: pk,
1588
- };
1589
-
1590
- // Children relationship (hasMany self)
1591
- this._relations[childrenName] = {
1592
- type: 'hasMany',
1593
- model: this,
1594
- foreignKey: opts.foreignKey,
1595
- localKey: pk,
1596
- };
1597
- }
1598
-
1599
- /**
1600
- * Build a full tree structure from self-referential records.
1601
- * Returns nested objects with a `children` array property.
1602
- *
1603
- * @param {object} [options] - Configuration options.
1604
- * @param {string} [options.foreignKey='parentId'] - FK column for the parent reference.
1605
- * @param {string} [options.childrenKey='children'] - Property name for nested children.
1606
- * @param {*} [options.rootValue=null] - Value of foreignKey that indicates a root node.
1607
- * @returns {Promise<object[]>} Array of root nodes with nested children.
1608
- *
1609
- * @example
1610
- * const tree = await Category.tree({ foreignKey: 'parentId' });
1611
- * // [{ id: 1, name: 'Root', children: [{ id: 2, name: 'Child', children: [] }] }]
1612
- */
1613
- static async tree(options = {})
1614
- {
1615
- const { foreignKey = 'parentId', childrenKey = 'children', rootValue = null } = options;
1616
- const all = await this.find();
1617
- const pk = this._primaryKey();
1618
- const map = new Map();
1619
- const roots = [];
1620
-
1621
- for (const node of all) { node[childrenKey] = []; map.set(node[pk], node); }
1622
-
1623
- for (const node of all)
1624
- {
1625
- const parentId = node[foreignKey];
1626
- if (parentId === rootValue || parentId === null || parentId === undefined)
1627
- {
1628
- roots.push(node);
1629
- }
1630
- else
1631
- {
1632
- const parent = map.get(parentId);
1633
- if (parent) parent[childrenKey].push(node);
1634
- else roots.push(node); // orphan → treat as root
1635
- }
1636
- }
1637
-
1638
- return roots;
1639
- }
1640
-
1641
- /**
1642
- * Get all ancestors of this instance in a self-referential tree.
1643
- *
1644
- * @param {string} [foreignKey='parentId'] - FK column for the parent reference.
1645
- * @returns {Promise<Model[]>} Array of ancestors from immediate parent to root.
1646
- *
1647
- * @example
1648
- * const ancestors = await category.ancestors('parentId');
1649
- * // [parentCategory, grandparentCategory, rootCategory]
1650
- */
1651
- async ancestors(foreignKey = 'parentId')
1652
- {
1653
- const ctor = this.constructor;
1654
- const pk = ctor._primaryKey();
1655
- const result = [];
1656
- let currentId = this[foreignKey];
1657
- const seen = new Set();
1658
-
1659
- while (currentId !== null && currentId !== undefined)
1660
- {
1661
- if (seen.has(currentId)) break; // circular reference guard
1662
- seen.add(currentId);
1663
- const parent = await ctor.findById(currentId);
1664
- if (!parent) break;
1665
- result.push(parent);
1666
- currentId = parent[foreignKey];
1667
- }
1668
-
1669
- return result;
1670
- }
1671
-
1672
- /**
1673
- * Get all descendants of this instance in a self-referential tree.
1674
- *
1675
- * @param {string} [foreignKey='parentId'] - FK column for the parent reference.
1676
- * @returns {Promise<Model[]>} Flat array of all descendants (breadth-first).
1677
- *
1678
- * @example
1679
- * const descendants = await category.descendants('parentId');
1680
- */
1681
- async descendants(foreignKey = 'parentId')
1682
- {
1683
- const ctor = this.constructor;
1684
- const pk = ctor._primaryKey();
1685
- const result = [];
1686
- const queue = [this[pk]];
1687
- const seen = new Set([this[pk]]);
1688
-
1689
- while (queue.length)
1690
- {
1691
- const parentId = queue.shift();
1692
- const children = await ctor.find({ [foreignKey]: parentId });
1693
- for (const child of children)
1694
- {
1695
- if (seen.has(child[pk])) continue; // circular reference guard
1696
- seen.add(child[pk]);
1697
- result.push(child);
1698
- queue.push(child[pk]);
1699
- }
1700
- }
1701
-
1702
- return result;
1703
- }
1704
- }
1705
-
1706
- module.exports = Model;
1
+ /**
2
+ * @module orm/model
3
+ * @description Base Model class for defining database-backed entities.
4
+ * Provides static CRUD methods, instance-level save/update/delete,
5
+ * lifecycle hooks, relationship definitions, computed/virtual columns,
6
+ * attribute casting, model events & observers, and advanced relationships.
7
+ *
8
+ * @example
9
+ * const { Model, Database } = require('@zero-server/sdk');
10
+ *
11
+ * class User extends Model {
12
+ * static table = 'users';
13
+ * static schema = {
14
+ * id: { type: 'integer', primaryKey: true, autoIncrement: true },
15
+ * name: { type: 'string', required: true, maxLength: 100 },
16
+ * email: { type: 'string', required: true, unique: true },
17
+ * role: { type: 'string', enum: ['user','admin'], default: 'user' },
18
+ * };
19
+ * static timestamps = true; // auto createdAt/updatedAt
20
+ * static softDelete = true; // deletedAt instead of real delete
21
+ * }
22
+ *
23
+ * db.register(User);
24
+ *
25
+ * const user = await User.create({ name: 'Alice', email: 'a@b.com' });
26
+ * const users = await User.find({ role: 'admin' });
27
+ * const u = await User.findById(1);
28
+ * await u.update({ name: 'Alice2' });
29
+ * await u.delete();
30
+ */
31
+ const { validate } = require('./schema');
32
+ const Query = require('./query');
33
+ const crypto = require('crypto');
34
+ const log = require('../debug')('zero:orm');
35
+ const { ValidationError, DatabaseError } = require('../errors');
36
+ const { EventEmitter } = require('events');
37
+
38
+ class Model
39
+ {
40
+ /**
41
+ * Table name — override in subclass.
42
+ * @type {string}
43
+ */
44
+ static table = '';
45
+
46
+ /**
47
+ * Column schema — override in subclass.
48
+ * @type {Object<string, object>}
49
+ */
50
+ static schema = {};
51
+
52
+ /**
53
+ * Enable auto timestamps (createdAt, updatedAt).
54
+ * @type {boolean}
55
+ */
56
+ static timestamps = false;
57
+
58
+ /**
59
+ * Enable soft deletes (deletedAt instead of real deletion).
60
+ * @type {boolean}
61
+ */
62
+ static softDelete = false;
63
+
64
+ /**
65
+ * Fields to hide from toJSON() serialization.
66
+ * Useful for excluding passwords, tokens, internal fields.
67
+ * @type {string[]}
68
+ *
69
+ * @example
70
+ * class User extends Model {
71
+ * static hidden = ['password', 'resetToken'];
72
+ * }
73
+ */
74
+ static hidden = [];
75
+
76
+ /**
77
+ * Named query scopes — reusable query conditions.
78
+ * Each scope is a function that receives a Query and returns it.
79
+ * @type {Object<string, Function>}
80
+ *
81
+ * @example
82
+ * class User extends Model {
83
+ * static scopes = {
84
+ * active: q => q.where('active', true),
85
+ * admins: q => q.where('role', 'admin'),
86
+ * olderThan: (q, age) => q.where('age', '>', age),
87
+ * };
88
+ * }
89
+ *
90
+ * // Use:
91
+ * await User.scope('active').scope('admins').limit(5);
92
+ * await User.scope('olderThan', 30);
93
+ */
94
+ static scopes = {};
95
+
96
+ /**
97
+ * Lifecycle hooks.
98
+ * Override these in subclasses: `static beforeCreate(data) { return data; }`
99
+ * @type {object}
100
+ */
101
+ static hooks = {};
102
+
103
+ // -- Computed & Virtual Columns ---------------------
104
+
105
+ /**
106
+ * Computed column definitions — virtual columns derived from other fields.
107
+ * Not stored in the database; calculated on the fly.
108
+ * Each entry maps a column name to a getter function `(instance) => value`.
109
+ * @type {Object<string, Function>}
110
+ *
111
+ * @example
112
+ * class User extends Model {
113
+ * static computed = {
114
+ * fullName: (user) => `${user.firstName} ${user.lastName}`,
115
+ * isAdmin: (user) => user.role === 'admin',
116
+ * };
117
+ * }
118
+ *
119
+ * const user = await User.findById(1);
120
+ * user.fullName // => 'Alice Smith'
121
+ */
122
+ static computed = {};
123
+
124
+ /**
125
+ * Attribute casts — automatic type transformations on get/set.
126
+ * Maps column names to cast types or custom cast objects.
127
+ *
128
+ * Built-in cast types:
129
+ * - `'json'` — JSON.parse on get, JSON.stringify on set
130
+ * - `'boolean'` — Cast to true/false
131
+ * - `'integer'` — parseInt
132
+ * - `'float'` — parseFloat
133
+ * - `'date'` — Cast to Date object
134
+ * - `'string'` — Cast to String
135
+ * - `'array'` — JSON parse/stringify for array data
136
+ *
137
+ * Custom casts:
138
+ * - `{ get: (v) => transformed, set: (v) => transformed }`
139
+ *
140
+ * @type {Object<string, string|{ get: Function, set: Function }>}
141
+ *
142
+ * @example
143
+ * class Settings extends Model {
144
+ * static casts = {
145
+ * preferences: 'json',
146
+ * isActive: 'boolean',
147
+ * loginCount: 'integer',
148
+ * tags: 'array',
149
+ * metadata: {
150
+ * get: (v) => v ? JSON.parse(v) : {},
151
+ * set: (v) => JSON.stringify(v),
152
+ * },
153
+ * };
154
+ * }
155
+ */
156
+ static casts = {};
157
+
158
+ /**
159
+ * Custom attribute accessors (getters).
160
+ * These transform values when reading from the model instance.
161
+ * Each entry maps a column name to a function `(value, instance) => transformedValue`.
162
+ * @type {Object<string, Function>}
163
+ *
164
+ * @example
165
+ * class User extends Model {
166
+ * static accessors = {
167
+ * email: (val) => val ? val.toLowerCase() : val,
168
+ * name: (val) => val ? val.trim() : val,
169
+ * };
170
+ * }
171
+ */
172
+ static accessors = {};
173
+
174
+ /**
175
+ * Custom attribute mutators (setters).
176
+ * These transform values before writing to the model instance.
177
+ * Each entry maps a column name to a function `(value, instance) => transformedValue`.
178
+ * @type {Object<string, Function>}
179
+ *
180
+ * @example
181
+ * class User extends Model {
182
+ * static mutators = {
183
+ * email: (val) => val ? val.toLowerCase().trim() : val,
184
+ * password: (val) => hashSync(val),
185
+ * };
186
+ * }
187
+ */
188
+ static mutators = {};
189
+
190
+ // -- Model Events -----------------------------------
191
+
192
+ /**
193
+ * Internal event emitter for model-level events.
194
+ * @type {EventEmitter|null}
195
+ * @private
196
+ */
197
+ static _emitter = null;
198
+
199
+ /**
200
+ * Registered observers for this model.
201
+ * @type {object[]}
202
+ * @private
203
+ */
204
+ static _observers = [];
205
+
206
+ /**
207
+ * Relationship definitions.
208
+ * @type {object}
209
+ * @private
210
+ */
211
+ static _relations = {};
212
+
213
+ /**
214
+ * Database adapter reference — set by Database.register().
215
+ * @type {object|null}
216
+ * @private
217
+ */
218
+ static _adapter = null;
219
+
220
+ // -- Constructor ------------------------------------
221
+
222
+ /**
223
+ * @constructor
224
+ * Create a model instance from a data row.
225
+ * Generally you won't call this directly — use static methods.
226
+ *
227
+ * @param {object} data - Row data.
228
+ */
229
+ constructor(data = {})
230
+ {
231
+ /** @type {boolean} Whether this instance exists in the database. */
232
+ this._persisted = false;
233
+
234
+ /** @type {object} The original data snapshot for dirty tracking. */
235
+ this._original = {};
236
+
237
+ // Assign data to instance (filter prototype pollution keys)
238
+ const mutators = this.constructor.mutators || {};
239
+ const casts = this.constructor.casts || {};
240
+ for (const key of Object.keys(data))
241
+ {
242
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
243
+ let val = data[key];
244
+ // Apply mutator if defined
245
+ if (typeof mutators[key] === 'function')
246
+ {
247
+ val = mutators[key](val, this);
248
+ }
249
+ // Apply cast set if defined
250
+ else if (casts[key])
251
+ {
252
+ val = Model._applyCastSet(val, casts[key]);
253
+ }
254
+ this[key] = val;
255
+ }
256
+ }
257
+
258
+ // -- Instance Methods -------------------------------
259
+
260
+ /**
261
+ * Save this instance to the database. Insert if new, update if persisted.
262
+ * @returns {Promise<Model>} `this`
263
+ */
264
+ async save()
265
+ {
266
+ const ctor = this.constructor;
267
+ if (this._persisted)
268
+ {
269
+ const pk = ctor._primaryKey();
270
+ const changes = this._dirtyFields();
271
+ if (Object.keys(changes).length === 0) return this;
272
+
273
+ if (ctor.timestamps && ctor._fullSchema().updatedAt)
274
+ {
275
+ changes.updatedAt = new Date();
276
+ }
277
+
278
+ await ctor._runHook('beforeUpdate', changes);
279
+ const { valid, errors, sanitized } = validate(changes, ctor._fullSchema(), { partial: true });
280
+ if (!valid) throw new ValidationError('Validation failed: ' + errors.join(', '), errors);
281
+
282
+ try { await ctor._adapter.update(ctor.table, pk, this[pk], sanitized); }
283
+ catch (e) { log.error('%s update failed: %s', ctor.table, e.message); throw e; }
284
+ log.debug('%s update id=%s', ctor.table, this[pk]);
285
+ Object.assign(this, sanitized);
286
+ await ctor._runHook('afterUpdate', this);
287
+ this._snapshot();
288
+ }
289
+ else
290
+ {
291
+ const data = this._toData();
292
+
293
+ if (ctor.timestamps)
294
+ {
295
+ const now = new Date();
296
+ if (ctor._fullSchema().createdAt && !data.createdAt) data.createdAt = now;
297
+ if (ctor._fullSchema().updatedAt && !data.updatedAt) data.updatedAt = now;
298
+ }
299
+
300
+ await ctor._runHook('beforeCreate', data);
301
+ const { valid, errors, sanitized } = validate(data, ctor._fullSchema());
302
+ if (!valid) throw new ValidationError('Validation failed: ' + errors.join(', '), errors);
303
+
304
+ let result;
305
+ try { result = await ctor._adapter.insert(ctor.table, sanitized); }
306
+ catch (e) { log.error('%s insert failed: %s', ctor.table, e.message); throw e; }
307
+ log.debug('%s insert', ctor.table);
308
+ const pk = ctor._primaryKey();
309
+ if (result && result[pk] !== undefined) this[pk] = result[pk];
310
+ Object.assign(this, sanitized);
311
+ this._persisted = true;
312
+ await ctor._runHook('afterCreate', this);
313
+ this._snapshot();
314
+ }
315
+ return this;
316
+ }
317
+
318
+ /**
319
+ * Update specific fields on this instance.
320
+ * @param {object} data - Fields to update.
321
+ * @returns {Promise<Model>} `this`
322
+ */
323
+ async update(data)
324
+ {
325
+ Object.assign(this, this.constructor._stripGuarded(data));
326
+ return this.save();
327
+ }
328
+
329
+ /**
330
+ * Delete this instance from the database.
331
+ * If softDelete is enabled, sets deletedAt instead.
332
+ * @returns {Promise<void>}
333
+ */
334
+ async delete()
335
+ {
336
+ const ctor = this.constructor;
337
+ const pk = ctor._primaryKey();
338
+
339
+ await ctor._runHook('beforeDelete', this);
340
+
341
+ if (ctor.softDelete)
342
+ {
343
+ this.deletedAt = new Date();
344
+ try { await ctor._adapter.update(ctor.table, pk, this[pk], { deletedAt: this.deletedAt }); }
345
+ catch (e) { log.error('%s soft-delete failed: %s', ctor.table, e.message); throw e; }
346
+ }
347
+ else
348
+ {
349
+ try { await ctor._adapter.remove(ctor.table, pk, this[pk]); }
350
+ catch (e) { log.error('%s delete failed: %s', ctor.table, e.message); throw e; }
351
+ }
352
+
353
+ log.debug('%s delete id=%s', ctor.table, this[pk]);
354
+
355
+ await ctor._runHook('afterDelete', this);
356
+ this._persisted = false;
357
+ }
358
+
359
+ /**
360
+ * Restore a soft-deleted record.
361
+ * @returns {Promise<Model>} `this`
362
+ */
363
+ async restore()
364
+ {
365
+ const ctor = this.constructor;
366
+ if (!ctor.softDelete) throw new Error('Model does not use soft deletes');
367
+ const pk = ctor._primaryKey();
368
+ this.deletedAt = null;
369
+ try { await ctor._adapter.update(ctor.table, pk, this[pk], { deletedAt: null }); }
370
+ catch (e) { log.error('%s restore failed: %s', ctor.table, e.message); throw e; }
371
+ return this;
372
+ }
373
+
374
+ /**
375
+ * Increment a numeric field atomically.
376
+ *
377
+ * @param {string} field - Column name to increment.
378
+ * @param {number} [by=1] - Amount to increment by.
379
+ * @returns {Promise<Model>} `this`
380
+ *
381
+ * @example
382
+ * await post.increment('views');
383
+ * await product.increment('stock', 10);
384
+ */
385
+ async increment(field, by = 1)
386
+ {
387
+ const ctor = this.constructor;
388
+ const pk = ctor._primaryKey();
389
+ this[field] = (Number(this[field]) || 0) + by;
390
+ const update = { [field]: this[field] };
391
+ if (ctor.timestamps && ctor._fullSchema().updatedAt)
392
+ {
393
+ update.updatedAt = new Date();
394
+ this.updatedAt = update.updatedAt;
395
+ }
396
+ await ctor._adapter.update(ctor.table, pk, this[pk], update);
397
+ log.debug('%s increment %s by %d', ctor.table, field, by);
398
+ this._snapshot();
399
+ return this;
400
+ }
401
+
402
+ /**
403
+ * Decrement a numeric field atomically.
404
+ *
405
+ * @param {string} field - Column name to decrement.
406
+ * @param {number} [by=1] - Amount to decrement by.
407
+ * @returns {Promise<Model>} `this`
408
+ *
409
+ * @example
410
+ * await product.decrement('stock');
411
+ * await account.decrement('balance', 50);
412
+ */
413
+ async decrement(field, by = 1)
414
+ {
415
+ return this.increment(field, -by);
416
+ }
417
+
418
+ /**
419
+ * Reload this instance from the database.
420
+ * @returns {Promise<Model>} `this`
421
+ */
422
+ async reload()
423
+ {
424
+ const ctor = this.constructor;
425
+ const pk = ctor._primaryKey();
426
+ const fresh = await ctor.findById(this[pk]);
427
+ if (!fresh) throw new Error('Record not found');
428
+ Object.assign(this, fresh);
429
+ this._snapshot();
430
+ return this;
431
+ }
432
+
433
+ /**
434
+ * Convert to plain object (for JSON serialization).
435
+ * Respects `static hidden = [...]` to exclude sensitive fields.
436
+ * Includes computed columns and applies accessor transformations.
437
+ * @returns {object} Plain data object with hidden fields excluded.
438
+ */
439
+ toJSON()
440
+ {
441
+ const data = {};
442
+ const ctor = this.constructor;
443
+ const schema = ctor._fullSchema();
444
+ const hidden = ctor.hidden || [];
445
+ const accessors = ctor.accessors || {};
446
+ const casts = ctor.casts || {};
447
+ for (const key of Object.keys(schema))
448
+ {
449
+ if (this[key] !== undefined && !hidden.includes(key))
450
+ {
451
+ let val = this[key];
452
+ // Apply accessor if defined
453
+ if (typeof accessors[key] === 'function')
454
+ {
455
+ val = accessors[key](val, this);
456
+ }
457
+ // Apply cast get if defined (and no accessor)
458
+ else if (casts[key])
459
+ {
460
+ val = Model._applyCastGet(val, casts[key]);
461
+ }
462
+ data[key] = val;
463
+ }
464
+ }
465
+ // Include computed columns
466
+ const computed = ctor.computed || {};
467
+ for (const [name, fn] of Object.entries(computed))
468
+ {
469
+ if (!hidden.includes(name) && typeof fn === 'function')
470
+ {
471
+ data[name] = fn(this);
472
+ }
473
+ }
474
+ return data;
475
+ }
476
+
477
+ // -- Internal Instance Helpers ----------------------
478
+
479
+ /** @private Snapshot current data for dirty tracking. */
480
+ _snapshot()
481
+ {
482
+ this._original = { ...this._toData() };
483
+ }
484
+
485
+ /** @private Get only data columns (exclude internal props). */
486
+ _toData()
487
+ {
488
+ const data = {};
489
+ const schema = this.constructor._fullSchema();
490
+ for (const key of Object.keys(schema))
491
+ {
492
+ if (this[key] !== undefined) data[key] = this[key];
493
+ }
494
+ return data;
495
+ }
496
+
497
+ /** @private Get fields that changed since last snapshot. */
498
+ _dirtyFields()
499
+ {
500
+ const data = this._toData();
501
+ const changes = {};
502
+ for (const [k, v] of Object.entries(data))
503
+ {
504
+ if (v !== this._original[k]) changes[k] = v;
505
+ }
506
+ return changes;
507
+ }
508
+
509
+ // -- Static CRUD ------------------------------------
510
+
511
+ /**
512
+ * Create and persist a new record.
513
+ *
514
+ * @param {object} data - Record data.
515
+ * @returns {Promise<Model>} The created instance.
516
+ */
517
+ static async create(data)
518
+ {
519
+ const instance = new this(this._stripGuarded(data));
520
+ return instance.save();
521
+ }
522
+
523
+ /**
524
+ * Create multiple records at once.
525
+ * Uses batch INSERT when the adapter supports it (much faster for SQL databases).
526
+ *
527
+ * @param {object[]} dataArray - Array of record data.
528
+ * @returns {Promise<Model[]>} Created model instances.
529
+ */
530
+ static async createMany(dataArray)
531
+ {
532
+ if (!dataArray.length) return [];
533
+
534
+ // Validate, apply hooks & timestamps for each row
535
+ const fullSchema = this._fullSchema();
536
+ const sanitizedRows = [];
537
+ for (const data of dataArray)
538
+ {
539
+ const row = this._stripGuarded({ ...data });
540
+ if (this.timestamps)
541
+ {
542
+ const now = new Date();
543
+ if (fullSchema.createdAt && !row.createdAt) row.createdAt = now;
544
+ if (fullSchema.updatedAt && !row.updatedAt) row.updatedAt = now;
545
+ }
546
+ await this._runHook('beforeCreate', row);
547
+ const { valid, errors, sanitized } = validate(row, fullSchema);
548
+ if (!valid) throw new ValidationError('Validation failed: ' + errors.join(', '), errors);
549
+ sanitizedRows.push(sanitized);
550
+ }
551
+
552
+ // Use batch insertMany if adapter supports it
553
+ if (typeof this._adapter.insertMany === 'function')
554
+ {
555
+ let results;
556
+ try { results = await this._adapter.insertMany(this.table, sanitizedRows); }
557
+ catch (e) { log.error('%s insertMany failed: %s', this.table, e.message); throw e; }
558
+
559
+ const instances = results.map(row => {
560
+ const inst = this._fromRow(row);
561
+ return inst;
562
+ });
563
+
564
+ for (const inst of instances) await this._runHook('afterCreate', inst);
565
+ return instances;
566
+ }
567
+
568
+ // Fallback: individual inserts
569
+ return Promise.all(dataArray.map(d => this.create(d)));
570
+ }
571
+
572
+ /**
573
+ * Find records matching conditions.
574
+ *
575
+ * @param {object} [conditions={}] - WHERE conditions `{ key: value }`.
576
+ * @returns {Promise<Model[]>} Matching records.
577
+ */
578
+ static async find(conditions = {})
579
+ {
580
+ const q = this.query().where(conditions);
581
+ return q.exec();
582
+ }
583
+
584
+ /**
585
+ * Find a single record matching conditions.
586
+ *
587
+ * @param {object} conditions - WHERE conditions.
588
+ * @returns {Promise<Model|null>} First matching record, or null.
589
+ */
590
+ static async findOne(conditions)
591
+ {
592
+ return this.query().where(conditions).first();
593
+ }
594
+
595
+ /**
596
+ * Find a record by primary key.
597
+ *
598
+ * @param {*} id - Primary key value.
599
+ * @returns {Promise<Model|null>} Matching record, or null.
600
+ */
601
+ static async findById(id)
602
+ {
603
+ const pk = this._primaryKey();
604
+ return this.query().where(pk, id).first();
605
+ }
606
+
607
+ /**
608
+ * Find one or create if not found.
609
+ *
610
+ * @param {object} conditions - Search conditions.
611
+ * @param {object} [defaults={}] - Additional data for creation.
612
+ * @returns {Promise<{ instance: Model, created: boolean }>}
613
+ */
614
+ static async findOrCreate(conditions, defaults = {})
615
+ {
616
+ const existing = await this.findOne(conditions);
617
+ if (existing) return { instance: existing, created: false };
618
+ const instance = await this.create({ ...conditions, ...defaults });
619
+ return { instance, created: true };
620
+ }
621
+
622
+ /**
623
+ * Update records matching conditions.
624
+ *
625
+ * @param {object} conditions - WHERE conditions.
626
+ * @param {object} data - Fields to update.
627
+ * @returns {Promise<number>} Number of updated records.
628
+ */
629
+ static async updateWhere(conditions, data)
630
+ {
631
+ data = this._stripGuarded(data);
632
+ if (this.timestamps && this._fullSchema().updatedAt)
633
+ {
634
+ data.updatedAt = new Date();
635
+ }
636
+ await this._runHook('beforeUpdate', data);
637
+ try { return await this._adapter.updateWhere(this.table, conditions, data); }
638
+ catch (e) { log.error('%s updateWhere failed: %s', this.table, e.message); throw e; }
639
+ }
640
+
641
+ /**
642
+ * Delete records matching conditions.
643
+ *
644
+ * @param {object} conditions - WHERE conditions.
645
+ * @returns {Promise<number>} Number of deleted records.
646
+ */
647
+ static async deleteWhere(conditions)
648
+ {
649
+ if (this.softDelete)
650
+ {
651
+ try { return await this._adapter.updateWhere(this.table, conditions, { deletedAt: new Date() }); }
652
+ catch (e) { log.error('%s deleteWhere (soft) failed: %s', this.table, e.message); throw e; }
653
+ }
654
+ try { return await this._adapter.deleteWhere(this.table, conditions); }
655
+ catch (e) { log.error('%s deleteWhere failed: %s', this.table, e.message); throw e; }
656
+ }
657
+
658
+ /**
659
+ * Count records matching conditions.
660
+ *
661
+ * @param {object} [conditions={}] - WHERE conditions.
662
+ * @returns {Promise<number>} Number of matching records.
663
+ */
664
+ static async count(conditions = {})
665
+ {
666
+ return this.query().where(conditions).count();
667
+ }
668
+
669
+ /**
670
+ * Check whether any records matching conditions exist.
671
+ *
672
+ * @param {object} [conditions={}] - WHERE conditions.
673
+ * @returns {Promise<boolean>} True if any matching records exist.
674
+ *
675
+ * @example
676
+ * if (await User.exists({ email: 'a@b.com' })) { ... }
677
+ */
678
+ static async exists(conditions = {})
679
+ {
680
+ return this.query().where(conditions).exists();
681
+ }
682
+
683
+ /**
684
+ * Insert or update a record matching conditions.
685
+ * If a matching record exists, update it. Otherwise, create a new one.
686
+ *
687
+ * @param {object} conditions - Search conditions (unique fields).
688
+ * @param {object} data - Data to set (merged with conditions on create).
689
+ * @returns {Promise<{ instance: Model, created: boolean }>}
690
+ *
691
+ * @example
692
+ * const { instance, created } = await User.upsert(
693
+ * { email: 'a@b.com' },
694
+ * { name: 'Alice', role: 'admin' }
695
+ * );
696
+ */
697
+ static async upsert(conditions, data = {})
698
+ {
699
+ const existing = await this.findOne(conditions);
700
+ if (existing)
701
+ {
702
+ await existing.update(data);
703
+ return { instance: existing, created: false };
704
+ }
705
+ const instance = await this.create({ ...conditions, ...data });
706
+ return { instance, created: true };
707
+ }
708
+
709
+ /**
710
+ * Start a query with a named scope applied.
711
+ *
712
+ * @param {string} name - Scope name (from `static scopes`).
713
+ * @param {...*} [args] - Additional arguments passed to the scope function.
714
+ * @returns {Query} Scoped query builder.
715
+ *
716
+ * @example
717
+ * await User.scope('active').where('role', 'admin');
718
+ * await User.scope('olderThan', 21).limit(10);
719
+ */
720
+ static scope(name, ...args)
721
+ {
722
+ if (!this.scopes || typeof this.scopes[name] !== 'function')
723
+ {
724
+ throw new Error(`Unknown scope "${name}" on ${this.name}`);
725
+ }
726
+ const q = this.query();
727
+ this.scopes[name](q, ...args);
728
+ return q;
729
+ }
730
+
731
+ /**
732
+ * Start a fluent query builder.
733
+ *
734
+ * @returns {Query} New fluent query builder.
735
+ *
736
+ * @example
737
+ * const results = await User.query()
738
+ * .where('age', '>', 18)
739
+ * .orderBy('name')
740
+ * .limit(10);
741
+ */
742
+ static query()
743
+ {
744
+ if (!this._adapter) throw new Error(`Model "${this.name}" is not registered with a database`);
745
+ const q = new Query(this, this._adapter);
746
+
747
+ // Auto-exclude soft-deleted records
748
+ if (this.softDelete)
749
+ {
750
+ q.whereNull('deletedAt');
751
+ }
752
+
753
+ return q;
754
+ }
755
+
756
+ // -- LINQ-Inspired Static Shortcuts -----------------
757
+
758
+ /**
759
+ * Find the first record matching optional conditions.
760
+ *
761
+ * @param {object} [conditions={}] - WHERE conditions.
762
+ * @returns {Promise<Model|null>} First matching record, or null.
763
+ *
764
+ * @example
765
+ * const admin = await User.first({ role: 'admin' });
766
+ * const oldest = await User.first(); // first by PK
767
+ */
768
+ static async first(conditions = {})
769
+ {
770
+ return this.query().where(conditions).first();
771
+ }
772
+
773
+ /**
774
+ * Find the last record matching optional conditions.
775
+ *
776
+ * @param {object} [conditions={}] - WHERE conditions.
777
+ * @returns {Promise<Model|null>} Last matching record, or null.
778
+ *
779
+ * @example
780
+ * const newest = await User.last();
781
+ * const lastAdmin = await User.last({ role: 'admin' });
782
+ */
783
+ static async last(conditions = {})
784
+ {
785
+ return this.query().where(conditions).last();
786
+ }
787
+
788
+ /**
789
+ * Rich pagination with metadata.
790
+ * Returns `{ data, total, page, perPage, pages, hasNext, hasPrev }`.
791
+ *
792
+ * @param {number} page - 1-indexed page number.
793
+ * @param {number} [perPage=20] - Items per page.
794
+ * @param {object} [conditions={}] - Optional WHERE conditions.
795
+ * @returns {Promise<object>} Pagination result with data, total, page, perPage, pages, hasNext, hasPrev.
796
+ *
797
+ * @example
798
+ * const result = await User.paginate(2, 10, { role: 'admin' });
799
+ * // { data: [...], total: 53, page: 2, perPage: 10,
800
+ * // pages: 6, hasNext: true, hasPrev: true }
801
+ */
802
+ static async paginate(page, perPage = 20, conditions = {})
803
+ {
804
+ return this.query().where(conditions).paginate(page, perPage);
805
+ }
806
+
807
+ /**
808
+ * Process all matching records in batches.
809
+ * Calls `fn(batch, batchIndex)` for each chunk.
810
+ *
811
+ * @param {number} size - Batch size.
812
+ * @param {Function} fn - Called with (batch: Model[], index: number).
813
+ * @param {object} [conditions={}] - Optional WHERE conditions.
814
+ * @returns {Promise<void>}
815
+ *
816
+ * @example
817
+ * await User.chunk(100, async (users, i) => {
818
+ * for (const u of users) await u.update({ migrated: true });
819
+ * }, { active: true });
820
+ */
821
+ static async chunk(size, fn, conditions = {})
822
+ {
823
+ return this.query().where(conditions).chunk(size, fn);
824
+ }
825
+
826
+ /**
827
+ * Get all records, optionally filtered.
828
+ * Alias for find() — for LINQ-familiarity.
829
+ *
830
+ * @param {object} [conditions={}] - WHERE conditions.
831
+ * @returns {Promise<Model[]>} All matching records.
832
+ */
833
+ static async all(conditions = {})
834
+ {
835
+ return this.find(conditions);
836
+ }
837
+
838
+ /**
839
+ * Get a random record.
840
+ *
841
+ * @param {object} [conditions={}] - Optional WHERE conditions.
842
+ * @returns {Promise<Model|null>} Random matching record, or null.
843
+ *
844
+ * @example
845
+ * const luckyUser = await User.random();
846
+ * const randomAdmin = await User.random({ role: 'admin' });
847
+ */
848
+ static async random(conditions = {})
849
+ {
850
+ const total = await this.count(conditions);
851
+ if (total === 0) return null;
852
+ const idx = Math.floor(Math.random() * total);
853
+ return this.query().where(conditions).offset(idx).first();
854
+ }
855
+
856
+ /**
857
+ * Pluck values for a single column across all matching records.
858
+ *
859
+ * @param {string} field - Column name to extract.
860
+ * @param {object} [conditions={}] - Optional WHERE conditions.
861
+ * @returns {Promise<Array>} Values for the specified column.
862
+ *
863
+ * @example
864
+ * const emails = await User.pluck('email');
865
+ * const adminNames = await User.pluck('name', { role: 'admin' });
866
+ */
867
+ static async pluck(field, conditions = {})
868
+ {
869
+ return this.query().where(conditions).pluck(field);
870
+ }
871
+
872
+ // -- Relationships ----------------------------------
873
+
874
+ /**
875
+ * Define a hasMany relationship.
876
+ * @param {Function} RelatedModel - The related Model class.
877
+ * @param {string} foreignKey - Foreign key column on the related table.
878
+ * @param {string} [localKey] - Local key (default: primary key).
879
+ */
880
+ static hasMany(RelatedModel, foreignKey, localKey)
881
+ {
882
+ const pk = localKey || this._primaryKey();
883
+ if (!this._relations) this._relations = {};
884
+ this._relations[RelatedModel.name] = { type: 'hasMany', model: RelatedModel, foreignKey, localKey: pk };
885
+ }
886
+
887
+ /**
888
+ * Define a hasOne relationship.
889
+ * @param {Function} RelatedModel - The related Model class.
890
+ * @param {string} foreignKey - Foreign key column on the related table.
891
+ * @param {string} [localKey] - Local key (default: primary key).
892
+ */
893
+ static hasOne(RelatedModel, foreignKey, localKey)
894
+ {
895
+ const pk = localKey || this._primaryKey();
896
+ if (!this._relations) this._relations = {};
897
+ this._relations[RelatedModel.name] = { type: 'hasOne', model: RelatedModel, foreignKey, localKey: pk };
898
+ }
899
+
900
+ /**
901
+ * Define a belongsTo relationship.
902
+ * @param {Function} RelatedModel - The related Model class.
903
+ * @param {string} foreignKey - Foreign key column on THIS table.
904
+ * @param {string} [otherKey] - Key on the related table (default: its primary key).
905
+ */
906
+ static belongsTo(RelatedModel, foreignKey, otherKey)
907
+ {
908
+ const ok = otherKey || RelatedModel._primaryKey();
909
+ if (!this._relations) this._relations = {};
910
+ this._relations[RelatedModel.name] = { type: 'belongsTo', model: RelatedModel, foreignKey, localKey: ok };
911
+ }
912
+
913
+ /**
914
+ * Define a many-to-many relationship through a junction/pivot table.
915
+ *
916
+ * @param {Function} RelatedModel - The related Model class.
917
+ * @param {object} opts - Relationship options.
918
+ * @param {string} opts.through - Junction table name (e.g. 'user_roles').
919
+ * @param {string} opts.foreignKey - Column on the junction table referencing THIS model.
920
+ * @param {string} opts.otherKey - Column on the junction table referencing the related model.
921
+ * @param {string} [opts.localKey] - Local key (default: primary key).
922
+ * @param {string} [opts.relatedKey] - Related model key (default: its primary key).
923
+ *
924
+ * @example
925
+ * User.belongsToMany(Role, {
926
+ * through: 'user_roles',
927
+ * foreignKey: 'userId',
928
+ * otherKey: 'roleId'
929
+ * });
930
+ * const roles = await user.load('Role'); // returns Role[]
931
+ */
932
+ static belongsToMany(RelatedModel, opts = {})
933
+ {
934
+ if (!opts.through || !opts.foreignKey || !opts.otherKey)
935
+ {
936
+ throw new Error('belongsToMany requires through, foreignKey, and otherKey');
937
+ }
938
+ const pk = opts.localKey || this._primaryKey();
939
+ const rpk = opts.relatedKey || RelatedModel._primaryKey();
940
+ if (!this._relations) this._relations = {};
941
+ this._relations[RelatedModel.name] = {
942
+ type: 'belongsToMany',
943
+ model: RelatedModel,
944
+ through: opts.through,
945
+ foreignKey: opts.foreignKey,
946
+ otherKey: opts.otherKey,
947
+ localKey: pk,
948
+ relatedKey: rpk,
949
+ };
950
+ }
951
+
952
+ /**
953
+ * Load a related model for this instance.
954
+ *
955
+ * @param {string} relationName - Name of the related Model class or relation alias.
956
+ * @returns {Promise<Model|Model[]|null>} The related model(s) or null.
957
+ */
958
+ async load(relationName)
959
+ {
960
+ const ctor = this.constructor;
961
+ const rel = ctor._relations && ctor._relations[relationName];
962
+ if (!rel) throw new Error(`Unknown relation "${relationName}" on ${ctor.name}`);
963
+
964
+ switch (rel.type)
965
+ {
966
+ case 'hasMany':
967
+ return rel.model.find({ [rel.foreignKey]: this[rel.localKey] });
968
+ case 'hasOne':
969
+ return rel.model.findOne({ [rel.foreignKey]: this[rel.localKey] });
970
+ case 'belongsTo':
971
+ return rel.model.findOne({ [rel.localKey]: this[rel.foreignKey] });
972
+ case 'belongsToMany':
973
+ {
974
+ // Query the junction table to find related IDs
975
+ const junctionRows = await ctor._adapter.execute({
976
+ action: 'select',
977
+ table: rel.through,
978
+ fields: [rel.otherKey],
979
+ where: [{ field: rel.foreignKey, op: '=', value: this[rel.localKey], logic: 'AND' }],
980
+ orderBy: [], joins: [], groupBy: [], having: [],
981
+ limit: null, offset: null, distinct: false,
982
+ });
983
+ if (!junctionRows.length) return [];
984
+ const relatedIds = junctionRows.map(r => r[rel.otherKey]);
985
+ return rel.model.query().whereIn(rel.relatedKey, relatedIds).exec();
986
+ }
987
+ case 'morphOne':
988
+ {
989
+ const typeCol = `${rel.morphName}_type`;
990
+ const idCol = `${rel.morphName}_id`;
991
+ return rel.model.findOne({ [typeCol]: ctor.name, [idCol]: this[rel.localKey] });
992
+ }
993
+ case 'morphMany':
994
+ {
995
+ const typeCol = `${rel.morphName}_type`;
996
+ const idCol = `${rel.morphName}_id`;
997
+ return rel.model.find({ [typeCol]: ctor.name, [idCol]: this[rel.localKey] });
998
+ }
999
+ case 'hasManyThrough':
1000
+ {
1001
+ // Get intermediate records
1002
+ const throughRecords = await rel.through.find({ [rel.firstKey]: this[rel.localKey] });
1003
+ if (!throughRecords.length) return [];
1004
+ const throughIds = throughRecords.map(r => r[rel.secondLocalKey]);
1005
+ return rel.model.query().whereIn(rel.secondKey, throughIds).exec();
1006
+ }
1007
+ default:
1008
+ throw new Error(`Unknown relation type "${rel.type}"`);
1009
+ }
1010
+ }
1011
+
1012
+ // -- Internal Static Helpers ------------------------
1013
+
1014
+ /**
1015
+ * Strip guarded fields from a data object.
1016
+ * Guarded fields are defined in the schema with `guarded: true`.
1017
+ * They cannot be set via mass-assignment (create / update with object).
1018
+ *
1019
+ * @param {object} data - The input data.
1020
+ * @returns {object} A copy of data without guarded fields.
1021
+ * @private
1022
+ */
1023
+ static _stripGuarded(data)
1024
+ {
1025
+ const schema = this.schema;
1026
+ const guardedKeys = Object.entries(schema)
1027
+ .filter(([, def]) => def.guarded)
1028
+ .map(([name]) => name);
1029
+ if (guardedKeys.length === 0) return data;
1030
+ const cleaned = { ...data };
1031
+ for (const key of guardedKeys) delete cleaned[key];
1032
+ return cleaned;
1033
+ }
1034
+
1035
+ /**
1036
+ * Get the full schema including auto-fields.
1037
+ * @returns {object} Schema with auto-generated timestamp and soft-delete columns.
1038
+ * @private
1039
+ */
1040
+ static _fullSchema()
1041
+ {
1042
+ const s = { ...this.schema };
1043
+ if (this.timestamps)
1044
+ {
1045
+ if (!s.createdAt) s.createdAt = { type: 'datetime', default: () => new Date() };
1046
+ if (!s.updatedAt) s.updatedAt = { type: 'datetime', default: () => new Date() };
1047
+ }
1048
+ if (this.softDelete)
1049
+ {
1050
+ if (!s.deletedAt) s.deletedAt = { type: 'datetime', nullable: true };
1051
+ }
1052
+ return s;
1053
+ }
1054
+
1055
+ /**
1056
+ * Get the primary key column name(s).
1057
+ * Returns a single string for simple PKs, or an array for composite PKs.
1058
+ * @returns {string|string[]} Primary key column name(s).
1059
+ * @private
1060
+ */
1061
+ static _primaryKey()
1062
+ {
1063
+ const pks = [];
1064
+ for (const [name, def] of Object.entries(this.schema))
1065
+ {
1066
+ if (def.primaryKey) pks.push(name);
1067
+ }
1068
+ if (pks.length === 0) return 'id'; // convention
1069
+ if (pks.length === 1) return pks[0];
1070
+ return pks; // composite PK
1071
+ }
1072
+
1073
+ /**
1074
+ * Create a model instance from a raw database row.
1075
+ * @param {object} row - Data row object.
1076
+ * @returns {Model} Hydrated model instance.
1077
+ * @private
1078
+ */
1079
+ static _fromRow(row)
1080
+ {
1081
+ const instance = new this(row);
1082
+ instance._persisted = true;
1083
+ instance._snapshot();
1084
+ return instance;
1085
+ }
1086
+
1087
+ /**
1088
+ * Run a lifecycle hook if defined.
1089
+ * Also emits model events and notifies observers.
1090
+ * @param {string} hookName - Lifecycle hook name.
1091
+ * @param {*} data - Record data object.
1092
+ * @returns {Promise<*>} Resolved value.
1093
+ * @private
1094
+ */
1095
+ static async _runHook(hookName, data)
1096
+ {
1097
+ // Check for static hook on class
1098
+ if (typeof this[hookName] === 'function')
1099
+ {
1100
+ await this[hookName](data);
1101
+ }
1102
+ // Check hooks object
1103
+ else if (this.hooks && typeof this.hooks[hookName] === 'function')
1104
+ {
1105
+ await this.hooks[hookName](data);
1106
+ }
1107
+
1108
+ // Emit model event
1109
+ this._emit(hookName, data);
1110
+
1111
+ // Notify observers
1112
+ this._notifyObservers(hookName, data);
1113
+
1114
+ return data;
1115
+ }
1116
+
1117
+ /**
1118
+ * Sync the table schema with the database (create table if not exists).
1119
+ * @returns {Promise<void>}
1120
+ */
1121
+ static async sync()
1122
+ {
1123
+ if (!this._adapter) throw new Error(`Model "${this.name}" is not registered with a database`);
1124
+ return this._adapter.createTable(this.table, this._fullSchema());
1125
+ }
1126
+
1127
+ /**
1128
+ * Drop the table.
1129
+ * @returns {Promise<void>}
1130
+ */
1131
+ static async drop()
1132
+ {
1133
+ if (!this._adapter) throw new Error(`Model "${this.name}" is not registered with a database`);
1134
+ return this._adapter.dropTable(this.table);
1135
+ }
1136
+
1137
+ // -- Attribute Casting Helpers ----------------------
1138
+
1139
+ /**
1140
+ * Apply a cast transformation on get (reading from model).
1141
+ * @param {*} value - Raw stored value.
1142
+ * @param {string|object} cast - Cast type or custom cast object.
1143
+ * @returns {*} Transformed value.
1144
+ * @private
1145
+ */
1146
+ static _applyCastGet(value, cast)
1147
+ {
1148
+ if (value === null || value === undefined) return value;
1149
+ if (typeof cast === 'object' && typeof cast.get === 'function')
1150
+ {
1151
+ return cast.get(value);
1152
+ }
1153
+ switch (cast)
1154
+ {
1155
+ case 'json':
1156
+ case 'array':
1157
+ return typeof value === 'string' ? JSON.parse(value) : value;
1158
+ case 'boolean':
1159
+ if (typeof value === 'boolean') return value;
1160
+ if (typeof value === 'number') return value !== 0;
1161
+ if (typeof value === 'string') return ['true', '1', 'yes'].includes(value.toLowerCase());
1162
+ return Boolean(value);
1163
+ case 'integer':
1164
+ return parseInt(value, 10) || 0;
1165
+ case 'float':
1166
+ return parseFloat(value) || 0;
1167
+ case 'date':
1168
+ return value instanceof Date ? value : new Date(value);
1169
+ case 'string':
1170
+ return String(value);
1171
+ default:
1172
+ return value;
1173
+ }
1174
+ }
1175
+
1176
+ /**
1177
+ * Apply a cast transformation on set (writing to model).
1178
+ * @param {*} value - Input value.
1179
+ * @param {string|object} cast - Cast type or custom cast object.
1180
+ * @returns {*} Transformed value for storage.
1181
+ * @private
1182
+ */
1183
+ static _applyCastSet(value, cast)
1184
+ {
1185
+ if (value === null || value === undefined) return value;
1186
+ if (typeof cast === 'object' && typeof cast.set === 'function')
1187
+ {
1188
+ return cast.set(value);
1189
+ }
1190
+ switch (cast)
1191
+ {
1192
+ case 'json':
1193
+ case 'array':
1194
+ return typeof value === 'string' ? value : JSON.stringify(value);
1195
+ case 'boolean':
1196
+ if (typeof value === 'boolean') return value;
1197
+ if (typeof value === 'number') return value !== 0;
1198
+ if (typeof value === 'string') return ['true', '1', 'yes'].includes(value.toLowerCase());
1199
+ return Boolean(value);
1200
+ case 'integer':
1201
+ return parseInt(value, 10) || 0;
1202
+ case 'float':
1203
+ return parseFloat(value) || 0;
1204
+ case 'date':
1205
+ return value instanceof Date ? value : new Date(value);
1206
+ case 'string':
1207
+ return String(value);
1208
+ default:
1209
+ return value;
1210
+ }
1211
+ }
1212
+
1213
+ /**
1214
+ * Get an attribute value with accessor/cast applied.
1215
+ *
1216
+ * @param {string} key - Attribute name.
1217
+ * @returns {*} Transformed value.
1218
+ *
1219
+ * @example
1220
+ * const email = user.getAttribute('email');
1221
+ */
1222
+ getAttribute(key)
1223
+ {
1224
+ const ctor = this.constructor;
1225
+ const accessors = ctor.accessors || {};
1226
+ const casts = ctor.casts || {};
1227
+ const computed = ctor.computed || {};
1228
+
1229
+ // Check computed first
1230
+ if (typeof computed[key] === 'function')
1231
+ {
1232
+ return computed[key](this);
1233
+ }
1234
+
1235
+ let val = this[key];
1236
+
1237
+ // Apply accessor
1238
+ if (typeof accessors[key] === 'function')
1239
+ {
1240
+ return accessors[key](val, this);
1241
+ }
1242
+
1243
+ // Apply cast get
1244
+ if (casts[key])
1245
+ {
1246
+ return Model._applyCastGet(val, casts[key]);
1247
+ }
1248
+
1249
+ return val;
1250
+ }
1251
+
1252
+ /**
1253
+ * Set an attribute value with mutator/cast applied.
1254
+ *
1255
+ * @param {string} key - Attribute name.
1256
+ * @param {*} value - Value to set.
1257
+ * @returns {Model} `this` for chaining.
1258
+ *
1259
+ * @example
1260
+ * user.setAttribute('email', 'ALICE@EXAMPLE.COM');
1261
+ * // If mutator lowercases: user.email => 'alice@example.com'
1262
+ */
1263
+ setAttribute(key, value)
1264
+ {
1265
+ const ctor = this.constructor;
1266
+ const mutators = ctor.mutators || {};
1267
+ const casts = ctor.casts || {};
1268
+
1269
+ if (typeof mutators[key] === 'function')
1270
+ {
1271
+ this[key] = mutators[key](value, this);
1272
+ }
1273
+ else if (casts[key])
1274
+ {
1275
+ this[key] = Model._applyCastSet(value, casts[key]);
1276
+ }
1277
+ else
1278
+ {
1279
+ this[key] = value;
1280
+ }
1281
+ return this;
1282
+ }
1283
+
1284
+ // -- Model Events -----------------------------------
1285
+
1286
+ /**
1287
+ * Get or create the EventEmitter for this model class.
1288
+ * @returns {EventEmitter} The model's event emitter.
1289
+ * @private
1290
+ */
1291
+ static _getEmitter()
1292
+ {
1293
+ if (!this.hasOwnProperty('_emitter') || !this._emitter)
1294
+ {
1295
+ this._emitter = new EventEmitter();
1296
+ }
1297
+ return this._emitter;
1298
+ }
1299
+
1300
+ /**
1301
+ * Register an event listener on this model.
1302
+ * Supported events: `creating`, `created`, `updating`, `updated`,
1303
+ * `deleting`, `deleted`, `saving`, `saved`.
1304
+ *
1305
+ * @param {string} event - Event name.
1306
+ * @param {Function} listener - Callback `(data) => {}`.
1307
+ * @returns {typeof Model} The model class (for chaining).
1308
+ *
1309
+ * @example
1310
+ * User.on('created', (user) => {
1311
+ * console.log('New user:', user.name);
1312
+ * });
1313
+ *
1314
+ * User.on('updating', (changes) => {
1315
+ * console.log('Updating fields:', Object.keys(changes));
1316
+ * });
1317
+ */
1318
+ static on(event, listener)
1319
+ {
1320
+ this._getEmitter().on(event, listener);
1321
+ return this;
1322
+ }
1323
+
1324
+ /**
1325
+ * Register a one-time event listener.
1326
+ *
1327
+ * @param {string} event - Event name.
1328
+ * @param {Function} listener - Callback function.
1329
+ * @returns {typeof Model} The model class (for chaining).
1330
+ */
1331
+ static once(event, listener)
1332
+ {
1333
+ this._getEmitter().once(event, listener);
1334
+ return this;
1335
+ }
1336
+
1337
+ /**
1338
+ * Remove an event listener.
1339
+ *
1340
+ * @param {string} event - Event name.
1341
+ * @param {Function} listener - Callback to remove.
1342
+ * @returns {typeof Model} The model class (for chaining).
1343
+ */
1344
+ static off(event, listener)
1345
+ {
1346
+ this._getEmitter().off(event, listener);
1347
+ return this;
1348
+ }
1349
+
1350
+ /**
1351
+ * Remove all listeners for an event, or all listeners entirely.
1352
+ *
1353
+ * @param {string} [event] - Event name. If omitted, removes all listeners.
1354
+ * @returns {typeof Model} The model class (for chaining).
1355
+ */
1356
+ static removeAllListeners(event)
1357
+ {
1358
+ if (event !== undefined)
1359
+ {
1360
+ this._getEmitter().removeAllListeners(event);
1361
+ }
1362
+ else
1363
+ {
1364
+ this._getEmitter().removeAllListeners();
1365
+ }
1366
+ return this;
1367
+ }
1368
+
1369
+ /**
1370
+ * Emit a model event.
1371
+ * @param {string} event - Event name.
1372
+ * @param {*} data - Event data.
1373
+ * @private
1374
+ */
1375
+ static _emit(event, data)
1376
+ {
1377
+ // Map hook names to event names
1378
+ const eventMap = {
1379
+ beforeCreate: 'creating',
1380
+ afterCreate: 'created',
1381
+ beforeUpdate: 'updating',
1382
+ afterUpdate: 'updated',
1383
+ beforeDelete: 'deleting',
1384
+ afterDelete: 'deleted',
1385
+ };
1386
+ const eventName = eventMap[event];
1387
+ if (eventName && this.hasOwnProperty('_emitter') && this._emitter)
1388
+ {
1389
+ this._emitter.emit(eventName, data);
1390
+ }
1391
+ }
1392
+
1393
+ // -- Observers --------------------------------------
1394
+
1395
+ /**
1396
+ * Register an observer for this model.
1397
+ * An observer is an object with methods named after lifecycle events:
1398
+ * `creating`, `created`, `updating`, `updated`, `deleting`, `deleted`.
1399
+ *
1400
+ * @param {object} observer - Observer object with event handler methods.
1401
+ * @returns {typeof Model} The model class (for chaining).
1402
+ *
1403
+ * @example
1404
+ * const UserObserver = {
1405
+ * created(user) { console.log('New user:', user.name); },
1406
+ * updating(changes) { console.log('Updating:', changes); },
1407
+ * deleted(user) { console.log('Deleted user:', user.id); },
1408
+ * };
1409
+ *
1410
+ * User.observe(UserObserver);
1411
+ */
1412
+ static observe(observer)
1413
+ {
1414
+ if (!this.hasOwnProperty('_observers'))
1415
+ {
1416
+ this._observers = [];
1417
+ }
1418
+ this._observers.push(observer);
1419
+ return this;
1420
+ }
1421
+
1422
+ /**
1423
+ * Remove an observer from this model.
1424
+ *
1425
+ * @param {object} observer - Observer to remove.
1426
+ * @returns {typeof Model} The model class (for chaining).
1427
+ */
1428
+ static unobserve(observer)
1429
+ {
1430
+ if (this.hasOwnProperty('_observers'))
1431
+ {
1432
+ this._observers = this._observers.filter(o => o !== observer);
1433
+ }
1434
+ return this;
1435
+ }
1436
+
1437
+ /**
1438
+ * Notify all registered observers of a lifecycle event.
1439
+ * @param {string} hookName - Hook name (e.g. 'beforeCreate').
1440
+ * @param {*} data - Event data.
1441
+ * @private
1442
+ */
1443
+ static _notifyObservers(hookName, data)
1444
+ {
1445
+ const eventMap = {
1446
+ beforeCreate: 'creating',
1447
+ afterCreate: 'created',
1448
+ beforeUpdate: 'updating',
1449
+ afterUpdate: 'updated',
1450
+ beforeDelete: 'deleting',
1451
+ afterDelete: 'deleted',
1452
+ };
1453
+ const eventName = eventMap[hookName];
1454
+ if (!eventName) return;
1455
+
1456
+ const observers = this.hasOwnProperty('_observers') ? this._observers : [];
1457
+ for (const observer of observers)
1458
+ {
1459
+ if (typeof observer[eventName] === 'function')
1460
+ {
1461
+ observer[eventName](data);
1462
+ }
1463
+ }
1464
+ }
1465
+
1466
+ // -- Advanced Relationships -------------------------
1467
+
1468
+ /**
1469
+ * Define a polymorphic one-to-one relationship (morphOne).
1470
+ * The related table uses two columns: a type column and an ID column.
1471
+ *
1472
+ * @param {Function} RelatedModel - The related Model class.
1473
+ * @param {string} morphName - Base name for the polymorphic columns (e.g. 'commentable').
1474
+ * @param {string} [localKey] - Local key (default: primary key).
1475
+ *
1476
+ * @example
1477
+ * // Image can belong to either User or Post
1478
+ * User.morphOne(Image, 'imageable');
1479
+ * // Related table has: imageable_type, imageable_id columns
1480
+ * const avatar = await user.load('Image'); // Image where imageable_type='User', imageable_id=user.id
1481
+ */
1482
+ static morphOne(RelatedModel, morphName, localKey)
1483
+ {
1484
+ const pk = localKey || this._primaryKey();
1485
+ if (!this._relations) this._relations = {};
1486
+ this._relations[RelatedModel.name] = {
1487
+ type: 'morphOne',
1488
+ model: RelatedModel,
1489
+ morphName,
1490
+ localKey: pk,
1491
+ };
1492
+ }
1493
+
1494
+ /**
1495
+ * Define a polymorphic one-to-many relationship (morphMany).
1496
+ * The related table uses two columns: a type column and an ID column.
1497
+ *
1498
+ * @param {Function} RelatedModel - The related Model class.
1499
+ * @param {string} morphName - Base name for the polymorphic columns (e.g. 'commentable').
1500
+ * @param {string} [localKey] - Local key (default: primary key).
1501
+ *
1502
+ * @example
1503
+ * // Comments can belong to either Post or Video
1504
+ * Post.morphMany(Comment, 'commentable');
1505
+ * const comments = await post.load('Comment');
1506
+ */
1507
+ static morphMany(RelatedModel, morphName, localKey)
1508
+ {
1509
+ const pk = localKey || this._primaryKey();
1510
+ if (!this._relations) this._relations = {};
1511
+ this._relations[RelatedModel.name] = {
1512
+ type: 'morphMany',
1513
+ model: RelatedModel,
1514
+ morphName,
1515
+ localKey: pk,
1516
+ };
1517
+ }
1518
+
1519
+ /**
1520
+ * Define a has-many-through relationship.
1521
+ * Accesses distant relations through an intermediate table.
1522
+ *
1523
+ * @param {Function} RelatedModel - The distant related Model class.
1524
+ * @param {Function} ThroughModel - The intermediate Model class.
1525
+ * @param {string} firstKey - FK on the through table referencing this model.
1526
+ * @param {string} secondKey - FK on the related table referencing the through table.
1527
+ * @param {string} [localKey] - Local key (default: primary key).
1528
+ * @param {string} [secondLocalKey] - Key on the through table matched by secondKey (default: through model PK).
1529
+ *
1530
+ * @example
1531
+ * // Country → User → Post
1532
+ * // A country has many posts through users
1533
+ * Country.hasManyThrough(Post, User, 'countryId', 'userId');
1534
+ * const posts = await country.load('Post');
1535
+ */
1536
+ static hasManyThrough(RelatedModel, ThroughModel, firstKey, secondKey, localKey, secondLocalKey)
1537
+ {
1538
+ const pk = localKey || this._primaryKey();
1539
+ const throughPk = secondLocalKey || ThroughModel._primaryKey();
1540
+ if (!this._relations) this._relations = {};
1541
+ this._relations[RelatedModel.name] = {
1542
+ type: 'hasManyThrough',
1543
+ model: RelatedModel,
1544
+ through: ThroughModel,
1545
+ firstKey,
1546
+ secondKey,
1547
+ localKey: pk,
1548
+ secondLocalKey: throughPk,
1549
+ };
1550
+ }
1551
+
1552
+ /**
1553
+ * Define a self-referential relationship for tree/graph structures.
1554
+ * Sets up both parent and children relationships.
1555
+ *
1556
+ * @param {object} opts - Relationship options.
1557
+ * @param {string} opts.foreignKey - FK column referencing self (e.g. 'parentId').
1558
+ * @param {string} [opts.localKey] - Local key (default: primary key).
1559
+ * @param {string} [opts.parentName='parent'] - Name for the parent relationship.
1560
+ * @param {string} [opts.childrenName='children'] - Name for the children relationship.
1561
+ *
1562
+ * @example
1563
+ * Category.selfReferential({
1564
+ * foreignKey: 'parentId',
1565
+ * parentName: 'parent',
1566
+ * childrenName: 'children',
1567
+ * });
1568
+ *
1569
+ * const parent = await category.load('parent');
1570
+ * const children = await category.load('children');
1571
+ * const tree = await Category.tree(); // full tree structure
1572
+ */
1573
+ static selfReferential(opts = {})
1574
+ {
1575
+ if (!opts.foreignKey) throw new Error('selfReferential requires foreignKey');
1576
+ const pk = opts.localKey || this._primaryKey();
1577
+ const parentName = opts.parentName || 'parent';
1578
+ const childrenName = opts.childrenName || 'children';
1579
+
1580
+ if (!this._relations) this._relations = {};
1581
+
1582
+ // Parent relationship (belongsTo self)
1583
+ this._relations[parentName] = {
1584
+ type: 'belongsTo',
1585
+ model: this,
1586
+ foreignKey: opts.foreignKey,
1587
+ localKey: pk,
1588
+ };
1589
+
1590
+ // Children relationship (hasMany self)
1591
+ this._relations[childrenName] = {
1592
+ type: 'hasMany',
1593
+ model: this,
1594
+ foreignKey: opts.foreignKey,
1595
+ localKey: pk,
1596
+ };
1597
+ }
1598
+
1599
+ /**
1600
+ * Build a full tree structure from self-referential records.
1601
+ * Returns nested objects with a `children` array property.
1602
+ *
1603
+ * @param {object} [options] - Configuration options.
1604
+ * @param {string} [options.foreignKey='parentId'] - FK column for the parent reference.
1605
+ * @param {string} [options.childrenKey='children'] - Property name for nested children.
1606
+ * @param {*} [options.rootValue=null] - Value of foreignKey that indicates a root node.
1607
+ * @returns {Promise<object[]>} Array of root nodes with nested children.
1608
+ *
1609
+ * @example
1610
+ * const tree = await Category.tree({ foreignKey: 'parentId' });
1611
+ * // [{ id: 1, name: 'Root', children: [{ id: 2, name: 'Child', children: [] }] }]
1612
+ */
1613
+ static async tree(options = {})
1614
+ {
1615
+ const { foreignKey = 'parentId', childrenKey = 'children', rootValue = null } = options;
1616
+ const all = await this.find();
1617
+ const pk = this._primaryKey();
1618
+ const map = new Map();
1619
+ const roots = [];
1620
+
1621
+ for (const node of all) { node[childrenKey] = []; map.set(node[pk], node); }
1622
+
1623
+ for (const node of all)
1624
+ {
1625
+ const parentId = node[foreignKey];
1626
+ if (parentId === rootValue || parentId === null || parentId === undefined)
1627
+ {
1628
+ roots.push(node);
1629
+ }
1630
+ else
1631
+ {
1632
+ const parent = map.get(parentId);
1633
+ if (parent) parent[childrenKey].push(node);
1634
+ else roots.push(node); // orphan → treat as root
1635
+ }
1636
+ }
1637
+
1638
+ return roots;
1639
+ }
1640
+
1641
+ /**
1642
+ * Get all ancestors of this instance in a self-referential tree.
1643
+ *
1644
+ * @param {string} [foreignKey='parentId'] - FK column for the parent reference.
1645
+ * @returns {Promise<Model[]>} Array of ancestors from immediate parent to root.
1646
+ *
1647
+ * @example
1648
+ * const ancestors = await category.ancestors('parentId');
1649
+ * // [parentCategory, grandparentCategory, rootCategory]
1650
+ */
1651
+ async ancestors(foreignKey = 'parentId')
1652
+ {
1653
+ const ctor = this.constructor;
1654
+ const pk = ctor._primaryKey();
1655
+ const result = [];
1656
+ let currentId = this[foreignKey];
1657
+ const seen = new Set();
1658
+
1659
+ while (currentId !== null && currentId !== undefined)
1660
+ {
1661
+ if (seen.has(currentId)) break; // circular reference guard
1662
+ seen.add(currentId);
1663
+ const parent = await ctor.findById(currentId);
1664
+ if (!parent) break;
1665
+ result.push(parent);
1666
+ currentId = parent[foreignKey];
1667
+ }
1668
+
1669
+ return result;
1670
+ }
1671
+
1672
+ /**
1673
+ * Get all descendants of this instance in a self-referential tree.
1674
+ *
1675
+ * @param {string} [foreignKey='parentId'] - FK column for the parent reference.
1676
+ * @returns {Promise<Model[]>} Flat array of all descendants (breadth-first).
1677
+ *
1678
+ * @example
1679
+ * const descendants = await category.descendants('parentId');
1680
+ */
1681
+ async descendants(foreignKey = 'parentId')
1682
+ {
1683
+ const ctor = this.constructor;
1684
+ const pk = ctor._primaryKey();
1685
+ const result = [];
1686
+ const queue = [this[pk]];
1687
+ const seen = new Set([this[pk]]);
1688
+
1689
+ while (queue.length)
1690
+ {
1691
+ const parentId = queue.shift();
1692
+ const children = await ctor.find({ [foreignKey]: parentId });
1693
+ for (const child of children)
1694
+ {
1695
+ if (seen.has(child[pk])) continue; // circular reference guard
1696
+ seen.add(child[pk]);
1697
+ result.push(child);
1698
+ queue.push(child[pk]);
1699
+ }
1700
+ }
1701
+
1702
+ return result;
1703
+ }
1704
+ }
1705
+
1706
+ module.exports = Model;