linkgress-orm 0.4.33 → 0.4.35

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 (39) hide show
  1. package/dist/database/database-client.interface.d.ts +37 -4
  2. package/dist/database/database-client.interface.d.ts.map +1 -1
  3. package/dist/database/database-client.interface.js +27 -3
  4. package/dist/database/database-client.interface.js.map +1 -1
  5. package/dist/database/index.d.ts +2 -1
  6. package/dist/database/index.d.ts.map +1 -1
  7. package/dist/database/index.js +2 -1
  8. package/dist/database/index.js.map +1 -1
  9. package/dist/database/postgres-client.d.ts +12 -1
  10. package/dist/database/postgres-client.d.ts.map +1 -1
  11. package/dist/database/postgres-client.js +151 -21
  12. package/dist/database/postgres-client.js.map +1 -1
  13. package/dist/database/types.d.ts +12 -0
  14. package/dist/database/types.d.ts.map +1 -1
  15. package/dist/entity/db-context.d.ts +221 -8
  16. package/dist/entity/db-context.d.ts.map +1 -1
  17. package/dist/entity/db-context.js +335 -59
  18. package/dist/entity/db-context.js.map +1 -1
  19. package/dist/index.d.ts +3 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +3 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/query/grouped-query.d.ts +38 -1
  24. package/dist/query/grouped-query.d.ts.map +1 -1
  25. package/dist/query/grouped-query.js +67 -0
  26. package/dist/query/grouped-query.js.map +1 -1
  27. package/dist/query/join-builder.d.ts +14 -1
  28. package/dist/query/join-builder.d.ts.map +1 -1
  29. package/dist/query/join-builder.js +23 -0
  30. package/dist/query/join-builder.js.map +1 -1
  31. package/dist/query/query-builder.d.ts +33 -1
  32. package/dist/query/query-builder.d.ts.map +1 -1
  33. package/dist/query/query-builder.js +51 -0
  34. package/dist/query/query-builder.js.map +1 -1
  35. package/dist/query/union-builder.d.ts +14 -1
  36. package/dist/query/union-builder.d.ts.map +1 -1
  37. package/dist/query/union-builder.js +23 -0
  38. package/dist/query/union-builder.js.map +1 -1
  39. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DatabaseContext = exports.DbEntityTable = exports.EntityInsertBuilder = exports.DataContext = exports.TableAccessor = exports.InsertBuilder = exports.QueryExecutor = exports.TimeTracer = void 0;
4
+ exports.defaultLogger = defaultLogger;
4
5
  const database_client_interface_1 = require("../database/database-client.interface");
5
6
  const entity_base_1 = require("./entity-base");
6
7
  const model_config_1 = require("./model-config");
@@ -9,6 +10,113 @@ const query_builder_1 = require("../query/query-builder");
9
10
  const db_schema_manager_1 = require("../migration/db-schema-manager");
10
11
  const sequence_builder_1 = require("../schema/sequence-builder");
11
12
  const sql_utils_1 = require("../query/sql-utils");
13
+ /**
14
+ * Default logger used when no custom `logger` is provided. Routes by section to
15
+ * the appropriate console method.
16
+ */
17
+ function defaultLogger(message, section) {
18
+ if (section === 'error') {
19
+ console.error(message);
20
+ }
21
+ else if (section === 'warn') {
22
+ console.warn(message);
23
+ }
24
+ else {
25
+ console.log(message);
26
+ }
27
+ }
28
+ /**
29
+ * Basenames (without extension) of the linkgress source files that can appear in
30
+ * a query's call stack between the public terminal method (`.toList()` etc.) and
31
+ * the user's code. Internal frames are stripped from {@link SlowQueryInfo.stack}
32
+ * by matching these file IDENTITIES — not an install path — so it works wherever
33
+ * the package lives (node_modules, a monorepo, ts-node/ts-jest, a global link).
34
+ */
35
+ const LINKGRESS_INTERNAL_FILES = new Set([
36
+ 'db-context',
37
+ 'query-builder',
38
+ 'grouped-query',
39
+ 'union-builder',
40
+ 'join-builder',
41
+ 'future-query',
42
+ 'prepared-query',
43
+ ]);
44
+ /** Extract the source-file basename (without extension) from a V8 stack frame line. */
45
+ function stackFrameFile(line) {
46
+ // Matches the "<file>:<line>:<col>" tail, e.g.
47
+ // " at SelectQueryBuilder.toList (C:\repo\src\query\query-builder.ts:2019:17)"
48
+ // " at /repo/dist/entity/db-context.js:574:22"
49
+ const match = line.match(/([^()\s]+):\d+:\d+\)?\s*$/);
50
+ if (!match)
51
+ return undefined;
52
+ const base = match[1].split(/[\\/]/).pop();
53
+ return base ? base.replace(/\.[cm]?[jt]s$/, '') : undefined;
54
+ }
55
+ /**
56
+ * Capture a stack-trace holder cheaply — the `.stack` string is only formatted
57
+ * on access, so this is paid for fully only when the query turns out slow. Call
58
+ * it from within the user's synchronous call chain (before awaiting), so the
59
+ * user's frames are present. Temporarily raises the capture depth so deep user
60
+ * frames survive the internal frames sitting above them.
61
+ */
62
+ function captureStackHolder() {
63
+ const holder = {};
64
+ const previousLimit = Error.stackTraceLimit;
65
+ Error.stackTraceLimit = 50;
66
+ if (typeof Error.captureStackTrace === 'function') {
67
+ Error.captureStackTrace(holder, captureStackHolder);
68
+ }
69
+ else {
70
+ holder.stack = new Error().stack;
71
+ }
72
+ Error.stackTraceLimit = previousLimit;
73
+ return holder;
74
+ }
75
+ /**
76
+ * Turn a captured stack into the user-facing call stack: drop the leading run of
77
+ * linkgress-internal frames (identified by source-file basename, so it is robust
78
+ * to wherever the package is installed and catches anonymous internal closures
79
+ * too), leaving the user's terminal-method call site as the first frame. Falls
80
+ * back to all frames if filtering would leave nothing.
81
+ */
82
+ function extractUserStack(holder) {
83
+ const raw = holder?.stack;
84
+ if (!raw)
85
+ return '';
86
+ const frames = raw.split('\n').filter(line => line.trim().startsWith('at '));
87
+ let i = 0;
88
+ while (i < frames.length) {
89
+ const file = stackFrameFile(frames[i]);
90
+ if (file && LINKGRESS_INTERNAL_FILES.has(file)) {
91
+ i++;
92
+ }
93
+ else {
94
+ break;
95
+ }
96
+ }
97
+ const userFrames = frames.slice(i);
98
+ return (userFrames.length > 0 ? userFrames : frames).join('\n');
99
+ }
100
+ /**
101
+ * Invoke the slow-query callback defensively: resolve the user stack lazily and
102
+ * swallow any error from the callback so a diagnostic notice can never break the
103
+ * query that triggered it.
104
+ */
105
+ function fireSlowQueryCallback(callback, sql, params, durationMs, thresholdMs, stackHolder) {
106
+ let stack = '';
107
+ try {
108
+ stack = extractUserStack(stackHolder);
109
+ }
110
+ catch {
111
+ /* ignore stack-extraction failures */
112
+ }
113
+ try {
114
+ callback({ sql, params, durationMs, thresholdMs, stack });
115
+ }
116
+ catch {
117
+ /* swallow — the slow-query notice must never affect the query */
118
+ }
119
+ }
12
120
  /**
13
121
  * Time tracer utility for measuring query phases
14
122
  */
@@ -107,28 +215,28 @@ class TimeTracer {
107
215
  if (!this.enabled)
108
216
  return;
109
217
  const trace = this.getTrace(rowCount);
110
- const log = this.logger || console.log;
111
- log('\n[Time Trace Summary]', 'debug');
112
- log(` Total: ${trace.totalMs.toFixed(2)}ms`, 'debug');
218
+ const log = this.logger || defaultLogger;
219
+ log('\n[Time Trace Summary]', 'timing');
220
+ log(` Total: ${trace.totalMs.toFixed(2)}ms`, 'timing');
113
221
  if (trace.phases.queryBuild !== undefined) {
114
- log(` Query Build: ${trace.phases.queryBuild.toFixed(2)}ms`, 'debug');
222
+ log(` Query Build: ${trace.phases.queryBuild.toFixed(2)}ms`, 'timing');
115
223
  }
116
224
  if (trace.phases.queryExecution !== undefined) {
117
- log(` Query Execution: ${trace.phases.queryExecution.toFixed(2)}ms`, 'debug');
225
+ log(` Query Execution: ${trace.phases.queryExecution.toFixed(2)}ms`, 'timing');
118
226
  }
119
227
  if (trace.phases.resultProcessing !== undefined) {
120
- log(` Result Processing: ${trace.phases.resultProcessing.toFixed(2)}ms`, 'debug');
228
+ log(` Result Processing: ${trace.phases.resultProcessing.toFixed(2)}ms`, 'timing');
121
229
  }
122
230
  if (rowCount !== undefined) {
123
- log(` Rows: ${rowCount}`, 'debug');
231
+ log(` Rows: ${rowCount}`, 'timing');
124
232
  }
125
233
  // Log detailed entries if there are any significant operations
126
234
  const significantEntries = this.entries.filter(e => e.durationMs > 0.1);
127
235
  if (significantEntries.length > 0) {
128
- log('\n[Detailed Trace]', 'debug');
236
+ log('\n[Detailed Trace]', 'timing');
129
237
  for (const entry of significantEntries) {
130
238
  const details = entry.details ? ` (${JSON.stringify(entry.details)})` : '';
131
- log(` [${entry.phase}] ${entry.operation}: ${entry.durationMs.toFixed(2)}ms${details}`, 'debug');
239
+ log(` [${entry.phase}] ${entry.operation}: ${entry.durationMs.toFixed(2)}ms${details}`, 'timing');
132
240
  }
133
241
  }
134
242
  }
@@ -138,30 +246,103 @@ exports.TimeTracer = TimeTracer;
138
246
  * Query executor with optional logging
139
247
  */
140
248
  class QueryExecutor {
141
- constructor(client, options = {}) {
249
+ constructor(client, options = {},
250
+ /**
251
+ * Per-query timeout override (ms) set via `.withTimeout()`. Threaded down to
252
+ * the driver as `QueryExecutionOptions.timeoutMs`. `undefined` means no
253
+ * override (the connection-level default, if any, applies).
254
+ */
255
+ overrideTimeoutMs,
256
+ /**
257
+ * Per-query "expected execution time" override (ms) set via
258
+ * `.expectedExecutionTime()`. If the query runs longer than this,
259
+ * `onQueryTakingTooLong` fires. `undefined` means use the context default.
260
+ */
261
+ overrideExpectedMs) {
142
262
  this.client = client;
143
263
  this.options = options;
264
+ this.overrideTimeoutMs = overrideTimeoutMs;
265
+ this.overrideExpectedMs = overrideExpectedMs;
266
+ }
267
+ /**
268
+ * Build the per-query execution options (binary protocol + timeout override),
269
+ * or `undefined` when neither is set so the driver takes its fast path.
270
+ */
271
+ buildExecutionOptions() {
272
+ if (!this.options.useBinaryProtocol && this.overrideTimeoutMs === undefined) {
273
+ return undefined;
274
+ }
275
+ return {
276
+ useBinaryProtocol: this.options.useBinaryProtocol,
277
+ timeoutMs: this.overrideTimeoutMs,
278
+ };
279
+ }
280
+ /**
281
+ * Return a new executor sharing this one's client and options but applying the
282
+ * given per-query timeout override (ms). Pass `0` to disable the timeout for
283
+ * the derived executor. Used by `.withTimeout()` on the query builders.
284
+ */
285
+ withTimeout(timeoutMs) {
286
+ return new QueryExecutor(this.client, this.options, timeoutMs, this.overrideExpectedMs);
287
+ }
288
+ /**
289
+ * Return a new executor that flags this query as expected to finish within
290
+ * `expectedMs` — if it runs longer, `onQueryTakingTooLong` fires. Used by
291
+ * `.expectedExecutionTime()` on the query builders.
292
+ */
293
+ withExpectedExecutionTime(expectedMs) {
294
+ return new QueryExecutor(this.client, this.options, this.overrideTimeoutMs, expectedMs);
295
+ }
296
+ /** Whether slow-query detection is active (a callback is configured). */
297
+ get slowQueryEnabled() {
298
+ return typeof this.options.onQueryTakingTooLong === 'function';
299
+ }
300
+ /** Effective "too long" threshold for the current query (ms). */
301
+ get expectedExecutionMs() {
302
+ return this.overrideExpectedMs ?? this.options.longRunningQueryThreshold ?? 10000;
303
+ }
304
+ /**
305
+ * Begin timing/stack capture for a query. Returns `undefined` when neither
306
+ * execution-time logging nor slow-query detection is active (zero overhead).
307
+ * The stack is captured here — synchronously, inside the caller's call chain —
308
+ * so the slow-query callback can report the user's code, not an async frame.
309
+ */
310
+ beginTiming() {
311
+ if (!this.options.logExecutionTime && !this.slowQueryEnabled) {
312
+ return undefined;
313
+ }
314
+ const stackHolder = this.slowQueryEnabled ? captureStackHolder() : undefined;
315
+ return { startTime: performance.now(), stackHolder };
316
+ }
317
+ /**
318
+ * Finish timing: log execution time (if enabled) and fire the slow-query
319
+ * callback (if enabled and the expected threshold was exceeded).
320
+ */
321
+ finishTiming(timing, logger, sql, params) {
322
+ if (!timing)
323
+ return;
324
+ const duration = performance.now() - timing.startTime;
325
+ if (this.options.logExecutionTime) {
326
+ logger(`[Execution Time] ${duration.toFixed(2)}ms`, 'timing');
327
+ }
328
+ const callback = this.options.onQueryTakingTooLong;
329
+ if (callback && duration > this.expectedExecutionMs) {
330
+ fireSlowQueryCallback(callback, sql, params, duration, this.expectedExecutionMs, timing.stackHolder);
331
+ }
144
332
  }
145
333
  async query(sql, params) {
146
- const logger = this.options.logger || console.log;
147
- const startTime = this.options.logExecutionTime ? performance.now() : 0;
334
+ const logger = this.options.logger || defaultLogger;
335
+ const timing = this.beginTiming();
148
336
  if (this.options.logQueries) {
149
- logger(`\n[SQL Query]`, 'debug');
150
- logger(sql.trim(), 'debug');
337
+ logger(`\n[SQL Query]`, 'sql');
338
+ logger(sql.trim(), 'sql');
151
339
  if (this.options.logParameters && params && params.length > 0) {
152
- logger(`[Parameters] ${JSON.stringify(params)}`, 'debug');
340
+ logger(`[Parameters] ${JSON.stringify(params)}`, 'params');
153
341
  }
154
342
  }
155
343
  try {
156
- // Pass binary protocol option if enabled
157
- const queryOptions = this.options.useBinaryProtocol
158
- ? { useBinaryProtocol: true }
159
- : undefined;
160
- const result = await this.client.query(sql, params, queryOptions);
161
- if (this.options.logExecutionTime) {
162
- const duration = (performance.now() - startTime).toFixed(2);
163
- logger(`[Execution Time] ${duration}ms`, 'debug');
164
- }
344
+ const result = await this.client.query(sql, params, this.buildExecutionOptions());
345
+ this.finishTiming(timing, logger, sql, params);
165
346
  return result;
166
347
  }
167
348
  catch (error) {
@@ -176,31 +357,23 @@ class QueryExecutor {
176
357
  * Only available for clients that support it (e.g., PostgresClient)
177
358
  */
178
359
  async querySimple(sql) {
179
- const logger = this.options.logger || console.log;
180
- const startTime = this.options.logExecutionTime ? performance.now() : 0;
360
+ const logger = this.options.logger || defaultLogger;
361
+ const timing = this.beginTiming();
181
362
  if (this.options.logQueries) {
182
- logger(`\n[SQL Query - Multi-Statement]`, 'debug');
183
- logger(sql.trim(), 'debug');
363
+ logger(`\n[SQL Query - Multi-Statement]`, 'sql');
364
+ logger(sql.trim(), 'sql');
184
365
  }
185
366
  try {
186
- // Check if client has querySimple method
367
+ let result;
187
368
  if ('querySimple' in this.client && typeof this.client.querySimple === 'function') {
188
- const result = await this.client.querySimple(sql);
189
- if (this.options.logExecutionTime) {
190
- const duration = (performance.now() - startTime).toFixed(2);
191
- logger(`[Execution Time] ${duration}ms`, 'debug');
192
- }
193
- return result;
369
+ result = await this.client.querySimple(sql);
194
370
  }
195
371
  else {
196
372
  // Fallback to regular query
197
- const result = await this.client.query(sql, []);
198
- if (this.options.logExecutionTime) {
199
- const duration = (performance.now() - startTime).toFixed(2);
200
- logger(`[Execution Time] ${duration}ms`, 'debug');
201
- }
202
- return result;
373
+ result = await this.client.query(sql, []);
203
374
  }
375
+ this.finishTiming(timing, logger, sql);
376
+ return result;
204
377
  }
205
378
  catch (error) {
206
379
  if (this.options.logQueries) {
@@ -214,20 +387,17 @@ class QueryExecutor {
214
387
  * Only available for PostgresClient
215
388
  */
216
389
  async querySimpleMulti(sql) {
217
- const logger = this.options.logger || console.log;
218
- const startTime = this.options.logExecutionTime ? performance.now() : 0;
390
+ const logger = this.options.logger || defaultLogger;
391
+ const timing = this.beginTiming();
219
392
  if (this.options.logQueries) {
220
- logger(`\n[SQL Query - Fully Optimized Multi-Statement]`, 'debug');
221
- logger(sql.trim(), 'debug');
393
+ logger(`\n[SQL Query - Fully Optimized Multi-Statement]`, 'sql');
394
+ logger(sql.trim(), 'sql');
222
395
  }
223
396
  try {
224
397
  // Check if client has querySimpleMulti method
225
398
  if ('querySimpleMulti' in this.client && typeof this.client.querySimpleMulti === 'function') {
226
399
  const results = await this.client.querySimpleMulti(sql);
227
- if (this.options.logExecutionTime) {
228
- const duration = (performance.now() - startTime).toFixed(2);
229
- logger(`[Execution Time] ${duration}ms`, 'debug');
230
- }
400
+ this.finishTiming(timing, logger, sql);
231
401
  return results;
232
402
  }
233
403
  else {
@@ -456,7 +626,7 @@ class TableAccessor {
456
626
  const mergedStrategy = options.collectionStrategy ?? this.collectionStrategy;
457
627
  // Create new executor if logging options are provided
458
628
  let newExecutor = this.executor;
459
- if (options.logQueries || options.logExecutionTime) {
629
+ if (options.logQueries || options.logExecutionTime || options.onQueryTakingTooLong) {
460
630
  newExecutor = new QueryExecutor(this.client, {
461
631
  ...options,
462
632
  collectionStrategy: mergedStrategy,
@@ -465,6 +635,33 @@ class TableAccessor {
465
635
  // Return new instance with updated options
466
636
  return new TableAccessor(this.tableBuilder, this.client, this.schemaRegistry, newExecutor, mergedStrategy);
467
637
  }
638
+ /**
639
+ * Set a per-query timeout (ms) applied to every query and CRUD operation
640
+ * started from the returned accessor. Each such query is wrapped individually
641
+ * (`SET LOCAL statement_timeout`); pass `0` to disable. Overrides the
642
+ * connection-level default. On timeout a `QueryTimeoutError` is thrown.
643
+ *
644
+ * @example
645
+ * await db.users.withTimeout(5000).where(u => gt(u.id, 0)).toList();
646
+ */
647
+ withTimeout(timeoutMs) {
648
+ const newExecutor = this.executor
649
+ ? this.executor.withTimeout(timeoutMs)
650
+ : new QueryExecutor(this.client, undefined, timeoutMs);
651
+ return new TableAccessor(this.tableBuilder, this.client, this.schemaRegistry, newExecutor, this.collectionStrategy);
652
+ }
653
+ /**
654
+ * Mark queries started from the returned accessor as expected to finish within
655
+ * `expectedMs` (ms). If a query runs longer, the context's
656
+ * `onQueryTakingTooLong` callback fires — the query is NOT cancelled (use
657
+ * `.withTimeout()` for that). Overrides the context's `longRunningQueryThreshold`.
658
+ */
659
+ expectedExecutionTime(expectedMs) {
660
+ const newExecutor = this.executor
661
+ ? this.executor.withExpectedExecutionTime(expectedMs)
662
+ : new QueryExecutor(this.client, undefined, undefined, expectedMs);
663
+ return new TableAccessor(this.tableBuilder, this.client, this.schemaRegistry, newExecutor, this.collectionStrategy);
664
+ }
468
665
  /**
469
666
  * Start a select query with automatic type inference
470
667
  * UnwrapSelection extracts the value types from SqlFragment<T> expressions
@@ -776,7 +973,7 @@ class DataContext {
776
973
  this.client = client;
777
974
  this.queryOptions = queryOptions;
778
975
  // Create executor if logging is enabled
779
- if (queryOptions?.logQueries || queryOptions?.logExecutionTime) {
976
+ if (queryOptions?.logQueries || queryOptions?.logExecutionTime || queryOptions?.onQueryTakingTooLong) {
780
977
  this.executor = new QueryExecutor(client, queryOptions);
781
978
  }
782
979
  this.initializeSchema(schema);
@@ -853,12 +1050,26 @@ class DataContext {
853
1050
  * Creates a scoped transactional context to avoid race conditions with concurrent transactions.
854
1051
  * Each transaction gets its own isolated context instance with fresh table accessors.
855
1052
  */
856
- async transaction(fn) {
1053
+ async transaction(fn, options) {
857
1054
  return await this.client.transaction(async (queryFn) => {
858
1055
  // Create a transactional client that routes all queries through the transaction
859
1056
  const txClient = new database_client_interface_1.TransactionalClient(queryFn, this.client);
1057
+ // Raise the per-statement timeout for the WHOLE transaction up-front. `SET LOCAL`
1058
+ // is transaction-scoped (auto-resets at COMMIT/ROLLBACK) and applies to every
1059
+ // subsequent statement — including bulk inserts/upserts that don't expose a
1060
+ // per-query `.withTimeout()`. Clamp to a safe non-negative integer (it is
1061
+ // inlined into the SQL); `0` disables the timeout for the transaction.
1062
+ if (options?.timeoutMs !== undefined) {
1063
+ const ms = Math.max(0, Math.floor(Number(options.timeoutMs) || 0));
1064
+ await txClient.query(`SET LOCAL statement_timeout = ${ms}`);
1065
+ }
1066
+ // Within the transaction, raise the slow-query "expected" threshold so a
1067
+ // deliberately long unit of work doesn't trip the global threshold. Defaults
1068
+ // to the transaction timeout when not given explicitly.
1069
+ const expectedMs = options?.expectedExecutionMs ?? options?.timeoutMs;
1070
+ const optionsOverride = expectedMs !== undefined ? { longRunningQueryThreshold: expectedMs } : undefined;
860
1071
  // Create an isolated transactional context instead of mutating this.client
861
- const txContext = this.createTransactionalContext(txClient);
1072
+ const txContext = this.createTransactionalContext(txClient, optionsOverride);
862
1073
  return await fn(txContext);
863
1074
  });
864
1075
  }
@@ -867,22 +1078,27 @@ class DataContext {
867
1078
  * Used internally for transaction isolation to prevent race conditions
868
1079
  * when multiple transactions run concurrently.
869
1080
  */
870
- createTransactionalContext(txClient) {
1081
+ createTransactionalContext(txClient, queryOptionsOverride) {
871
1082
  // Create new instance preserving the prototype chain (including subclass methods/getters)
872
1083
  const txContext = Object.create(Object.getPrototypeOf(this));
873
1084
  // Set up the transactional client
874
1085
  txContext.client = txClient;
875
1086
  // Share read-only schema registry
876
1087
  txContext.schemaRegistry = this.schemaRegistry;
877
- txContext.queryOptions = this.queryOptions;
1088
+ // Merge any per-transaction option override (e.g. a raised slow-query threshold)
1089
+ // over the context's base options so the transaction's executor + accessors use it.
1090
+ const effectiveOptions = queryOptionsOverride
1091
+ ? { ...(this.queryOptions ?? {}), ...queryOptionsOverride }
1092
+ : this.queryOptions;
1093
+ txContext.queryOptions = effectiveOptions;
878
1094
  // Create executor for the transactional client if logging is enabled
879
- if (this.queryOptions?.logQueries || this.queryOptions?.logExecutionTime) {
880
- txContext.executor = new QueryExecutor(txClient, this.queryOptions);
1095
+ if (effectiveOptions?.logQueries || effectiveOptions?.logExecutionTime || effectiveOptions?.onQueryTakingTooLong) {
1096
+ txContext.executor = new QueryExecutor(txClient, effectiveOptions);
881
1097
  }
882
1098
  // Create fresh table accessors bound to the transactional client
883
1099
  txContext.tableAccessors = new Map();
884
1100
  for (const [key, accessor] of this.tableAccessors) {
885
- const newAccessor = new TableAccessor(accessor.tableBuilder, txClient, this.schemaRegistry, txContext.executor, this.queryOptions?.collectionStrategy);
1101
+ const newAccessor = new TableAccessor(accessor.tableBuilder, txClient, this.schemaRegistry, txContext.executor, effectiveOptions?.collectionStrategy);
886
1102
  txContext.tableAccessors.set(key, newAccessor);
887
1103
  // Only attach as direct property if not a getter on the prototype
888
1104
  // (DatabaseContext subclasses use getters like `get users()` that call this.table())
@@ -1219,7 +1435,7 @@ class DbEntityTable {
1219
1435
  const mergedOptions = { ...originalOptions, ...options };
1220
1436
  // Create new executor if logging options are provided
1221
1437
  let newExecutor = originalContext.executor;
1222
- if (mergedOptions.logQueries || mergedOptions.logExecutionTime) {
1438
+ if (mergedOptions.logQueries || mergedOptions.logExecutionTime || mergedOptions.onQueryTakingTooLong) {
1223
1439
  newExecutor = new QueryExecutor(originalContext.client, mergedOptions);
1224
1440
  }
1225
1441
  // Create a proxy context that overrides queryOptions, executor, and getTable
@@ -1255,6 +1471,66 @@ class DbEntityTable {
1255
1471
  // Return new instance with proxy context
1256
1472
  return new DbEntityTable(proxyContext, this.tableName, this.tableBuilder);
1257
1473
  }
1474
+ /**
1475
+ * Set a per-query timeout (ms) applied to every query and CRUD operation
1476
+ * started from the returned table. Each such query is wrapped individually
1477
+ * (`SET LOCAL statement_timeout`); pass `0` to disable. Overrides the
1478
+ * connection-level default. On timeout a `QueryTimeoutError` is thrown.
1479
+ *
1480
+ * @example
1481
+ * await db.users.withTimeout(5000).where(u => gt(u.id, 0)).toList();
1482
+ */
1483
+ withTimeout(timeoutMs) {
1484
+ return this._deriveWithExecutor((current, client) => current ? current.withTimeout(timeoutMs) : new QueryExecutor(client, undefined, timeoutMs));
1485
+ }
1486
+ /**
1487
+ * Mark queries started from the returned table as expected to finish within
1488
+ * `expectedMs` (ms). If a query runs longer, the context's
1489
+ * `onQueryTakingTooLong` callback fires — the query is NOT cancelled (use
1490
+ * `.withTimeout()` for that). Overrides the context's `longRunningQueryThreshold`.
1491
+ *
1492
+ * @example
1493
+ * await db.users.expectedExecutionTime(2000).where(u => gt(u.id, 0)).toList();
1494
+ */
1495
+ expectedExecutionTime(expectedMs) {
1496
+ return this._deriveWithExecutor((current, client) => current ? current.withExpectedExecutionTime(expectedMs) : new QueryExecutor(client, undefined, undefined, expectedMs));
1497
+ }
1498
+ /**
1499
+ * Build a derived table whose context surfaces a transformed executor (via a
1500
+ * proxy context, threaded into this table's accessor). Shared by
1501
+ * `.withTimeout()` and `.expectedExecutionTime()`.
1502
+ * @internal
1503
+ */
1504
+ _deriveWithExecutor(makeExecutor) {
1505
+ const originalContext = this.context;
1506
+ const tableName = this.tableName;
1507
+ const client = originalContext.client;
1508
+ const newExecutor = makeExecutor(originalContext.executor, client);
1509
+ const collectionStrategy = originalContext.queryOptions?.collectionStrategy;
1510
+ const proxyContext = new Proxy(originalContext, {
1511
+ get(target, prop) {
1512
+ if (prop === 'executor') {
1513
+ return newExecutor;
1514
+ }
1515
+ if (prop === 'getTable') {
1516
+ return (name) => {
1517
+ const originalAccessor = target.tableAccessors.get(name);
1518
+ if (!originalAccessor) {
1519
+ return target.getTable(name);
1520
+ }
1521
+ if (name === tableName) {
1522
+ const schemaRegistry = target.schemaRegistry;
1523
+ const originalTableBuilder = originalAccessor.tableBuilder;
1524
+ return new TableAccessor(originalTableBuilder, client, schemaRegistry, newExecutor, collectionStrategy);
1525
+ }
1526
+ return originalAccessor;
1527
+ };
1528
+ }
1529
+ return target[prop];
1530
+ }
1531
+ });
1532
+ return new DbEntityTable(proxyContext, this.tableName, this.tableBuilder);
1533
+ }
1258
1534
  /**
1259
1535
  * Select all records - returns full entities with unwrapped DbColumns
1260
1536
  */