@zero-server/orm 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 (41) hide show
  1. package/LICENSE +21 -21
  2. package/index.js +35 -35
  3. package/lib/debug.js +372 -0
  4. package/lib/orm/adapters/json.js +290 -0
  5. package/lib/orm/adapters/memory.js +764 -0
  6. package/lib/orm/adapters/mongo.js +764 -0
  7. package/lib/orm/adapters/mysql.js +933 -0
  8. package/lib/orm/adapters/postgres.js +1144 -0
  9. package/lib/orm/adapters/redis.js +1534 -0
  10. package/lib/orm/adapters/sql-base.js +212 -0
  11. package/lib/orm/adapters/sqlite.js +858 -0
  12. package/lib/orm/audit.js +649 -0
  13. package/lib/orm/cache.js +394 -0
  14. package/lib/orm/geo.js +387 -0
  15. package/lib/orm/index.js +784 -0
  16. package/lib/orm/migrate.js +432 -0
  17. package/lib/orm/model.js +1706 -0
  18. package/lib/orm/plugin.js +375 -0
  19. package/lib/orm/procedures.js +836 -0
  20. package/lib/orm/profiler.js +233 -0
  21. package/lib/orm/query.js +1772 -0
  22. package/lib/orm/replicas.js +241 -0
  23. package/lib/orm/schema.js +307 -0
  24. package/lib/orm/search.js +380 -0
  25. package/lib/orm/seed/data/commerce.js +136 -0
  26. package/lib/orm/seed/data/internet.js +111 -0
  27. package/lib/orm/seed/data/locations.js +204 -0
  28. package/lib/orm/seed/data/names.js +338 -0
  29. package/lib/orm/seed/data/person.js +128 -0
  30. package/lib/orm/seed/data/phone.js +211 -0
  31. package/lib/orm/seed/data/words.js +134 -0
  32. package/lib/orm/seed/factory.js +178 -0
  33. package/lib/orm/seed/fake.js +1186 -0
  34. package/lib/orm/seed/index.js +18 -0
  35. package/lib/orm/seed/rng.js +71 -0
  36. package/lib/orm/seed/seeder.js +125 -0
  37. package/lib/orm/seed/unique.js +68 -0
  38. package/lib/orm/snapshot.js +366 -0
  39. package/lib/orm/tenancy.js +605 -0
  40. package/lib/orm/views.js +350 -0
  41. package/package.json +11 -2
@@ -0,0 +1,784 @@
1
+ /**
2
+ * @module orm
3
+ * @description ORM entry point. Provides the `Database` factory that creates
4
+ * a connection to a backing store, the base `Model` class, the
5
+ * `TYPES` enum, and schema helpers.
6
+ *
7
+ * Supported adapters (all optional "bring your own driver"):
8
+ * - `memory` — in-process (no driver needed)
9
+ * - `json` — JSON file persistence (no driver needed)
10
+ * - `sqlite` — requires `better-sqlite3`
11
+ * - `mysql` — requires `mysql2`
12
+ * - `postgres` — requires `pg`
13
+ * - `mongo` — requires `mongodb`
14
+ *
15
+ * @example
16
+ * const { Database, Model, TYPES } = require('@zero-server/sdk');
17
+ *
18
+ * const db = Database.connect('memory');
19
+ *
20
+ * class User extends Model {
21
+ * static table = 'users';
22
+ * static schema = {
23
+ * id: { type: TYPES.INTEGER, primaryKey: true, autoIncrement: true },
24
+ * name: { type: TYPES.STRING, required: true },
25
+ * email: { type: TYPES.STRING, required: true, unique: true },
26
+ * };
27
+ * static timestamps = true;
28
+ * }
29
+ *
30
+ * db.register(User);
31
+ * await db.sync();
32
+ *
33
+ * const user = await User.create({ name: 'Alice', email: 'a@b.com' });
34
+ */
35
+ const Model = require('./model');
36
+ const { TYPES, validate, validateValue, validateFKAction, validateCheck } = require('./schema');
37
+ const Query = require('./query');
38
+
39
+ // -- Adapter loaders (lazy) ------------------------------
40
+
41
+ const ADAPTERS = {
42
+ memory: () => require('./adapters/memory'),
43
+ json: () => require('./adapters/json'),
44
+ sqlite: () => require('./adapters/sqlite'),
45
+ mysql: () => require('./adapters/mysql'),
46
+ postgres: () => require('./adapters/postgres'),
47
+ mongo: () => require('./adapters/mongo'),
48
+ redis: () => require('./adapters/redis'),
49
+ };
50
+
51
+ // -- Database class --------------------------------------
52
+
53
+ /**
54
+ * Validate adapter connection options and sanitize credentials.
55
+ * @private
56
+ * @param {string} type - Adapter type identifier.
57
+ * @param {object} options - Configuration options.
58
+ * @returns {object} sanitised options
59
+ */
60
+ function _validateOptions(type, options)
61
+ {
62
+ const opts = { ...options };
63
+
64
+ // Adapters that take network credentials
65
+ if (type === 'mysql' || type === 'postgres')
66
+ {
67
+ if (opts.host !== undefined)
68
+ {
69
+ if (typeof opts.host !== 'string' || !opts.host.trim())
70
+ throw new Error(`${type}: "host" must be a non-empty string`);
71
+ opts.host = opts.host.trim();
72
+ }
73
+ if (opts.port !== undefined)
74
+ {
75
+ opts.port = Number(opts.port);
76
+ if (!Number.isInteger(opts.port) || opts.port < 1 || opts.port > 65535)
77
+ throw new Error(`${type}: "port" must be an integer 1-65535`);
78
+ }
79
+ if (opts.user !== undefined)
80
+ {
81
+ if (typeof opts.user !== 'string')
82
+ throw new Error(`${type}: "user" must be a string`);
83
+ }
84
+ if (opts.password !== undefined)
85
+ {
86
+ if (typeof opts.password !== 'string')
87
+ throw new Error(`${type}: "password" must be a string`);
88
+ }
89
+ if (opts.database !== undefined)
90
+ {
91
+ if (typeof opts.database !== 'string' || !opts.database.trim())
92
+ throw new Error(`${type}: "database" must be a non-empty string`);
93
+ opts.database = opts.database.trim();
94
+ }
95
+ }
96
+
97
+ // Mongo connection string
98
+ if (type === 'mongo')
99
+ {
100
+ if (opts.url !== undefined)
101
+ {
102
+ if (typeof opts.url !== 'string' || !opts.url.trim())
103
+ throw new Error('mongo: "url" must be a non-empty string');
104
+ opts.url = opts.url.trim();
105
+ }
106
+ if (opts.database !== undefined)
107
+ {
108
+ if (typeof opts.database !== 'string' || !opts.database.trim())
109
+ throw new Error('mongo: "database" must be a non-empty string');
110
+ opts.database = opts.database.trim();
111
+ }
112
+ }
113
+
114
+ // SQLite validation
115
+ if (type === 'sqlite')
116
+ {
117
+ if (opts.filename !== undefined && typeof opts.filename !== 'string')
118
+ throw new Error('sqlite: "filename" must be a string');
119
+ }
120
+
121
+ // Redis validation
122
+ if (type === 'redis')
123
+ {
124
+ if (opts.url !== undefined)
125
+ {
126
+ if (typeof opts.url !== 'string' || !opts.url.trim())
127
+ throw new Error('redis: "url" must be a non-empty string');
128
+ opts.url = opts.url.trim();
129
+ }
130
+ if (opts.host !== undefined)
131
+ {
132
+ if (typeof opts.host !== 'string' || !opts.host.trim())
133
+ throw new Error('redis: "host" must be a non-empty string');
134
+ opts.host = opts.host.trim();
135
+ }
136
+ if (opts.port !== undefined)
137
+ {
138
+ opts.port = Number(opts.port);
139
+ if (!Number.isInteger(opts.port) || opts.port < 1 || opts.port > 65535)
140
+ throw new Error('redis: "port" must be an integer 1-65535');
141
+ }
142
+ if (opts.password !== undefined)
143
+ {
144
+ if (typeof opts.password !== 'string')
145
+ throw new Error('redis: "password" must be a string');
146
+ }
147
+ if (opts.db !== undefined)
148
+ {
149
+ opts.db = Number(opts.db);
150
+ if (!Number.isInteger(opts.db) || opts.db < 0)
151
+ throw new Error('redis: "db" must be a non-negative integer');
152
+ }
153
+ }
154
+
155
+ return opts;
156
+ }
157
+
158
+ class Database
159
+ {
160
+ /**
161
+ * @constructor
162
+ * @param {object} adapter - Instantiated adapter.
163
+ */
164
+ constructor(adapter)
165
+ {
166
+ /** @type {object} The underlying adapter instance. */
167
+ this.adapter = adapter;
168
+
169
+ /** @type {Map<string, typeof Model>} Registered model classes. */
170
+ this._models = new Map();
171
+ }
172
+
173
+ /**
174
+ * Create a Database connection.
175
+ *
176
+ * @param {string} type - Adapter type: memory, json, sqlite, mysql, postgres, mongo.
177
+ * @param {object} [options] - Adapter-specific connection options.
178
+ * @param {string} [options.host] - Database host (mysql, postgres).
179
+ * @param {number} [options.port] - Database port (mysql, postgres).
180
+ * @param {string} [options.user] - Database user (mysql, postgres).
181
+ * @param {string} [options.password] - Database password (mysql, postgres).
182
+ * @param {string} [options.database] - Database name (mysql, postgres, mongo).
183
+ * @param {string} [options.url] - Connection URL (mongo, redis).
184
+ * @param {string} [options.filename] - Database file path (sqlite).
185
+ * @param {string} [options.directory] - Storage directory (json).
186
+ * @returns {Database} Connected database instance.
187
+ *
188
+ * @example
189
+ * const db = Database.connect('sqlite', { filename: './app.db' });
190
+ * const db = Database.connect('mysql', { host: '127.0.0.1', user: 'root', database: 'app' });
191
+ * const db = Database.connect('postgres', { host: '127.0.0.1', user: 'postgres', database: 'app' });
192
+ * const db = Database.connect('mongo', { url: 'mongodb://localhost:27017', database: 'app' });
193
+ * const db = Database.connect('json', { directory: './data' });
194
+ * const db = Database.connect('memory');
195
+ */
196
+ static connect(type, options = {})
197
+ {
198
+ const loader = ADAPTERS[type];
199
+ if (!loader) throw new Error(`Unknown adapter "${type}". Supported: ${Object.keys(ADAPTERS).join(', ')}`);
200
+
201
+ const opts = _validateOptions(type, options);
202
+ const AdapterClass = loader();
203
+ const adapter = new AdapterClass(opts);
204
+ return new Database(adapter);
205
+ }
206
+
207
+ /**
208
+ * Register a Model class with this database.
209
+ * Binds the adapter to the model so all CRUD operations go through it.
210
+ *
211
+ * @param {typeof Model} ModelClass - Model class.
212
+ * @returns {Database} this (for chaining)
213
+ */
214
+ register(ModelClass)
215
+ {
216
+ ModelClass._adapter = this.adapter;
217
+ this._models.set(ModelClass.table || ModelClass.name, ModelClass);
218
+ return this;
219
+ }
220
+
221
+ /**
222
+ * Register multiple Model classes at once.
223
+ *
224
+ * @param {...typeof Model} models - Array of Model classes.
225
+ * @returns {Database} this (for chaining)
226
+ */
227
+ registerAll(...models)
228
+ {
229
+ for (const m of models) this.register(m);
230
+ return this;
231
+ }
232
+
233
+ /**
234
+ * Synchronise all registered models — create tables if they don't exist.
235
+ * Tables are ordered so referenced tables are created first (topological sort).
236
+ * @returns {Promise<void>}
237
+ */
238
+ async sync()
239
+ {
240
+ const models = [...this._models.values()];
241
+
242
+ // Build dependency graph from schema references
243
+ const tableMap = new Map();
244
+ for (const M of models) tableMap.set(M.table || M.name, M);
245
+
246
+ const ordered = this._topoSort(models);
247
+ for (const ModelClass of ordered)
248
+ {
249
+ await ModelClass.sync();
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Topological sort of models by FK dependencies.
255
+ * Models that reference other models come after them.
256
+ * @param {Array<typeof Model>} models - Array of Model classes.
257
+ * @returns {Array<typeof Model>} Models ordered by foreign key dependencies.
258
+ * @private
259
+ */
260
+ _topoSort(models)
261
+ {
262
+ const nameToModel = new Map();
263
+ for (const M of models) nameToModel.set(M.table || M.name, M);
264
+
265
+ const deps = new Map();
266
+ for (const M of models)
267
+ {
268
+ const tableName = M.table || M.name;
269
+ const references = new Set();
270
+ if (M.schema)
271
+ {
272
+ for (const def of Object.values(M.schema))
273
+ {
274
+ if (def.references && def.references.table && nameToModel.has(def.references.table))
275
+ {
276
+ references.add(def.references.table);
277
+ }
278
+ }
279
+ }
280
+ deps.set(tableName, references);
281
+ }
282
+
283
+ const sorted = [];
284
+ const visited = new Set();
285
+ const visiting = new Set();
286
+
287
+ const visit = (tableName) =>
288
+ {
289
+ if (visited.has(tableName)) return;
290
+ if (visiting.has(tableName)) return; // circular ref, break cycle
291
+ visiting.add(tableName);
292
+ const references = deps.get(tableName) || new Set();
293
+ for (const ref of references) visit(ref);
294
+ visiting.delete(tableName);
295
+ visited.add(tableName);
296
+ sorted.push(nameToModel.get(tableName));
297
+ };
298
+
299
+ for (const M of models) visit(M.table || M.name);
300
+ return sorted;
301
+ }
302
+
303
+ /**
304
+ * Drop all registered model tables (in reverse order to respect FK deps).
305
+ * @returns {Promise<void>}
306
+ */
307
+ async drop()
308
+ {
309
+ const models = [...this._models.values()].reverse();
310
+ for (const ModelClass of models)
311
+ {
312
+ await ModelClass.drop();
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Close the underlying connection / pool.
318
+ * @returns {Promise<void>}
319
+ */
320
+ async close()
321
+ {
322
+ if (typeof this.adapter.close === 'function')
323
+ {
324
+ await this.adapter.close();
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Get a registered model by table name.
330
+ * @param {string} name - Name identifier.
331
+ * @returns {typeof Model|undefined} The registered Model class, or undefined.
332
+ */
333
+ model(name)
334
+ {
335
+ return this._models.get(name);
336
+ }
337
+
338
+ /**
339
+ * Execute a callback within a database transaction.
340
+ * If the callback throws, the transaction is rolled back.
341
+ * If the callback returns normally, the transaction is committed.
342
+ *
343
+ * Note: Transaction support depends on the adapter.
344
+ * Memory and JSON adapters run the callback directly (no real transaction).
345
+ *
346
+ * @param {Function} fn - Async callback to execute within the transaction.
347
+ * @returns {Promise<*>} The return value of the callback.
348
+ *
349
+ * @example
350
+ * await db.transaction(async () => {
351
+ * await User.create({ name: 'Alice', email: 'a@b.com' });
352
+ * await Account.create({ userId: 1, balance: 100 });
353
+ * });
354
+ */
355
+ async transaction(fn)
356
+ {
357
+ if (typeof this.adapter.beginTransaction === 'function')
358
+ {
359
+ await this.adapter.beginTransaction();
360
+ try
361
+ {
362
+ const result = await fn();
363
+ await this.adapter.commit();
364
+ return result;
365
+ }
366
+ catch (err)
367
+ {
368
+ await this.adapter.rollback();
369
+ throw err;
370
+ }
371
+ }
372
+ // Adapters without transaction support run directly
373
+ return fn();
374
+ }
375
+
376
+ // -- Migration / DDL Convenience ---------------------
377
+
378
+ /**
379
+ * Add a column to an existing table.
380
+ * @param {string} table - Table name.
381
+ * @param {string} column - Column name.
382
+ * @param {object} definition - Column definition.
383
+ * @returns {Promise<void>}
384
+ */
385
+ async addColumn(table, column, definition)
386
+ {
387
+ if (typeof this.adapter.addColumn !== 'function')
388
+ throw new Error(`Adapter does not support addColumn`);
389
+ return this.adapter.addColumn(table, column, definition);
390
+ }
391
+
392
+ /**
393
+ * Drop a column from a table.
394
+ * @param {string} table - Table name.
395
+ * @param {string} column - Column name.
396
+ * @returns {Promise<void>}
397
+ */
398
+ async dropColumn(table, column)
399
+ {
400
+ if (typeof this.adapter.dropColumn !== 'function')
401
+ throw new Error(`Adapter does not support dropColumn`);
402
+ return this.adapter.dropColumn(table, column);
403
+ }
404
+
405
+ /**
406
+ * Rename a column.
407
+ * @param {string} table - Table name.
408
+ * @param {string} oldName - Current name.
409
+ * @param {string} newName - New name.
410
+ * @returns {Promise<void>}
411
+ */
412
+ async renameColumn(table, oldName, newName)
413
+ {
414
+ if (typeof this.adapter.renameColumn !== 'function')
415
+ throw new Error(`Adapter does not support renameColumn`);
416
+ return this.adapter.renameColumn(table, oldName, newName);
417
+ }
418
+
419
+ /**
420
+ * Rename a table.
421
+ * @param {string} oldName - Current name.
422
+ * @param {string} newName - New name.
423
+ * @returns {Promise<void>}
424
+ */
425
+ async renameTable(oldName, newName)
426
+ {
427
+ if (typeof this.adapter.renameTable !== 'function')
428
+ throw new Error(`Adapter does not support renameTable`);
429
+ return this.adapter.renameTable(oldName, newName);
430
+ }
431
+
432
+ /**
433
+ * Create an index on a table.
434
+ * @param {string} table - Table name.
435
+ * @param {string|string[]} columns - Column name(s).
436
+ * @param {object} [options={}] - Index configuration.
437
+ * @param {string} [options.name] - Custom index name.
438
+ * @param {boolean} [options.unique] - Create a unique index.
439
+ * @returns {Promise<void>}
440
+ */
441
+ async createIndex(table, columns, options = {})
442
+ {
443
+ if (typeof this.adapter.createIndex !== 'function')
444
+ throw new Error(`Adapter does not support createIndex`);
445
+ return this.adapter.createIndex(table, columns, options);
446
+ }
447
+
448
+ /**
449
+ * Drop an index.
450
+ * @param {string} table - Table name.
451
+ * @param {string} name - Index name.
452
+ * @returns {Promise<void>}
453
+ */
454
+ async dropIndex(table, name)
455
+ {
456
+ if (typeof this.adapter.dropIndex !== 'function')
457
+ throw new Error(`Adapter does not support dropIndex`);
458
+ return this.adapter.dropIndex(table, name);
459
+ }
460
+
461
+ /**
462
+ * Check if a table exists.
463
+ * @param {string} table - Table name.
464
+ * @returns {Promise<boolean>} True if the table exists.
465
+ */
466
+ async hasTable(table)
467
+ {
468
+ if (typeof this.adapter.hasTable !== 'function')
469
+ throw new Error(`Adapter does not support hasTable`);
470
+ return this.adapter.hasTable(table);
471
+ }
472
+
473
+ /**
474
+ * Check if a column exists on a table.
475
+ * @param {string} table - Table name.
476
+ * @param {string} column - Column name.
477
+ * @returns {Promise<boolean>} True if the column exists.
478
+ */
479
+ async hasColumn(table, column)
480
+ {
481
+ if (typeof this.adapter.hasColumn !== 'function')
482
+ throw new Error(`Adapter does not support hasColumn`);
483
+ return this.adapter.hasColumn(table, column);
484
+ }
485
+
486
+ /**
487
+ * Get detailed column info for a table.
488
+ * @param {string} table - Table name.
489
+ * @returns {Promise<Array>} Column definition objects for the table.
490
+ */
491
+ async describeTable(table)
492
+ {
493
+ if (typeof this.adapter.describeTable !== 'function')
494
+ throw new Error(`Adapter does not support describeTable`);
495
+ return this.adapter.describeTable(table);
496
+ }
497
+
498
+ /**
499
+ * Add a foreign key constraint (MySQL / PostgreSQL only).
500
+ * @param {string} table - Table name.
501
+ * @param {string} column - Column name.
502
+ * @param {string} refTable - Referenced table name.
503
+ * @param {string} refColumn - Referenced column name.
504
+ * @param {object} [options={}] - Foreign key constraint options.
505
+ * @param {string} [options.onDelete] - Referential action on delete (CASCADE, SET NULL, RESTRICT, etc.).
506
+ * @param {string} [options.onUpdate] - Referential action on update (CASCADE, SET NULL, RESTRICT, etc.).
507
+ * @param {string} [options.name] - Custom constraint name.
508
+ * @returns {Promise<void>}
509
+ */
510
+ async addForeignKey(table, column, refTable, refColumn, options = {})
511
+ {
512
+ if (typeof this.adapter.addForeignKey !== 'function')
513
+ throw new Error(`Adapter does not support addForeignKey`);
514
+ return this.adapter.addForeignKey(table, column, refTable, refColumn, options);
515
+ }
516
+
517
+ /**
518
+ * Drop a foreign key constraint (MySQL / PostgreSQL only).
519
+ * @param {string} table - Table name.
520
+ * @param {string} constraintName - Constraint name.
521
+ * @returns {Promise<void>}
522
+ */
523
+ async dropForeignKey(table, constraintName)
524
+ {
525
+ if (typeof this.adapter.dropForeignKey !== 'function')
526
+ throw new Error(`Adapter does not support dropForeignKey`);
527
+ return this.adapter.dropForeignKey(table, constraintName);
528
+ }
529
+
530
+ // -- Health Check & Retry ----------------------------
531
+
532
+ /**
533
+ * Ping the database to check connectivity.
534
+ * Works across all adapters.
535
+ *
536
+ * @returns {Promise<boolean>} True if healthy.
537
+ *
538
+ * @example
539
+ * const healthy = await db.ping();
540
+ * if (!healthy) console.error('Database is unreachable');
541
+ */
542
+ async ping()
543
+ {
544
+ try
545
+ {
546
+ // Adapter-specific ping
547
+ if (typeof this.adapter.ping === 'function')
548
+ {
549
+ return await this.adapter.ping();
550
+ }
551
+
552
+ // Memory/JSON adapters are always healthy
553
+ if (typeof this.adapter._tables !== 'undefined' ||
554
+ typeof this.adapter._getTable === 'function')
555
+ {
556
+ return true;
557
+ }
558
+
559
+ // Fallback: try a trivial query
560
+ if (typeof this.adapter.execute === 'function')
561
+ {
562
+ await this.adapter.execute({ action: 'count', table: '_ping_test', where: [] });
563
+ return true;
564
+ }
565
+
566
+ return true;
567
+ }
568
+ catch (_)
569
+ {
570
+ return false;
571
+ }
572
+ }
573
+
574
+ /**
575
+ * Execute a function with automatic retry on failure.
576
+ * Uses exponential backoff with jitter.
577
+ *
578
+ * @param {Function} fn - Async function to execute.
579
+ * @param {object} [options] - Configuration options.
580
+ * @param {number} [options.retries=3] - Maximum retry attempts.
581
+ * @param {number} [options.delay=100] - Initial delay in ms.
582
+ * @param {number} [options.maxDelay=5000] - Maximum delay in ms.
583
+ * @param {number} [options.factor=2] - Backoff multiplier.
584
+ * @param {Function} [options.onRetry] - Callback on each retry: (err, attempt) => {}.
585
+ * @returns {Promise<*>} Result of fn().
586
+ *
587
+ * @example
588
+ * const users = await db.retry(async () => {
589
+ * return User.find({ active: true });
590
+ * }, { retries: 5, delay: 200 });
591
+ */
592
+ async retry(fn, options = {})
593
+ {
594
+ const {
595
+ retries = 3,
596
+ delay = 100,
597
+ maxDelay = 5000,
598
+ factor = 2,
599
+ onRetry,
600
+ } = options;
601
+
602
+ let lastError;
603
+ for (let attempt = 0; attempt <= retries; attempt++)
604
+ {
605
+ try
606
+ {
607
+ return await fn();
608
+ }
609
+ catch (err)
610
+ {
611
+ lastError = err;
612
+ if (attempt >= retries) break;
613
+
614
+ const wait = Math.min(delay * Math.pow(factor, attempt), maxDelay);
615
+ // Add jitter (±25%)
616
+ const jitter = wait * (0.75 + Math.random() * 0.5);
617
+
618
+ if (typeof onRetry === 'function')
619
+ {
620
+ onRetry(err, attempt + 1);
621
+ }
622
+
623
+ await new Promise(resolve => setTimeout(resolve, jitter));
624
+ }
625
+ }
626
+ throw lastError;
627
+ }
628
+
629
+ // -- Profiling ---------------------------------------
630
+
631
+ /**
632
+ * Enable query profiling on this database.
633
+ * Attaches a QueryProfiler that tracks every query execution, detects
634
+ * slow queries, and flags potential N+1 patterns.
635
+ *
636
+ * @param {object} [options] - Configuration options.
637
+ * @param {number} [options.slowThreshold=100] - Duration (ms) above which a query is considered slow.
638
+ * @param {number} [options.maxHistory=1000] - Maximum recorded query entries.
639
+ * @param {Function} [options.onSlow] - Callback on slow query: (entry) => {}.
640
+ * @param {number} [options.n1Threshold=5] - Minimum rapid SELECTs to flag N+1.
641
+ * @param {number} [options.n1Window=100] - Time window (ms) for N+1 detection.
642
+ * @param {Function} [options.onN1] - Callback on N+1 detection.
643
+ * @returns {QueryProfiler} The attached profiler instance.
644
+ *
645
+ * @example
646
+ * const profiler = db.enableProfiling({ slowThreshold: 50 });
647
+ * // ... run queries ...
648
+ * console.log(profiler.metrics());
649
+ */
650
+ enableProfiling(options = {})
651
+ {
652
+ const { QueryProfiler } = require('./profiler');
653
+ this._profiler = new QueryProfiler(options);
654
+ this.adapter._profiler = this._profiler;
655
+ return this._profiler;
656
+ }
657
+
658
+ /**
659
+ * The attached profiler (if profiling is enabled).
660
+ * @type {QueryProfiler|null}
661
+ */
662
+ get profiler() { return this._profiler || null; }
663
+
664
+ /**
665
+ * The attached replica manager (if configured).
666
+ * @type {ReplicaManager|null}
667
+ */
668
+ get replicas() { return this._replicaManager || null; }
669
+
670
+ // -- Read Replicas -----------------------------------
671
+
672
+ /**
673
+ * Create a Database with read replica support.
674
+ * Automatically sets up a ReplicaManager with the primary and replica adapters.
675
+ *
676
+ * @param {string} type - Adapter type.
677
+ * @param {object} primaryOpts - Options for the primary adapter.
678
+ * @param {object[]} [replicaConfigs=[]] - Array of options for each replica adapter.
679
+ * @param {object} [options] - ReplicaManager options.
680
+ * @param {string} [options.strategy='round-robin'] - Selection strategy.
681
+ * @param {boolean} [options.stickyWrite=true] - Read from primary after a write.
682
+ * @param {number} [options.stickyWindow=1000] - Duration of sticky window (ms).
683
+ * @returns {Database} Connected database with replica routing.
684
+ *
685
+ * @example
686
+ * const db = Database.connectWithReplicas('postgres',
687
+ * { host: 'primary.db', database: 'app' },
688
+ * [
689
+ * { host: 'replica1.db', database: 'app' },
690
+ * { host: 'replica2.db', database: 'app' },
691
+ * ],
692
+ * { strategy: 'round-robin', stickyWindow: 2000 }
693
+ * );
694
+ */
695
+ static connectWithReplicas(type, primaryOpts, replicaConfigs = [], options = {})
696
+ {
697
+ if (!Array.isArray(replicaConfigs))
698
+ {
699
+ throw new Error('replicaConfigs must be an array');
700
+ }
701
+ const { ReplicaManager } = require('./replicas');
702
+ const db = Database.connect(type, primaryOpts);
703
+ const manager = new ReplicaManager(options);
704
+ manager.setPrimary(db.adapter);
705
+
706
+ for (const opts of replicaConfigs)
707
+ {
708
+ const replicaDb = Database.connect(type, opts);
709
+ manager.addReplica(replicaDb.adapter);
710
+ }
711
+
712
+ db._replicaManager = manager;
713
+ db.adapter._replicaManager = manager;
714
+ return db;
715
+ }
716
+ }
717
+
718
+ // -- Lazy module loaders ---------------------------------
719
+
720
+ const { Migrator, defineMigration } = require('./migrate');
721
+ const { QueryCache } = require('./cache');
722
+ const { Seeder, SeederRunner, Factory, Fake } = require('./seed');
723
+ const { QueryProfiler } = require('./profiler');
724
+ const { ReplicaManager } = require('./replicas');
725
+ const { DatabaseView } = require('./views');
726
+ const { FullTextSearch } = require('./search');
727
+ const { GeoQuery, EARTH_RADIUS_KM, EARTH_RADIUS_MI } = require('./geo');
728
+ const { TenantManager } = require('./tenancy');
729
+ const { AuditLog } = require('./audit');
730
+ const { PluginManager } = require('./plugin');
731
+ const { StoredProcedure, StoredFunction, TriggerManager } = require('./procedures');
732
+ const {
733
+ buildSnapshot, loadSnapshot, saveSnapshot,
734
+ diffSnapshots, hasNoChanges, generateMigrationCode,
735
+ discoverModels, SNAPSHOT_FILE,
736
+ } = require('./snapshot');
737
+
738
+ // -- Exports ---------------------------------------------
739
+
740
+ module.exports = {
741
+ Database,
742
+ Model,
743
+ TYPES,
744
+ Query,
745
+ validate,
746
+ validateValue,
747
+ validateFKAction,
748
+ validateCheck,
749
+ // Migration framework
750
+ Migrator,
751
+ defineMigration,
752
+ // Schema snapshots (EF Core–style auto-migrations)
753
+ buildSnapshot,
754
+ loadSnapshot,
755
+ saveSnapshot,
756
+ diffSnapshots,
757
+ hasNoChanges,
758
+ generateMigrationCode,
759
+ discoverModels,
760
+ SNAPSHOT_FILE,
761
+ // Query caching
762
+ QueryCache,
763
+ // Seeder framework
764
+ Seeder,
765
+ SeederRunner,
766
+ Factory,
767
+ Fake,
768
+ // Performance & Scalability (Phase 2)
769
+ QueryProfiler,
770
+ ReplicaManager,
771
+ // Advanced ORM Features (Phase 3)
772
+ DatabaseView,
773
+ FullTextSearch,
774
+ GeoQuery,
775
+ EARTH_RADIUS_KM,
776
+ EARTH_RADIUS_MI,
777
+ // Enterprise Infrastructure (Phase 4)
778
+ TenantManager,
779
+ AuditLog,
780
+ PluginManager,
781
+ StoredProcedure,
782
+ StoredFunction,
783
+ TriggerManager,
784
+ };