zero-http 0.2.5 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +1250 -283
  2. package/documentation/config/db.js +25 -0
  3. package/documentation/config/middleware.js +44 -0
  4. package/documentation/config/tls.js +12 -0
  5. package/documentation/controllers/cookies.js +34 -0
  6. package/documentation/controllers/tasks.js +108 -0
  7. package/documentation/full-server.js +25 -184
  8. package/documentation/models/Task.js +21 -0
  9. package/documentation/public/data/api.json +404 -24
  10. package/documentation/public/data/docs.json +1139 -0
  11. package/documentation/public/data/examples.json +80 -2
  12. package/documentation/public/data/options.json +23 -8
  13. package/documentation/public/index.html +138 -99
  14. package/documentation/public/scripts/app.js +1 -3
  15. package/documentation/public/scripts/custom-select.js +189 -0
  16. package/documentation/public/scripts/data-sections.js +233 -250
  17. package/documentation/public/scripts/playground.js +270 -0
  18. package/documentation/public/scripts/ui.js +4 -3
  19. package/documentation/public/styles.css +56 -5
  20. package/documentation/public/vendor/icons/compress.svg +17 -17
  21. package/documentation/public/vendor/icons/database.svg +21 -0
  22. package/documentation/public/vendor/icons/env.svg +21 -0
  23. package/documentation/public/vendor/icons/fetch.svg +11 -14
  24. package/documentation/public/vendor/icons/security.svg +15 -0
  25. package/documentation/public/vendor/icons/sse.svg +12 -13
  26. package/documentation/public/vendor/icons/static.svg +12 -26
  27. package/documentation/public/vendor/icons/stream.svg +7 -13
  28. package/documentation/public/vendor/icons/validate.svg +17 -0
  29. package/documentation/routes/api.js +41 -0
  30. package/documentation/routes/core.js +20 -0
  31. package/documentation/routes/playground.js +29 -0
  32. package/documentation/routes/realtime.js +49 -0
  33. package/documentation/routes/uploads.js +71 -0
  34. package/index.js +62 -1
  35. package/lib/app.js +200 -8
  36. package/lib/body/json.js +28 -5
  37. package/lib/body/multipart.js +29 -1
  38. package/lib/body/raw.js +1 -1
  39. package/lib/body/sendError.js +1 -0
  40. package/lib/body/text.js +1 -1
  41. package/lib/body/typeMatch.js +6 -2
  42. package/lib/body/urlencoded.js +5 -2
  43. package/lib/debug.js +345 -0
  44. package/lib/env/index.js +440 -0
  45. package/lib/errors.js +231 -0
  46. package/lib/http/request.js +219 -1
  47. package/lib/http/response.js +410 -6
  48. package/lib/middleware/compress.js +39 -6
  49. package/lib/middleware/cookieParser.js +237 -0
  50. package/lib/middleware/cors.js +13 -2
  51. package/lib/middleware/csrf.js +135 -0
  52. package/lib/middleware/errorHandler.js +90 -0
  53. package/lib/middleware/helmet.js +176 -0
  54. package/lib/middleware/index.js +7 -2
  55. package/lib/middleware/rateLimit.js +12 -1
  56. package/lib/middleware/requestId.js +54 -0
  57. package/lib/middleware/static.js +95 -11
  58. package/lib/middleware/timeout.js +72 -0
  59. package/lib/middleware/validator.js +257 -0
  60. package/lib/orm/adapters/json.js +215 -0
  61. package/lib/orm/adapters/memory.js +383 -0
  62. package/lib/orm/adapters/mongo.js +444 -0
  63. package/lib/orm/adapters/mysql.js +272 -0
  64. package/lib/orm/adapters/postgres.js +394 -0
  65. package/lib/orm/adapters/sql-base.js +142 -0
  66. package/lib/orm/adapters/sqlite.js +311 -0
  67. package/lib/orm/index.js +276 -0
  68. package/lib/orm/model.js +895 -0
  69. package/lib/orm/query.js +807 -0
  70. package/lib/orm/schema.js +172 -0
  71. package/lib/router/index.js +136 -47
  72. package/lib/sse/stream.js +15 -3
  73. package/lib/ws/connection.js +19 -3
  74. package/lib/ws/handshake.js +3 -0
  75. package/lib/ws/index.js +3 -1
  76. package/lib/ws/room.js +222 -0
  77. package/package.json +15 -5
  78. package/types/app.d.ts +120 -0
  79. package/types/env.d.ts +80 -0
  80. package/types/errors.d.ts +147 -0
  81. package/types/fetch.d.ts +43 -0
  82. package/types/index.d.ts +135 -0
  83. package/types/middleware.d.ts +292 -0
  84. package/types/orm.d.ts +610 -0
  85. package/types/request.d.ts +99 -0
  86. package/types/response.d.ts +142 -0
  87. package/types/router.d.ts +78 -0
  88. package/types/sse.d.ts +78 -0
  89. package/types/websocket.d.ts +119 -0
@@ -0,0 +1,895 @@
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, and relationship definitions.
6
+ *
7
+ * @example
8
+ * const { Model, Database } = require('zero-http');
9
+ *
10
+ * class User extends Model {
11
+ * static table = 'users';
12
+ * static schema = {
13
+ * id: { type: 'integer', primaryKey: true, autoIncrement: true },
14
+ * name: { type: 'string', required: true, maxLength: 100 },
15
+ * email: { type: 'string', required: true, unique: true },
16
+ * role: { type: 'string', enum: ['user','admin'], default: 'user' },
17
+ * };
18
+ * static timestamps = true; // auto createdAt/updatedAt
19
+ * static softDelete = true; // deletedAt instead of real delete
20
+ * }
21
+ *
22
+ * db.register(User);
23
+ *
24
+ * const user = await User.create({ name: 'Alice', email: 'a@b.com' });
25
+ * const users = await User.find({ role: 'admin' });
26
+ * const u = await User.findById(1);
27
+ * await u.update({ name: 'Alice2' });
28
+ * await u.delete();
29
+ */
30
+ const { validate } = require('./schema');
31
+ const Query = require('./query');
32
+ const crypto = require('crypto');
33
+ const log = require('../debug')('zero:orm');
34
+
35
+ class Model
36
+ {
37
+ /**
38
+ * Table name — override in subclass.
39
+ * @type {string}
40
+ */
41
+ static table = '';
42
+
43
+ /**
44
+ * Column schema — override in subclass.
45
+ * @type {Object<string, object>}
46
+ */
47
+ static schema = {};
48
+
49
+ /**
50
+ * Enable auto timestamps (createdAt, updatedAt).
51
+ * @type {boolean}
52
+ */
53
+ static timestamps = false;
54
+
55
+ /**
56
+ * Enable soft deletes (deletedAt instead of real deletion).
57
+ * @type {boolean}
58
+ */
59
+ static softDelete = false;
60
+
61
+ /**
62
+ * Fields to hide from toJSON() serialization.
63
+ * Useful for excluding passwords, tokens, internal fields.
64
+ * @type {string[]}
65
+ *
66
+ * @example
67
+ * class User extends Model {
68
+ * static hidden = ['password', 'resetToken'];
69
+ * }
70
+ */
71
+ static hidden = [];
72
+
73
+ /**
74
+ * Named query scopes — reusable query conditions.
75
+ * Each scope is a function that receives a Query and returns it.
76
+ * @type {Object<string, Function>}
77
+ *
78
+ * @example
79
+ * class User extends Model {
80
+ * static scopes = {
81
+ * active: q => q.where('active', true),
82
+ * admins: q => q.where('role', 'admin'),
83
+ * olderThan: (q, age) => q.where('age', '>', age),
84
+ * };
85
+ * }
86
+ *
87
+ * // Use:
88
+ * await User.scope('active').scope('admins').limit(5);
89
+ * await User.scope('olderThan', 30);
90
+ */
91
+ static scopes = {};
92
+
93
+ /**
94
+ * Lifecycle hooks.
95
+ * Override these in subclasses: `static beforeCreate(data) { return data; }`
96
+ * @type {object}
97
+ */
98
+ static hooks = {};
99
+
100
+ /**
101
+ * Relationship definitions.
102
+ * @type {object}
103
+ * @private
104
+ */
105
+ static _relations = {};
106
+
107
+ /**
108
+ * Database adapter reference — set by Database.register().
109
+ * @type {object|null}
110
+ * @private
111
+ */
112
+ static _adapter = null;
113
+
114
+ // -- Constructor ------------------------------------
115
+
116
+ /**
117
+ * Create a model instance from a data row.
118
+ * Generally you won't call this directly — use static methods.
119
+ *
120
+ * @param {object} data - Row data.
121
+ */
122
+ constructor(data = {})
123
+ {
124
+ /** @type {boolean} Whether this instance exists in the database. */
125
+ this._persisted = false;
126
+
127
+ /** @type {object} The original data snapshot for dirty tracking. */
128
+ this._original = {};
129
+
130
+ // Assign data to instance
131
+ Object.assign(this, data);
132
+ }
133
+
134
+ // -- Instance Methods -------------------------------
135
+
136
+ /**
137
+ * Save this instance to the database. Insert if new, update if persisted.
138
+ * @returns {Promise<Model>} `this`
139
+ */
140
+ async save()
141
+ {
142
+ const ctor = this.constructor;
143
+ if (this._persisted)
144
+ {
145
+ const pk = ctor._primaryKey();
146
+ const changes = this._dirtyFields();
147
+ if (Object.keys(changes).length === 0) return this;
148
+
149
+ if (ctor.timestamps && ctor._fullSchema().updatedAt)
150
+ {
151
+ changes.updatedAt = new Date();
152
+ }
153
+
154
+ await ctor._runHook('beforeUpdate', changes);
155
+ const { valid, errors, sanitized } = validate(changes, ctor._fullSchema(), { partial: true });
156
+ if (!valid) throw new Error('Validation failed: ' + errors.join(', '));
157
+
158
+ try { await ctor._adapter.update(ctor.table, pk, this[pk], sanitized); }
159
+ catch (e) { log.error('%s update failed: %s', ctor.table, e.message); throw e; }
160
+ log.debug('%s update id=%s', ctor.table, this[pk]);
161
+ Object.assign(this, sanitized);
162
+ await ctor._runHook('afterUpdate', this);
163
+ this._snapshot();
164
+ }
165
+ else
166
+ {
167
+ const data = this._toData();
168
+
169
+ if (ctor.timestamps)
170
+ {
171
+ const now = new Date();
172
+ if (ctor._fullSchema().createdAt && !data.createdAt) data.createdAt = now;
173
+ if (ctor._fullSchema().updatedAt && !data.updatedAt) data.updatedAt = now;
174
+ }
175
+
176
+ await ctor._runHook('beforeCreate', data);
177
+ const { valid, errors, sanitized } = validate(data, ctor._fullSchema());
178
+ if (!valid) throw new Error('Validation failed: ' + errors.join(', '));
179
+
180
+ let result;
181
+ try { result = await ctor._adapter.insert(ctor.table, sanitized); }
182
+ catch (e) { log.error('%s insert failed: %s', ctor.table, e.message); throw e; }
183
+ log.debug('%s insert', ctor.table);
184
+ const pk = ctor._primaryKey();
185
+ if (result && result[pk] !== undefined) this[pk] = result[pk];
186
+ Object.assign(this, sanitized);
187
+ this._persisted = true;
188
+ await ctor._runHook('afterCreate', this);
189
+ this._snapshot();
190
+ }
191
+ return this;
192
+ }
193
+
194
+ /**
195
+ * Update specific fields on this instance.
196
+ * @param {object} data - Fields to update.
197
+ * @returns {Promise<Model>} `this`
198
+ */
199
+ async update(data)
200
+ {
201
+ Object.assign(this, data);
202
+ return this.save();
203
+ }
204
+
205
+ /**
206
+ * Delete this instance from the database.
207
+ * If softDelete is enabled, sets deletedAt instead.
208
+ * @returns {Promise<void>}
209
+ */
210
+ async delete()
211
+ {
212
+ const ctor = this.constructor;
213
+ const pk = ctor._primaryKey();
214
+
215
+ await ctor._runHook('beforeDelete', this);
216
+
217
+ if (ctor.softDelete)
218
+ {
219
+ this.deletedAt = new Date();
220
+ try { await ctor._adapter.update(ctor.table, pk, this[pk], { deletedAt: this.deletedAt }); }
221
+ catch (e) { log.error('%s soft-delete failed: %s', ctor.table, e.message); throw e; }
222
+ }
223
+ else
224
+ {
225
+ try { await ctor._adapter.remove(ctor.table, pk, this[pk]); }
226
+ catch (e) { log.error('%s delete failed: %s', ctor.table, e.message); throw e; }
227
+ }
228
+
229
+ log.debug('%s delete id=%s', ctor.table, this[pk]);
230
+
231
+ await ctor._runHook('afterDelete', this);
232
+ this._persisted = false;
233
+ }
234
+
235
+ /**
236
+ * Restore a soft-deleted record.
237
+ * @returns {Promise<Model>} `this`
238
+ */
239
+ async restore()
240
+ {
241
+ const ctor = this.constructor;
242
+ if (!ctor.softDelete) throw new Error('Model does not use soft deletes');
243
+ const pk = ctor._primaryKey();
244
+ this.deletedAt = null;
245
+ try { await ctor._adapter.update(ctor.table, pk, this[pk], { deletedAt: null }); }
246
+ catch (e) { log.error('%s restore failed: %s', ctor.table, e.message); throw e; }
247
+ return this;
248
+ }
249
+
250
+ /**
251
+ * Increment a numeric field atomically.
252
+ *
253
+ * @param {string} field - Column name to increment.
254
+ * @param {number} [by=1] - Amount to increment by.
255
+ * @returns {Promise<Model>} `this`
256
+ *
257
+ * @example
258
+ * await post.increment('views');
259
+ * await product.increment('stock', 10);
260
+ */
261
+ async increment(field, by = 1)
262
+ {
263
+ const ctor = this.constructor;
264
+ const pk = ctor._primaryKey();
265
+ this[field] = (Number(this[field]) || 0) + by;
266
+ const update = { [field]: this[field] };
267
+ if (ctor.timestamps && ctor._fullSchema().updatedAt)
268
+ {
269
+ update.updatedAt = new Date();
270
+ this.updatedAt = update.updatedAt;
271
+ }
272
+ await ctor._adapter.update(ctor.table, pk, this[pk], update);
273
+ log.debug('%s increment %s by %d', ctor.table, field, by);
274
+ this._snapshot();
275
+ return this;
276
+ }
277
+
278
+ /**
279
+ * Decrement a numeric field atomically.
280
+ *
281
+ * @param {string} field - Column name to decrement.
282
+ * @param {number} [by=1] - Amount to decrement by.
283
+ * @returns {Promise<Model>} `this`
284
+ *
285
+ * @example
286
+ * await product.decrement('stock');
287
+ * await account.decrement('balance', 50);
288
+ */
289
+ async decrement(field, by = 1)
290
+ {
291
+ return this.increment(field, -by);
292
+ }
293
+
294
+ /**
295
+ * Reload this instance from the database.
296
+ * @returns {Promise<Model>} `this`
297
+ */
298
+ async reload()
299
+ {
300
+ const ctor = this.constructor;
301
+ const pk = ctor._primaryKey();
302
+ const fresh = await ctor.findById(this[pk]);
303
+ if (!fresh) throw new Error('Record not found');
304
+ Object.assign(this, fresh);
305
+ this._snapshot();
306
+ return this;
307
+ }
308
+
309
+ /**
310
+ * Convert to plain object (for JSON serialization).
311
+ * Respects `static hidden = [...]` to exclude sensitive fields.
312
+ * @returns {object}
313
+ */
314
+ toJSON()
315
+ {
316
+ const data = {};
317
+ const schema = this.constructor._fullSchema();
318
+ const hidden = this.constructor.hidden || [];
319
+ for (const key of Object.keys(schema))
320
+ {
321
+ if (this[key] !== undefined && !hidden.includes(key)) data[key] = this[key];
322
+ }
323
+ return data;
324
+ }
325
+
326
+ // -- Internal Instance Helpers ----------------------
327
+
328
+ /** @private Snapshot current data for dirty tracking. */
329
+ _snapshot()
330
+ {
331
+ this._original = { ...this._toData() };
332
+ }
333
+
334
+ /** @private Get only data columns (exclude internal props). */
335
+ _toData()
336
+ {
337
+ const data = {};
338
+ const schema = this.constructor._fullSchema();
339
+ for (const key of Object.keys(schema))
340
+ {
341
+ if (this[key] !== undefined) data[key] = this[key];
342
+ }
343
+ return data;
344
+ }
345
+
346
+ /** @private Get fields that changed since last snapshot. */
347
+ _dirtyFields()
348
+ {
349
+ const data = this._toData();
350
+ const changes = {};
351
+ for (const [k, v] of Object.entries(data))
352
+ {
353
+ if (v !== this._original[k]) changes[k] = v;
354
+ }
355
+ return changes;
356
+ }
357
+
358
+ // -- Static CRUD ------------------------------------
359
+
360
+ /**
361
+ * Create and persist a new record.
362
+ *
363
+ * @param {object} data - Record data.
364
+ * @returns {Promise<Model>} The created instance.
365
+ */
366
+ static async create(data)
367
+ {
368
+ const instance = new this(data);
369
+ return instance.save();
370
+ }
371
+
372
+ /**
373
+ * Create multiple records at once.
374
+ *
375
+ * @param {object[]} dataArray - Array of record data.
376
+ * @returns {Promise<Model[]>}
377
+ */
378
+ static async createMany(dataArray)
379
+ {
380
+ return Promise.all(dataArray.map(d => this.create(d)));
381
+ }
382
+
383
+ /**
384
+ * Find records matching conditions.
385
+ *
386
+ * @param {object} [conditions={}] - WHERE conditions `{ key: value }`.
387
+ * @returns {Promise<Model[]>}
388
+ */
389
+ static async find(conditions = {})
390
+ {
391
+ const q = this.query().where(conditions);
392
+ return q.exec();
393
+ }
394
+
395
+ /**
396
+ * Find a single record matching conditions.
397
+ *
398
+ * @param {object} conditions - WHERE conditions.
399
+ * @returns {Promise<Model|null>}
400
+ */
401
+ static async findOne(conditions)
402
+ {
403
+ return this.query().where(conditions).first();
404
+ }
405
+
406
+ /**
407
+ * Find a record by primary key.
408
+ *
409
+ * @param {*} id - Primary key value.
410
+ * @returns {Promise<Model|null>}
411
+ */
412
+ static async findById(id)
413
+ {
414
+ const pk = this._primaryKey();
415
+ return this.query().where(pk, id).first();
416
+ }
417
+
418
+ /**
419
+ * Find one or create if not found.
420
+ *
421
+ * @param {object} conditions - Search conditions.
422
+ * @param {object} [defaults={}] - Additional data for creation.
423
+ * @returns {Promise<{ instance: Model, created: boolean }>}
424
+ */
425
+ static async findOrCreate(conditions, defaults = {})
426
+ {
427
+ const existing = await this.findOne(conditions);
428
+ if (existing) return { instance: existing, created: false };
429
+ const instance = await this.create({ ...conditions, ...defaults });
430
+ return { instance, created: true };
431
+ }
432
+
433
+ /**
434
+ * Update records matching conditions.
435
+ *
436
+ * @param {object} conditions - WHERE conditions.
437
+ * @param {object} data - Fields to update.
438
+ * @returns {Promise<number>} Number of updated records.
439
+ */
440
+ static async updateWhere(conditions, data)
441
+ {
442
+ if (this.timestamps && this._fullSchema().updatedAt)
443
+ {
444
+ data.updatedAt = new Date();
445
+ }
446
+ await this._runHook('beforeUpdate', data);
447
+ try { return await this._adapter.updateWhere(this.table, conditions, data); }
448
+ catch (e) { log.error('%s updateWhere failed: %s', this.table, e.message); throw e; }
449
+ }
450
+
451
+ /**
452
+ * Delete records matching conditions.
453
+ *
454
+ * @param {object} conditions - WHERE conditions.
455
+ * @returns {Promise<number>} Number of deleted records.
456
+ */
457
+ static async deleteWhere(conditions)
458
+ {
459
+ if (this.softDelete)
460
+ {
461
+ try { return await this._adapter.updateWhere(this.table, conditions, { deletedAt: new Date() }); }
462
+ catch (e) { log.error('%s deleteWhere (soft) failed: %s', this.table, e.message); throw e; }
463
+ }
464
+ try { return await this._adapter.deleteWhere(this.table, conditions); }
465
+ catch (e) { log.error('%s deleteWhere failed: %s', this.table, e.message); throw e; }
466
+ }
467
+
468
+ /**
469
+ * Count records matching conditions.
470
+ *
471
+ * @param {object} [conditions={}] - WHERE conditions.
472
+ * @returns {Promise<number>}
473
+ */
474
+ static async count(conditions = {})
475
+ {
476
+ return this.query().where(conditions).count();
477
+ }
478
+
479
+ /**
480
+ * Check whether any records matching conditions exist.
481
+ *
482
+ * @param {object} [conditions={}] - WHERE conditions.
483
+ * @returns {Promise<boolean>}
484
+ *
485
+ * @example
486
+ * if (await User.exists({ email: 'a@b.com' })) { ... }
487
+ */
488
+ static async exists(conditions = {})
489
+ {
490
+ return this.query().where(conditions).exists();
491
+ }
492
+
493
+ /**
494
+ * Insert or update a record matching conditions.
495
+ * If a matching record exists, update it. Otherwise, create a new one.
496
+ *
497
+ * @param {object} conditions - Search conditions (unique fields).
498
+ * @param {object} data - Data to set (merged with conditions on create).
499
+ * @returns {Promise<{ instance: Model, created: boolean }>}
500
+ *
501
+ * @example
502
+ * const { instance, created } = await User.upsert(
503
+ * { email: 'a@b.com' },
504
+ * { name: 'Alice', role: 'admin' }
505
+ * );
506
+ */
507
+ static async upsert(conditions, data = {})
508
+ {
509
+ const existing = await this.findOne(conditions);
510
+ if (existing)
511
+ {
512
+ await existing.update(data);
513
+ return { instance: existing, created: false };
514
+ }
515
+ const instance = await this.create({ ...conditions, ...data });
516
+ return { instance, created: true };
517
+ }
518
+
519
+ /**
520
+ * Start a query with a named scope applied.
521
+ *
522
+ * @param {string} name - Scope name (from `static scopes`).
523
+ * @param {...*} [args] - Additional arguments passed to the scope function.
524
+ * @returns {Query}
525
+ *
526
+ * @example
527
+ * await User.scope('active').where('role', 'admin');
528
+ * await User.scope('olderThan', 21).limit(10);
529
+ */
530
+ static scope(name, ...args)
531
+ {
532
+ if (!this.scopes || typeof this.scopes[name] !== 'function')
533
+ {
534
+ throw new Error(`Unknown scope "${name}" on ${this.name}`);
535
+ }
536
+ const q = this.query();
537
+ this.scopes[name](q, ...args);
538
+ return q;
539
+ }
540
+
541
+ /**
542
+ * Start a fluent query builder.
543
+ *
544
+ * @returns {Query}
545
+ *
546
+ * @example
547
+ * const results = await User.query()
548
+ * .where('age', '>', 18)
549
+ * .orderBy('name')
550
+ * .limit(10);
551
+ */
552
+ static query()
553
+ {
554
+ if (!this._adapter) throw new Error(`Model "${this.name}" is not registered with a database`);
555
+ const q = new Query(this, this._adapter);
556
+
557
+ // Auto-exclude soft-deleted records
558
+ if (this.softDelete)
559
+ {
560
+ q.whereNull('deletedAt');
561
+ }
562
+
563
+ return q;
564
+ }
565
+
566
+ // -- LINQ-Inspired Static Shortcuts -----------------
567
+
568
+ /**
569
+ * Find the first record matching optional conditions.
570
+ *
571
+ * @param {object} [conditions={}] - WHERE conditions.
572
+ * @returns {Promise<Model|null>}
573
+ *
574
+ * @example
575
+ * const admin = await User.first({ role: 'admin' });
576
+ * const oldest = await User.first(); // first by PK
577
+ */
578
+ static async first(conditions = {})
579
+ {
580
+ return this.query().where(conditions).first();
581
+ }
582
+
583
+ /**
584
+ * Find the last record matching optional conditions.
585
+ *
586
+ * @param {object} [conditions={}] - WHERE conditions.
587
+ * @returns {Promise<Model|null>}
588
+ *
589
+ * @example
590
+ * const newest = await User.last();
591
+ * const lastAdmin = await User.last({ role: 'admin' });
592
+ */
593
+ static async last(conditions = {})
594
+ {
595
+ return this.query().where(conditions).last();
596
+ }
597
+
598
+ /**
599
+ * Rich pagination with metadata.
600
+ * Returns `{ data, total, page, perPage, pages, hasNext, hasPrev }`.
601
+ *
602
+ * @param {number} page - 1-indexed page number.
603
+ * @param {number} [perPage=20] - Items per page.
604
+ * @param {object} [conditions={}] - Optional WHERE conditions.
605
+ * @returns {Promise<object>}
606
+ *
607
+ * @example
608
+ * const result = await User.paginate(2, 10, { role: 'admin' });
609
+ * // { data: [...], total: 53, page: 2, perPage: 10,
610
+ * // pages: 6, hasNext: true, hasPrev: true }
611
+ */
612
+ static async paginate(page, perPage = 20, conditions = {})
613
+ {
614
+ return this.query().where(conditions).paginate(page, perPage);
615
+ }
616
+
617
+ /**
618
+ * Process all matching records in batches.
619
+ * Calls `fn(batch, batchIndex)` for each chunk.
620
+ *
621
+ * @param {number} size - Batch size.
622
+ * @param {Function} fn - Called with (batch: Model[], index: number).
623
+ * @param {object} [conditions={}] - Optional WHERE conditions.
624
+ * @returns {Promise<void>}
625
+ *
626
+ * @example
627
+ * await User.chunk(100, async (users, i) => {
628
+ * for (const u of users) await u.update({ migrated: true });
629
+ * }, { active: true });
630
+ */
631
+ static async chunk(size, fn, conditions = {})
632
+ {
633
+ return this.query().where(conditions).chunk(size, fn);
634
+ }
635
+
636
+ /**
637
+ * Get all records, optionally filtered.
638
+ * Alias for find() — for LINQ-familiarity.
639
+ *
640
+ * @param {object} [conditions={}] - WHERE conditions.
641
+ * @returns {Promise<Model[]>}
642
+ */
643
+ static async all(conditions = {})
644
+ {
645
+ return this.find(conditions);
646
+ }
647
+
648
+ /**
649
+ * Get a random record.
650
+ *
651
+ * @param {object} [conditions={}] - Optional WHERE conditions.
652
+ * @returns {Promise<Model|null>}
653
+ *
654
+ * @example
655
+ * const luckyUser = await User.random();
656
+ * const randomAdmin = await User.random({ role: 'admin' });
657
+ */
658
+ static async random(conditions = {})
659
+ {
660
+ const total = await this.count(conditions);
661
+ if (total === 0) return null;
662
+ const idx = Math.floor(Math.random() * total);
663
+ return this.query().where(conditions).offset(idx).first();
664
+ }
665
+
666
+ /**
667
+ * Pluck values for a single column across all matching records.
668
+ *
669
+ * @param {string} field - Column name to extract.
670
+ * @param {object} [conditions={}] - Optional WHERE conditions.
671
+ * @returns {Promise<Array>}
672
+ *
673
+ * @example
674
+ * const emails = await User.pluck('email');
675
+ * const adminNames = await User.pluck('name', { role: 'admin' });
676
+ */
677
+ static async pluck(field, conditions = {})
678
+ {
679
+ return this.query().where(conditions).pluck(field);
680
+ }
681
+
682
+ // -- Relationships ----------------------------------
683
+
684
+ /**
685
+ * Define a hasMany relationship.
686
+ * @param {Function} RelatedModel - The related Model class.
687
+ * @param {string} foreignKey - Foreign key column on the related table.
688
+ * @param {string} [localKey] - Local key (default: primary key).
689
+ */
690
+ static hasMany(RelatedModel, foreignKey, localKey)
691
+ {
692
+ const pk = localKey || this._primaryKey();
693
+ if (!this._relations) this._relations = {};
694
+ this._relations[RelatedModel.name] = { type: 'hasMany', model: RelatedModel, foreignKey, localKey: pk };
695
+ }
696
+
697
+ /**
698
+ * Define a hasOne relationship.
699
+ * @param {Function} RelatedModel
700
+ * @param {string} foreignKey
701
+ * @param {string} [localKey]
702
+ */
703
+ static hasOne(RelatedModel, foreignKey, localKey)
704
+ {
705
+ const pk = localKey || this._primaryKey();
706
+ if (!this._relations) this._relations = {};
707
+ this._relations[RelatedModel.name] = { type: 'hasOne', model: RelatedModel, foreignKey, localKey: pk };
708
+ }
709
+
710
+ /**
711
+ * Define a belongsTo relationship.
712
+ * @param {Function} RelatedModel
713
+ * @param {string} foreignKey - Foreign key column on THIS table.
714
+ * @param {string} [otherKey] - Key on the related table (default: its primary key).
715
+ */
716
+ static belongsTo(RelatedModel, foreignKey, otherKey)
717
+ {
718
+ const ok = otherKey || RelatedModel._primaryKey();
719
+ if (!this._relations) this._relations = {};
720
+ this._relations[RelatedModel.name] = { type: 'belongsTo', model: RelatedModel, foreignKey, localKey: ok };
721
+ }
722
+
723
+ /**
724
+ * Define a many-to-many relationship through a junction/pivot table.
725
+ *
726
+ * @param {Function} RelatedModel - The related Model class.
727
+ * @param {object} opts - Relationship options.
728
+ * @param {string} opts.through - Junction table name (e.g. 'user_roles').
729
+ * @param {string} opts.foreignKey - Column on the junction table referencing THIS model.
730
+ * @param {string} opts.otherKey - Column on the junction table referencing the related model.
731
+ * @param {string} [opts.localKey] - Local key (default: primary key).
732
+ * @param {string} [opts.relatedKey] - Related model key (default: its primary key).
733
+ *
734
+ * @example
735
+ * User.belongsToMany(Role, {
736
+ * through: 'user_roles',
737
+ * foreignKey: 'userId',
738
+ * otherKey: 'roleId'
739
+ * });
740
+ * const roles = await user.load('Role'); // returns Role[]
741
+ */
742
+ static belongsToMany(RelatedModel, opts = {})
743
+ {
744
+ if (!opts.through || !opts.foreignKey || !opts.otherKey)
745
+ {
746
+ throw new Error('belongsToMany requires through, foreignKey, and otherKey');
747
+ }
748
+ const pk = opts.localKey || this._primaryKey();
749
+ const rpk = opts.relatedKey || RelatedModel._primaryKey();
750
+ if (!this._relations) this._relations = {};
751
+ this._relations[RelatedModel.name] = {
752
+ type: 'belongsToMany',
753
+ model: RelatedModel,
754
+ through: opts.through,
755
+ foreignKey: opts.foreignKey,
756
+ otherKey: opts.otherKey,
757
+ localKey: pk,
758
+ relatedKey: rpk,
759
+ };
760
+ }
761
+
762
+ /**
763
+ * Load a related model for this instance.
764
+ *
765
+ * @param {string} relationName - Name of the related Model class.
766
+ * @returns {Promise<Model|Model[]|null>}
767
+ */
768
+ async load(relationName)
769
+ {
770
+ const ctor = this.constructor;
771
+ const rel = ctor._relations && ctor._relations[relationName];
772
+ if (!rel) throw new Error(`Unknown relation "${relationName}" on ${ctor.name}`);
773
+
774
+ switch (rel.type)
775
+ {
776
+ case 'hasMany':
777
+ return rel.model.find({ [rel.foreignKey]: this[rel.localKey] });
778
+ case 'hasOne':
779
+ return rel.model.findOne({ [rel.foreignKey]: this[rel.localKey] });
780
+ case 'belongsTo':
781
+ return rel.model.findOne({ [rel.localKey]: this[rel.foreignKey] });
782
+ case 'belongsToMany':
783
+ {
784
+ // Query the junction table to find related IDs
785
+ const junctionRows = await ctor._adapter.execute({
786
+ action: 'select',
787
+ table: rel.through,
788
+ fields: [rel.otherKey],
789
+ where: [{ field: rel.foreignKey, op: '=', value: this[rel.localKey], logic: 'AND' }],
790
+ orderBy: [], joins: [], groupBy: [], having: [],
791
+ limit: null, offset: null, distinct: false,
792
+ });
793
+ if (!junctionRows.length) return [];
794
+ const relatedIds = junctionRows.map(r => r[rel.otherKey]);
795
+ return rel.model.query().whereIn(rel.relatedKey, relatedIds).exec();
796
+ }
797
+ default:
798
+ throw new Error(`Unknown relation type "${rel.type}"`);
799
+ }
800
+ }
801
+
802
+ // -- Internal Static Helpers ------------------------
803
+
804
+ /**
805
+ * Get the full schema including auto-fields.
806
+ * @returns {object}
807
+ * @private
808
+ */
809
+ static _fullSchema()
810
+ {
811
+ const s = { ...this.schema };
812
+ if (this.timestamps)
813
+ {
814
+ if (!s.createdAt) s.createdAt = { type: 'datetime', default: () => new Date() };
815
+ if (!s.updatedAt) s.updatedAt = { type: 'datetime', default: () => new Date() };
816
+ }
817
+ if (this.softDelete)
818
+ {
819
+ if (!s.deletedAt) s.deletedAt = { type: 'datetime', nullable: true };
820
+ }
821
+ return s;
822
+ }
823
+
824
+ /**
825
+ * Get the primary key column name.
826
+ * @returns {string}
827
+ * @private
828
+ */
829
+ static _primaryKey()
830
+ {
831
+ for (const [name, def] of Object.entries(this.schema))
832
+ {
833
+ if (def.primaryKey) return name;
834
+ }
835
+ return 'id'; // convention
836
+ }
837
+
838
+ /**
839
+ * Create a model instance from a raw database row.
840
+ * @param {object} row
841
+ * @returns {Model}
842
+ * @private
843
+ */
844
+ static _fromRow(row)
845
+ {
846
+ const instance = new this(row);
847
+ instance._persisted = true;
848
+ instance._snapshot();
849
+ return instance;
850
+ }
851
+
852
+ /**
853
+ * Run a lifecycle hook if defined.
854
+ * @param {string} hookName
855
+ * @param {*} data
856
+ * @returns {Promise<*>}
857
+ * @private
858
+ */
859
+ static async _runHook(hookName, data)
860
+ {
861
+ // Check for static hook on class
862
+ if (typeof this[hookName] === 'function')
863
+ {
864
+ return this[hookName](data);
865
+ }
866
+ // Check hooks object
867
+ if (this.hooks && typeof this.hooks[hookName] === 'function')
868
+ {
869
+ return this.hooks[hookName](data);
870
+ }
871
+ return data;
872
+ }
873
+
874
+ /**
875
+ * Sync the table schema with the database (create table if not exists).
876
+ * @returns {Promise<void>}
877
+ */
878
+ static async sync()
879
+ {
880
+ if (!this._adapter) throw new Error(`Model "${this.name}" is not registered with a database`);
881
+ return this._adapter.createTable(this.table, this._fullSchema());
882
+ }
883
+
884
+ /**
885
+ * Drop the table.
886
+ * @returns {Promise<void>}
887
+ */
888
+ static async drop()
889
+ {
890
+ if (!this._adapter) throw new Error(`Model "${this.name}" is not registered with a database`);
891
+ return this._adapter.dropTable(this.table);
892
+ }
893
+ }
894
+
895
+ module.exports = Model;