@zerotal/orm 1.0.0

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 (87) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +21 -0
  3. package/README.md +170 -0
  4. package/package.json +58 -0
  5. package/src/casts/Cast.ts +200 -0
  6. package/src/commands/DbSeedCommand.ts +71 -0
  7. package/src/commands/MakeFactoryCommand.ts +59 -0
  8. package/src/commands/MakeMigrationCommand.ts +109 -0
  9. package/src/commands/MakeModelCommand.ts +83 -0
  10. package/src/commands/MakeSeederCommand.ts +50 -0
  11. package/src/commands/MigrateCommand.ts +60 -0
  12. package/src/commands/MigrateFreshCommand.ts +41 -0
  13. package/src/commands/MigrateGenerateCommand.ts +110 -0
  14. package/src/commands/MigrateRollbackCommand.ts +43 -0
  15. package/src/commands/MigrateStatusCommand.ts +49 -0
  16. package/src/commands/_loadMigrations.ts +34 -0
  17. package/src/commands/index.ts +30 -0
  18. package/src/config.ts +182 -0
  19. package/src/conventions.ts +67 -0
  20. package/src/db/DB.ts +486 -0
  21. package/src/db/NPlusOneDetector.ts +176 -0
  22. package/src/db/QueryBuilder.ts +2458 -0
  23. package/src/db/ReadWriteRouter.ts +96 -0
  24. package/src/db/TransactionContext.ts +13 -0
  25. package/src/db/dialects/MysqlDialect.ts +57 -0
  26. package/src/db/dialects/PostgresDialect.ts +55 -0
  27. package/src/db/dialects/SqliteDialect.ts +54 -0
  28. package/src/db/dialects/index.ts +25 -0
  29. package/src/db/dialects/types.ts +67 -0
  30. package/src/db/resolver.ts +30 -0
  31. package/src/db/sql-types.ts +12 -0
  32. package/src/db/types.ts +296 -0
  33. package/src/errors/MassAssignmentError.ts +25 -0
  34. package/src/errors/MigrationError.ts +18 -0
  35. package/src/errors/ModelNotFoundError.ts +21 -0
  36. package/src/errors/NPlusOneError.ts +6 -0
  37. package/src/errors/RelationNotLoadedError.ts +19 -0
  38. package/src/errors/StateError.ts +18 -0
  39. package/src/errors/TransactionError.ts +13 -0
  40. package/src/errors/UnsupportedDialectError.ts +18 -0
  41. package/src/errors/index.ts +7 -0
  42. package/src/events.ts +112 -0
  43. package/src/global.d.ts +17 -0
  44. package/src/implicitBinding.ts +73 -0
  45. package/src/index.ts +255 -0
  46. package/src/model/BaseModel.ts +2499 -0
  47. package/src/model/ModelQueryBuilder.ts +1808 -0
  48. package/src/model/Observer.ts +73 -0
  49. package/src/model/OrmContext.ts +71 -0
  50. package/src/model/ReactiveProxy.ts +53 -0
  51. package/src/model/SoftDeletes.ts +108 -0
  52. package/src/model/State.ts +290 -0
  53. package/src/model/decorators/_metadata.ts +211 -0
  54. package/src/model/decorators/_registerRelation.ts +20 -0
  55. package/src/model/decorators/belongsTo.ts +38 -0
  56. package/src/model/decorators/column.ts +278 -0
  57. package/src/model/decorators/hasMany.ts +34 -0
  58. package/src/model/decorators/hasManyThrough.ts +50 -0
  59. package/src/model/decorators/hasOne.ts +34 -0
  60. package/src/model/decorators/hasOneThrough.ts +40 -0
  61. package/src/model/decorators/manyToMany.ts +55 -0
  62. package/src/model/decorators/morphMany.ts +38 -0
  63. package/src/model/decorators/morphOne.ts +38 -0
  64. package/src/model/decorators/morphTo.ts +51 -0
  65. package/src/model/decorators/morphToMany.ts +49 -0
  66. package/src/model/decorators/morphedByMany.ts +46 -0
  67. package/src/model/decorators/table.ts +124 -0
  68. package/src/model/hooks/HookRegistry.ts +110 -0
  69. package/src/model/mixins.ts +536 -0
  70. package/src/model/payload.ts +114 -0
  71. package/src/model/relations/RelationRegistry.ts +184 -0
  72. package/src/observability.ts +210 -0
  73. package/src/provider/DatabaseProvider.ts +266 -0
  74. package/src/schema/Blueprint.ts +900 -0
  75. package/src/schema/ColumnDefinition.ts +517 -0
  76. package/src/schema/Migration.ts +34 -0
  77. package/src/schema/MigrationCodegen.ts +108 -0
  78. package/src/schema/MigrationRunner.ts +351 -0
  79. package/src/schema/ModelInspector.ts +133 -0
  80. package/src/schema/Schema.ts +140 -0
  81. package/src/schema/SchemaDiffer.ts +137 -0
  82. package/src/schema/SchemaInspector.ts +164 -0
  83. package/src/schema/__test_migrations__/001_create_test_table.ts +15 -0
  84. package/src/schema/autoMigrate.ts +154 -0
  85. package/src/schema/index.ts +28 -0
  86. package/src/seeding/Seeder.ts +46 -0
  87. package/src/support/identifiers.ts +62 -0
package/src/db/DB.ts ADDED
@@ -0,0 +1,486 @@
1
+ import type { SQLInstance } from "./sql-types.ts";
2
+ import { RequestContext, FrameworkEvents } from "@zerotal/core";
3
+ import {
4
+ QueryExecuted,
5
+ TransactionStarted,
6
+ TransactionCommitted,
7
+ TransactionRolledBack,
8
+ } from "../events.ts";
9
+ import { resolveContainerConnection } from "./resolver.ts";
10
+ import { QueryBuilder, dialectFor } from "./QueryBuilder.ts";
11
+ import { getDialect } from "./dialects/index.ts";
12
+ import { UnsupportedDialectError } from "../errors/index.ts";
13
+ import { TransactionContext } from "./TransactionContext.ts";
14
+ import { createReadWriteRouter } from "./ReadWriteRouter.ts";
15
+ import {
16
+ preventNPlusOne as _preventNPlusOne,
17
+ allowNPlusOne as _allowNPlusOne,
18
+ type NPlusOneOptions,
19
+ } from "./NPlusOneDetector.ts";
20
+
21
+ // Test-override escape hatch — set by _setDbConnection() in test files.
22
+ // Production code resolves the connection from the container instead.
23
+ let _connection: SQLInstance | undefined;
24
+
25
+ /** Test helper: inject a primary connection without going through the container/provider. */
26
+ export function _setDbConnection(conn: SQLInstance | null): void {
27
+ _connection = conn ?? undefined;
28
+ }
29
+
30
+ /**
31
+ * Test helper: read the current injected override, or `null` when none is set.
32
+ *
33
+ * Distinct from {@link _getDbConnection}, which falls back to the container:
34
+ * a helper that installs its own connection needs to restore the *override
35
+ * slot* exactly as it found it, and cannot tell an absent override from a
36
+ * container-resolved connection otherwise.
37
+ */
38
+ export function _getDbConnectionOverride(): SQLInstance | null {
39
+ return _connection ?? null;
40
+ }
41
+
42
+ /**
43
+ * Test helper: inject a primary + replicas as a read/write router.
44
+ * Resets to a plain primary when `replicas` is empty.
45
+ */
46
+ export function _setReadReplicas(primary: SQLInstance, replicas: SQLInstance[]): void {
47
+ _connection = replicas.length > 0 ? createReadWriteRouter(primary, replicas) : primary;
48
+ }
49
+
50
+ /** Resolve the active base connection.
51
+ * Test overrides (_setDbConnection) take priority so isolated test databases
52
+ * are not displaced by a shared Application container that may exist in the
53
+ * same process when multiple test files run together.
54
+ */
55
+ function _fromContainer(): SQLInstance | undefined {
56
+ if (_connection) return _connection;
57
+ return resolveContainerConnection();
58
+ }
59
+
60
+ /** Read the active connection (used by Schema and migration commands). */
61
+ export function _getDbConnection(): SQLInstance {
62
+ const conn = _fromContainer();
63
+ if (!conn)
64
+ throw new Error("[Zerotal ORM] No database connection. Is DatabaseProvider registered?");
65
+ return conn;
66
+ }
67
+
68
+ /** Alias used by migration command helpers. */
69
+ export function _getConnection(): SQLInstance {
70
+ return _getDbConnection();
71
+ }
72
+
73
+ /**
74
+ * Resolve the active connection for this call site.
75
+ * Priority (highest first):
76
+ * 1. TransactionContext — inside a DB.transaction() call (ALS-based)
77
+ * 2. RequestContext._transaction — legacy request-scoped transaction
78
+ * 3. Container 'db' singleton (production)
79
+ * 4. _connection test override (tests without a full container)
80
+ */
81
+ function _resolveDbConn(): SQLInstance {
82
+ return (
83
+ TransactionContext.getStore() ??
84
+ (RequestContext.tryGet()?._transaction as SQLInstance | undefined) ??
85
+ _fromContainer()!
86
+ );
87
+ }
88
+
89
+ /**
90
+ * Resolve the primary (writable) connection, bypassing the read/write router.
91
+ * When no replicas are configured this is identical to `_resolveDbConn()`.
92
+ */
93
+ function _resolvePrimaryConn(): SQLInstance {
94
+ const conn = _resolveDbConn();
95
+ // If conn is a ReadWriteRouter proxy, it exposes the underlying primary via __primary__
96
+ const primary = (conn as unknown as Record<string, unknown>)["__primary__"];
97
+ return (primary as SQLInstance | undefined) ?? conn;
98
+ }
99
+
100
+ // ── Transaction helpers ───────────────────────────────────────────────────────
101
+
102
+ let _savepointCounter = 0;
103
+
104
+ /** Run a raw, parameter-less statement on a specific connection (SAVEPOINT, etc.). */
105
+ function _runRaw(conn: SQLInstance, sql: string): Promise<unknown> {
106
+ const arr = Object.assign([sql], { raw: [sql] }) as unknown as TemplateStringsArray;
107
+ return conn(arr);
108
+ }
109
+
110
+ /** Run a parameterised statement (`?` placeholders) on a specific connection. */
111
+ function _runParams(conn: SQLInstance, sql: string, params: unknown[]): Promise<unknown> {
112
+ const parts = sql.split("?");
113
+ const tpl = Object.assign(parts, { raw: parts }) as unknown as TemplateStringsArray;
114
+ return conn(tpl, ...params);
115
+ }
116
+
117
+ /** Heuristic: does this error look like a deadlock / serialization failure worth retrying? */
118
+ function _isDeadlock(err: unknown): boolean {
119
+ const msg = String((err as { message?: string })?.message ?? err).toLowerCase();
120
+ return (
121
+ msg.includes("deadlock") ||
122
+ msg.includes("serialization failure") ||
123
+ msg.includes("could not serialize") ||
124
+ msg.includes("sqlite_busy") ||
125
+ msg.includes("database is locked") ||
126
+ msg.includes("40001") ||
127
+ msg.includes("40p01")
128
+ );
129
+ }
130
+
131
+ /** Handle returned by DB.beginTransaction() for manual commit/rollback control. */
132
+ export interface ManualTransaction {
133
+ /** The transaction connection — use for tagged-template queries. */
134
+ readonly sql: SQLInstance;
135
+ /** Start a query builder bound to this transaction. */
136
+ table(name: string): QueryBuilder;
137
+ /** Commit and release the transaction. */
138
+ commit(): Promise<void>;
139
+ /** Roll back and release the transaction. */
140
+ rollback(): Promise<void>;
141
+ }
142
+
143
+ /**
144
+ * The `DB` facade — the entry point for database access outside the model layer.
145
+ *
146
+ * Bundles table queries, raw SQL, transactions, replica routing and N+1
147
+ * detection over the connection resolved for the current call site. Connection
148
+ * resolution is context-aware: inside a {@link DB.transaction} callback every
149
+ * query automatically uses the transaction connection (via
150
+ * {@link TransactionContext} AsyncLocalStorage), and when read replicas are
151
+ * configured, reads route to a replica while writes and transactions go to the
152
+ * primary.
153
+ *
154
+ * @example
155
+ * ```ts
156
+ * // Raw SQL (parameterised)
157
+ * const rows = await DB.raw`SELECT * FROM users WHERE id = ${id}`;
158
+ *
159
+ * // Fluent query builder
160
+ * const active = await DB.table('users').where('active', true).get();
161
+ *
162
+ * // Transaction — all queries inside use the tx connection automatically
163
+ * await DB.transaction(async () => {
164
+ * await DB.table('accounts').where('id', 1).decrement('balance', 100);
165
+ * await DB.table('accounts').where('id', 2).increment('balance', 100);
166
+ * });
167
+ * ```
168
+ */
169
+ export const DB = {
170
+ /**
171
+ * Start a chainable {@link QueryBuilder} against a table on the current
172
+ * connection.
173
+ * @category Tables
174
+ */
175
+ table(tableName: string): QueryBuilder {
176
+ return new QueryBuilder(tableName, _resolveDbConn());
177
+ },
178
+
179
+ /**
180
+ * Execute raw SQL.
181
+ *
182
+ * Tagged-template form (parameterized, safe):
183
+ * await DB.raw`SELECT * FROM users WHERE id = ${id}`
184
+ *
185
+ * String form (splits on `?` placeholders, same safety):
186
+ * await DB.raw('SELECT * FROM users WHERE id = ?', [id])
187
+ * await DB.raw('SELECT 1 + 1 AS n')
188
+ * @category Queries
189
+ */
190
+ async raw<T = Record<string, unknown>>(
191
+ sql: TemplateStringsArray | string,
192
+ ...rest: unknown[]
193
+ ): Promise<T[]> {
194
+ const conn = _resolveDbConn();
195
+ const startMs = Date.now();
196
+ if (typeof sql === "string") {
197
+ const bindings: unknown[] = Array.isArray(rest[0]) ? (rest[0] as unknown[]) : rest;
198
+ const parts = sql.split("?");
199
+ const tpl = Object.assign(parts, { raw: parts }) as unknown as TemplateStringsArray;
200
+ const rows = await conn<T>(tpl, ...bindings);
201
+ FrameworkEvents.emit(
202
+ new QueryExecuted(
203
+ sql,
204
+ bindings,
205
+ startMs,
206
+ Date.now() - startMs,
207
+ Array.isArray(rows) ? rows.length : 0,
208
+ RequestContext.tryGet(),
209
+ ),
210
+ );
211
+ return rows;
212
+ }
213
+ const rows = await conn<T>(sql, ...rest);
214
+ const rawSql = Array.isArray((sql as TemplateStringsArray).raw)
215
+ ? (sql as TemplateStringsArray).raw.join("?")
216
+ : String(sql);
217
+ FrameworkEvents.emit(
218
+ new QueryExecuted(
219
+ rawSql,
220
+ rest,
221
+ startMs,
222
+ Date.now() - startMs,
223
+ Array.isArray(rows) ? rows.length : 0,
224
+ RequestContext.tryGet(),
225
+ ),
226
+ );
227
+ return rows;
228
+ },
229
+
230
+ /**
231
+ * Run a callback inside a database transaction.
232
+ *
233
+ * All BaseModel and DB queries made within the callback automatically
234
+ * use the transaction connection via AsyncLocalStorage (TransactionContext).
235
+ * Works in any environment — request handlers, console commands, seeders.
236
+ *
237
+ * Bun auto-commits on resolve and auto-rolls-back on throw.
238
+ * NEVER call tx.commit() or tx.rollback() manually.
239
+ *
240
+ * Nested calls use a `SAVEPOINT` so an inner rollback does not abort the outer
241
+ * transaction. Pass `attempts > 1` to automatically retry on deadlock /
242
+ * serialization failures.
243
+ *
244
+ * @param callback - Work to run inside the transaction.
245
+ * @param attempts - Max attempts on deadlock-like errors (default 1 = no retry).
246
+ * @category Transactions
247
+ */
248
+ async transaction<T>(callback: (tx?: SQLInstance) => Promise<T>, attempts = 1): Promise<T> {
249
+ const existingTx = TransactionContext.getStore();
250
+ if (existingTx) {
251
+ // Nested transaction → use a SAVEPOINT so an inner rollback does not abort
252
+ // the entire outer transaction (true nested-transaction semantics).
253
+ const name = `zerotal_sp_${++_savepointCounter}`;
254
+ await _runRaw(existingTx, `SAVEPOINT ${name}`);
255
+ try {
256
+ const result = await TransactionContext.run(existingTx, () => callback(existingTx));
257
+ await _runRaw(existingTx, `RELEASE SAVEPOINT ${name}`);
258
+ return result;
259
+ } catch (err) {
260
+ await _runRaw(existingTx, `ROLLBACK TO SAVEPOINT ${name}`);
261
+ throw err;
262
+ }
263
+ }
264
+
265
+ const ctx = RequestContext.tryGet();
266
+ const maxAttempts = Math.max(1, attempts);
267
+ let lastErr: unknown;
268
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
269
+ const txId = crypto.randomUUID();
270
+ const start = performance.now();
271
+ FrameworkEvents.emit(new TransactionStarted(txId, ctx));
272
+ try {
273
+ const result = await _fromContainer()!.begin(async (tx: SQLInstance) => {
274
+ // TransactionContext (ALS) is the authoritative propagation mechanism. ctx._transaction
275
+ // is maintained only for legacy callers that read it directly; save and restore the
276
+ // previous value rather than blanking it, so a nested or concurrent transaction
277
+ // finishing does not clear an outer one's entry.
278
+ const previousTx = ctx?._transaction;
279
+ if (ctx) ctx._transaction = tx;
280
+ try {
281
+ return await TransactionContext.run(tx, () => callback(tx));
282
+ } finally {
283
+ if (ctx) ctx._transaction = previousTx;
284
+ }
285
+ });
286
+ FrameworkEvents.emit(
287
+ new TransactionCommitted(txId, Math.round(performance.now() - start), ctx),
288
+ );
289
+ return result;
290
+ } catch (err) {
291
+ lastErr = err;
292
+ const reason = (err as { message?: string })?.message;
293
+ FrameworkEvents.emit(
294
+ new TransactionRolledBack(txId, Math.round(performance.now() - start), reason, ctx),
295
+ );
296
+ if (attempt < maxAttempts && _isDeadlock(err)) continue;
297
+ throw err;
298
+ }
299
+ }
300
+ throw lastErr;
301
+ },
302
+
303
+ /**
304
+ * Begin a transaction with **manual** commit/rollback control. Run queries via
305
+ * the returned handle (`handle.sql` or `handle.table()`), then call
306
+ * `handle.commit()` or `handle.rollback()`.
307
+ *
308
+ * Prefer `DB.transaction(cb)` for automatic commit/rollback — this is for the
309
+ * rarer cases where the transaction boundary cannot be expressed as a callback.
310
+ *
311
+ * @example
312
+ * const t = await DB.beginTransaction();
313
+ * try {
314
+ * await t.table('accounts').where('id', 1).decrement('balance', 100);
315
+ * await t.commit();
316
+ * } catch (e) { await t.rollback(); throw e; }
317
+ * @category Transactions
318
+ */
319
+ async beginTransaction(): Promise<ManualTransaction> {
320
+ const conn = _fromContainer()!;
321
+ const ROLLBACK = Symbol("zerotal.rollback");
322
+ let releaseGate!: () => void;
323
+ const gate = new Promise<void>((r) => {
324
+ releaseGate = r;
325
+ });
326
+ let markReady!: () => void;
327
+ const ready = new Promise<void>((r) => {
328
+ markReady = r;
329
+ });
330
+ let txConn!: SQLInstance;
331
+ let outcome: "commit" | "rollback" = "commit";
332
+
333
+ // Hold the transaction open inside Bun's begin() callback until commit/rollback
334
+ // resolves the gate. Throwing the ROLLBACK sentinel triggers Bun's auto-rollback.
335
+ const done = conn
336
+ .begin(async (tx: SQLInstance) => {
337
+ txConn = tx;
338
+ markReady();
339
+ await gate;
340
+ if (outcome === "rollback") throw ROLLBACK;
341
+ })
342
+ .then(
343
+ () => undefined,
344
+ (e: unknown) => {
345
+ if (e !== ROLLBACK) throw e;
346
+ },
347
+ );
348
+
349
+ await ready;
350
+
351
+ const txId = crypto.randomUUID();
352
+ const start = performance.now();
353
+ const ctx = RequestContext.tryGet();
354
+ FrameworkEvents.emit(new TransactionStarted(txId, ctx));
355
+
356
+ let settled = false;
357
+ const finish = async (mode: "commit" | "rollback"): Promise<void> => {
358
+ if (settled) return;
359
+ settled = true;
360
+ outcome = mode;
361
+ releaseGate();
362
+ try {
363
+ await done;
364
+ } catch (err) {
365
+ FrameworkEvents.emit(
366
+ new TransactionRolledBack(
367
+ txId,
368
+ Math.round(performance.now() - start),
369
+ (err as { message?: string })?.message,
370
+ ctx,
371
+ ),
372
+ );
373
+ throw err;
374
+ }
375
+ if (mode === "commit") {
376
+ FrameworkEvents.emit(
377
+ new TransactionCommitted(txId, Math.round(performance.now() - start), ctx),
378
+ );
379
+ } else {
380
+ FrameworkEvents.emit(
381
+ new TransactionRolledBack(txId, Math.round(performance.now() - start), undefined, ctx),
382
+ );
383
+ }
384
+ };
385
+
386
+ return {
387
+ sql: txConn,
388
+ table: (name: string) => new QueryBuilder(name, txConn),
389
+ commit: () => finish("commit"),
390
+ rollback: () => finish("rollback"),
391
+ };
392
+ },
393
+
394
+ /**
395
+ * Return a query builder scoped to the **primary** connection, bypassing
396
+ * the replica pool entirely.
397
+ *
398
+ * Use this for **read-your-writes** scenarios — when you need to query data
399
+ * immediately after a mutation and cannot wait for replication lag.
400
+ *
401
+ * @example
402
+ * await DB.table('orders').insert({ total: 99 });
403
+ *
404
+ * // Read the just-inserted row from primary, not a potentially-lagging replica:
405
+ * const order = await DB.onPrimary().table('orders').where('id', id).first();
406
+ * @category Connections
407
+ */
408
+ onPrimary(): { table(name: string): QueryBuilder } {
409
+ const conn = _resolvePrimaryConn();
410
+ return {
411
+ table: (name: string) => new QueryBuilder(name, conn),
412
+ };
413
+ },
414
+
415
+ /**
416
+ * Return the active transaction connection (from the ALS transaction context
417
+ * or the legacy request-scoped transaction), or `undefined` when none is open.
418
+ * @category Transactions
419
+ */
420
+ currentTx(): unknown | undefined {
421
+ return (
422
+ TransactionContext.getStore() ??
423
+ (RequestContext.tryGet()?._transaction as SQLInstance | undefined)
424
+ );
425
+ },
426
+
427
+ /**
428
+ * Acquire a database advisory lock for the duration of a callback.
429
+ * The lock is released automatically when the callback resolves or rejects.
430
+ *
431
+ * Dialect-aware: `pg_advisory_lock()` on PostgreSQL, `GET_LOCK()` on MySQL.
432
+ * Throws `UnsupportedDialectError` (E_UNSUPPORTED_DIALECT) on SQLite, which
433
+ * has no advisory-lock primitive.
434
+ *
435
+ * @param key Integer lock key (application-defined).
436
+ * @param callback Work to perform while the lock is held.
437
+ * @throws {UnsupportedDialectError} On SQLite (no advisory-lock primitive).
438
+ * @category Connections
439
+ */
440
+ async advisoryLock<T>(key: number, callback: () => Promise<T>): Promise<T> {
441
+ const conn = _resolveDbConn();
442
+ const dialect = getDialect(dialectFor(conn));
443
+ if (!dialect.supportsAdvisoryLocks) {
444
+ throw new UnsupportedDialectError("DB.advisoryLock()", dialect.name);
445
+ }
446
+ const lock = dialect.advisoryLockSql(key)!;
447
+ const unlock = dialect.advisoryUnlockSql(key)!;
448
+ await _runParams(conn, lock.sql, lock.params);
449
+ try {
450
+ return await callback();
451
+ } finally {
452
+ await _runParams(conn, unlock.sql, unlock.params);
453
+ }
454
+ },
455
+
456
+ /**
457
+ * Configure N+1 query detection.
458
+ *
459
+ * Detection is automatically active in `local` and `development` environments
460
+ * (warn mode, threshold 5). Call this to change the threshold, switch to
461
+ * 'throw' mode, or enable detection in other environments.
462
+ *
463
+ * @example
464
+ * // bootstrap/app.ts
465
+ * DB.preventNPlusOne({ threshold: 3, mode: 'throw' });
466
+ * @category Queries
467
+ */
468
+ preventNPlusOne(options?: NPlusOneOptions): void {
469
+ _preventNPlusOne(options);
470
+ },
471
+
472
+ /**
473
+ * Suppress N+1 warnings for queries containing `pattern` as a substring.
474
+ *
475
+ * @param pattern A table name or any substring of the SQL shape.
476
+ * @param options `{ once: true }` suppresses only for the current request.
477
+ *
478
+ * @example
479
+ * DB.allowNPlusOne('activity_logs'); // all requests
480
+ * DB.allowNPlusOne('taggings', { once: true }); // this request only
481
+ * @category Queries
482
+ */
483
+ allowNPlusOne(pattern: string, options?: { once?: boolean }): void {
484
+ _allowNPlusOne(pattern, options, RequestContext.tryGet());
485
+ },
486
+ };
@@ -0,0 +1,176 @@
1
+ import { ZerotalError, FrameworkEvents } from "@zerotal/core";
2
+ import { NPlusOneDetected } from "../events.ts";
3
+
4
+ // ── N+1 Query Detector ────────────────────────────────────────────────────────
5
+ //
6
+ // Enabled automatically in local/development environments (APP_ENV = local |
7
+ // development | dev). Disabled in production and test automatically; call
8
+ // preventNPlusOne() to enable it in any other environment.
9
+ //
10
+ // How it works:
11
+ // Every SELECT shares a normalised SQL fingerprint (the _tplCache key from
12
+ // QueryBuilder: string fragments joined with \x00). Per-request counts are
13
+ // tracked in a WeakMap keyed by the HttpContext object — no extra ALS setup,
14
+ // no memory leaks: the map entry is GC'd with the request context.
15
+ //
16
+ // When a fingerprint crosses the threshold in one request the detector fires.
17
+ // ─────────────────────────────────────────────────────────────────────────────
18
+
19
+ /**
20
+ * Thrown (in `throw` mode) or logged (in `warn` mode) when one normalised query
21
+ * shape runs more times than the configured threshold within a single request —
22
+ * the signature of an N+1 access pattern. Carries the offending `fingerprint`
23
+ * and its `count`; error code `E_N_PLUS_ONE`.
24
+ */
25
+ export class NPlusOneError extends ZerotalError {
26
+ readonly fingerprint: string;
27
+ readonly count: number;
28
+
29
+ constructor(fingerprint: string, count: number) {
30
+ const sql = fingerprint.replace(/\x00/g, "?");
31
+ super(
32
+ `NPlusOneError: The query\n\n` +
33
+ ` ${sql}\n\n` +
34
+ `was executed ${count} times in a single request. This is an N+1 query.\n\n` +
35
+ `Fix: load the relation eagerly using .with('relation') on your query,\n` +
36
+ `or call await model.load('relation') before the loop.\n\n` +
37
+ `To suppress for a specific table or pattern:\n` +
38
+ ` DB.allowNPlusOne('table_name') // permanent\n` +
39
+ ` DB.allowNPlusOne('table_name', { once: true }) // this request only\n\n` +
40
+ `Set APP_ENV=production to disable this check entirely.`,
41
+ "E_N_PLUS_ONE",
42
+ 500,
43
+ );
44
+ this.fingerprint = fingerprint;
45
+ this.count = count;
46
+ }
47
+ }
48
+
49
+ // ── State ─────────────────────────────────────────────────────────────────────
50
+
51
+ /** Per-request query-shape hit counts. Keyed by the HttpContext object. */
52
+ const _counts = new WeakMap<object, Map<string, number>>();
53
+
54
+ /** Once-per-request suppressions. Cleared automatically when the context is GC'd. */
55
+ const _onceSuppressed = new WeakMap<object, Set<string>>();
56
+
57
+ /** Permanent (process-level) suppression patterns (matched as substrings). */
58
+ const _suppressed = new Set<string>();
59
+
60
+ let _threshold = 5;
61
+ let _mode: "warn" | "throw" = "warn";
62
+ let _forced = false; // true when explicitly configured via preventNPlusOne()
63
+
64
+ // ── Public API ────────────────────────────────────────────────────────────────
65
+
66
+ export interface NPlusOneOptions {
67
+ /** How many identical query shapes in one request triggers a violation. Default: 5. */
68
+ threshold?: number;
69
+ /**
70
+ * 'warn' → console.warn (default — non-breaking, still visible)
71
+ * 'throw' → throws NPlusOneError (good for CI / strict mode)
72
+ */
73
+ mode?: "warn" | "throw";
74
+ }
75
+
76
+ /**
77
+ * Configure N+1 query detection.
78
+ *
79
+ * Call once at application boot to enable detection in any environment.
80
+ * In `local` and `development` environments detection is already active with
81
+ * default settings — call this to change the threshold or switch to 'throw'.
82
+ *
83
+ * @example
84
+ * // bootstrap/app.ts (development)
85
+ * import { DB } from '@zerotal/orm';
86
+ * DB.preventNPlusOne({ threshold: 3, mode: 'throw' });
87
+ */
88
+ export function preventNPlusOne(options: NPlusOneOptions = {}): void {
89
+ _threshold = options.threshold ?? _threshold;
90
+ _mode = options.mode ?? _mode;
91
+ _forced = true;
92
+ }
93
+
94
+ /**
95
+ * Suppress N+1 warnings for queries involving `pattern` (matched as a
96
+ * case-insensitive substring of the SQL shape).
97
+ *
98
+ * @param pattern A table name or any substring of the SQL fingerprint.
99
+ * @param options `{ once: true }` — suppress only for the current request.
100
+ *
101
+ * @example
102
+ * DB.allowNPlusOne('activity_logs'); // permanent
103
+ * DB.allowNPlusOne('taggings', { once: true }); // this request only
104
+ */
105
+ export function allowNPlusOne(
106
+ pattern: string,
107
+ options?: { once?: boolean },
108
+ ctx?: object | null,
109
+ ): void {
110
+ if (options?.once) {
111
+ if (!ctx) return;
112
+ if (!_onceSuppressed.has(ctx)) _onceSuppressed.set(ctx, new Set());
113
+ _onceSuppressed.get(ctx)!.add(pattern.toLowerCase());
114
+ } else {
115
+ _suppressed.add(pattern.toLowerCase());
116
+ }
117
+ }
118
+
119
+ /** @internal — reset all state (used in tests). */
120
+ export function _resetNPlusOne(): void {
121
+ _suppressed.clear();
122
+ _threshold = 5;
123
+ _mode = "warn";
124
+ _forced = false;
125
+ }
126
+
127
+ // ── Core tracking ─────────────────────────────────────────────────────────────
128
+
129
+ /** @internal — called by QueryBuilder._run() on every query execution. */
130
+ export function trackQuery(ctx: object | null | undefined, fingerprint: string): void {
131
+ if (!ctx) return;
132
+
133
+ // Honour explicit opt-in or auto-enable in local/development only
134
+ if (!_forced) {
135
+ const env = (typeof Bun !== "undefined" ? Bun.env["APP_ENV"] : undefined) ?? "";
136
+ if (env !== "local" && env !== "development" && env !== "dev") return;
137
+ }
138
+
139
+ // Production always off
140
+ const env = (typeof Bun !== "undefined" ? Bun.env["APP_ENV"] : undefined) ?? "";
141
+ if (env === "production" || env === "prod") return;
142
+
143
+ // Only watch SELECTs — the N+1 problem is purely about reads
144
+ if (!fingerprint.trimStart().toLowerCase().startsWith("select")) return;
145
+
146
+ // Check suppressions
147
+ const lower = fingerprint.toLowerCase();
148
+ for (const pat of _suppressed) {
149
+ if (lower.includes(pat)) return;
150
+ }
151
+ const once = _onceSuppressed.get(ctx);
152
+ if (once) {
153
+ for (const pat of once) {
154
+ if (lower.includes(pat)) return;
155
+ }
156
+ }
157
+
158
+ // Count this fingerprint for the current request
159
+ if (!_counts.has(ctx)) _counts.set(ctx, new Map());
160
+ const map = _counts.get(ctx)!;
161
+ const count = (map.get(fingerprint) ?? 0) + 1;
162
+ map.set(fingerprint, count);
163
+
164
+ if (count >= _threshold) {
165
+ // Only fire once (at exactly the threshold), not on every subsequent hit
166
+ if (count > _threshold) return;
167
+
168
+ const err = new NPlusOneError(fingerprint, count);
169
+ FrameworkEvents.emit(new NPlusOneDetected(fingerprint, count, ctx ?? undefined));
170
+ if (_mode === "throw") {
171
+ throw err;
172
+ } else {
173
+ console.warn(`\n[Zerotal ORM] ${err.message}\n`);
174
+ }
175
+ }
176
+ }