@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.
- package/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +170 -0
- package/package.json +58 -0
- package/src/casts/Cast.ts +200 -0
- package/src/commands/DbSeedCommand.ts +71 -0
- package/src/commands/MakeFactoryCommand.ts +59 -0
- package/src/commands/MakeMigrationCommand.ts +109 -0
- package/src/commands/MakeModelCommand.ts +83 -0
- package/src/commands/MakeSeederCommand.ts +50 -0
- package/src/commands/MigrateCommand.ts +60 -0
- package/src/commands/MigrateFreshCommand.ts +41 -0
- package/src/commands/MigrateGenerateCommand.ts +110 -0
- package/src/commands/MigrateRollbackCommand.ts +43 -0
- package/src/commands/MigrateStatusCommand.ts +49 -0
- package/src/commands/_loadMigrations.ts +34 -0
- package/src/commands/index.ts +30 -0
- package/src/config.ts +182 -0
- package/src/conventions.ts +67 -0
- package/src/db/DB.ts +486 -0
- package/src/db/NPlusOneDetector.ts +176 -0
- package/src/db/QueryBuilder.ts +2458 -0
- package/src/db/ReadWriteRouter.ts +96 -0
- package/src/db/TransactionContext.ts +13 -0
- package/src/db/dialects/MysqlDialect.ts +57 -0
- package/src/db/dialects/PostgresDialect.ts +55 -0
- package/src/db/dialects/SqliteDialect.ts +54 -0
- package/src/db/dialects/index.ts +25 -0
- package/src/db/dialects/types.ts +67 -0
- package/src/db/resolver.ts +30 -0
- package/src/db/sql-types.ts +12 -0
- package/src/db/types.ts +296 -0
- package/src/errors/MassAssignmentError.ts +25 -0
- package/src/errors/MigrationError.ts +18 -0
- package/src/errors/ModelNotFoundError.ts +21 -0
- package/src/errors/NPlusOneError.ts +6 -0
- package/src/errors/RelationNotLoadedError.ts +19 -0
- package/src/errors/StateError.ts +18 -0
- package/src/errors/TransactionError.ts +13 -0
- package/src/errors/UnsupportedDialectError.ts +18 -0
- package/src/errors/index.ts +7 -0
- package/src/events.ts +112 -0
- package/src/global.d.ts +17 -0
- package/src/implicitBinding.ts +73 -0
- package/src/index.ts +255 -0
- package/src/model/BaseModel.ts +2499 -0
- package/src/model/ModelQueryBuilder.ts +1808 -0
- package/src/model/Observer.ts +73 -0
- package/src/model/OrmContext.ts +71 -0
- package/src/model/ReactiveProxy.ts +53 -0
- package/src/model/SoftDeletes.ts +108 -0
- package/src/model/State.ts +290 -0
- package/src/model/decorators/_metadata.ts +211 -0
- package/src/model/decorators/_registerRelation.ts +20 -0
- package/src/model/decorators/belongsTo.ts +38 -0
- package/src/model/decorators/column.ts +278 -0
- package/src/model/decorators/hasMany.ts +34 -0
- package/src/model/decorators/hasManyThrough.ts +50 -0
- package/src/model/decorators/hasOne.ts +34 -0
- package/src/model/decorators/hasOneThrough.ts +40 -0
- package/src/model/decorators/manyToMany.ts +55 -0
- package/src/model/decorators/morphMany.ts +38 -0
- package/src/model/decorators/morphOne.ts +38 -0
- package/src/model/decorators/morphTo.ts +51 -0
- package/src/model/decorators/morphToMany.ts +49 -0
- package/src/model/decorators/morphedByMany.ts +46 -0
- package/src/model/decorators/table.ts +124 -0
- package/src/model/hooks/HookRegistry.ts +110 -0
- package/src/model/mixins.ts +536 -0
- package/src/model/payload.ts +114 -0
- package/src/model/relations/RelationRegistry.ts +184 -0
- package/src/observability.ts +210 -0
- package/src/provider/DatabaseProvider.ts +266 -0
- package/src/schema/Blueprint.ts +900 -0
- package/src/schema/ColumnDefinition.ts +517 -0
- package/src/schema/Migration.ts +34 -0
- package/src/schema/MigrationCodegen.ts +108 -0
- package/src/schema/MigrationRunner.ts +351 -0
- package/src/schema/ModelInspector.ts +133 -0
- package/src/schema/Schema.ts +140 -0
- package/src/schema/SchemaDiffer.ts +137 -0
- package/src/schema/SchemaInspector.ts +164 -0
- package/src/schema/__test_migrations__/001_create_test_table.ts +15 -0
- package/src/schema/autoMigrate.ts +154 -0
- package/src/schema/index.ts +28 -0
- package/src/seeding/Seeder.ts +46 -0
- 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
|
+
}
|