@feasibleone/blong-gogo 1.28.0 → 1.30.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 CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.30.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.29.0...blong-gogo-v1.30.0) (2026-08-21)
4
+
5
+
6
+ ### Features
7
+
8
+ * access sessions and audit ([694349f](https://github.com/feasibleone/blong/commit/694349f7e70973b984a30f696d2b6e892b55ec02))
9
+ * improve MySQL handling in CI ([4770eec](https://github.com/feasibleone/blong/commit/4770eec3a24cd38cd1931da1c553e0a804ca58dd))
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * api gateway wiring ([cbdb743](https://github.com/feasibleone/blong/commit/cbdb7439a997925ca6a4ba07e046ad2809d37218))
15
+ * blong-access-mock, silent error, expected error ([dc00429](https://github.com/feasibleone/blong/commit/dc00429a63a973996d6e94359830cc52d149b995))
16
+ * build ([fc98bed](https://github.com/feasibleone/blong/commit/fc98beda0e7ba800682bd663eeebd4408ce04bf1))
17
+
18
+ ## [1.29.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.28.0...blong-gogo-v1.29.0) (2026-08-19)
19
+
20
+
21
+ ### Features
22
+
23
+ * blong-access UI ([999de20](https://github.com/feasibleone/blong/commit/999de20b5f8e36979787bed71695de45a4006f5f))
24
+
3
25
  ## [1.28.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.27.0...blong-gogo-v1.28.0) (2026-08-19)
4
26
 
5
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feasibleone/blong-gogo",
3
- "version": "1.28.0",
3
+ "version": "1.30.0",
4
4
  "repository": {
5
5
  "url": "git+https://github.com/feasibleone/blong.git"
6
6
  },
@@ -87,6 +87,7 @@
87
87
  "playwright": "^1.60.0",
88
88
  "tap": "^21.6.3",
89
89
  "typescript": "^6.0.3",
90
+ "vite": "^8.2.1",
90
91
  "@feasibleone/blong-dev": "1.2.0"
91
92
  },
92
93
  "scripts": {
@@ -183,6 +183,7 @@ export class AdapterBase<T, C extends IContext> implements AdapterHandlerContext
183
183
 
184
184
  error(error: ITypedError, $meta: IMeta): ITypedError {
185
185
  if ($meta) error.method = $meta.method;
186
+ if (error.silent) return error;
186
187
  if (isExpectedError(error.type, $meta?.expect)) {
187
188
  (this.log as {debug?: (...args: unknown[]) => void})?.debug?.(error);
188
189
  return error;
@@ -195,14 +196,22 @@ export class AdapterBase<T, C extends IContext> implements AdapterHandlerContext
195
196
  return null;
196
197
  }
197
198
 
199
+ namespaces(): (string | RegExp)[] {
200
+ const namespace = this.config.namespace;
201
+ return ([] as (string | RegExp)[]).concat(
202
+ namespace
203
+ ? Array.isArray(namespace) || typeof namespace === 'string'
204
+ ? namespace
205
+ : Object.keys(namespace)
206
+ : this.config.imports || this.config.id.replace(/\./g, '-'),
207
+ );
208
+ }
209
+
198
210
  handles(name: string): boolean {
199
211
  if (reserved.includes(name)) return true;
200
- const id = this.config.id.replace(/\./g, '-');
201
- return ([] as (string | RegExp)[])
202
- .concat(this.config.namespace || this.config.imports || id)
203
- .some(namespace =>
204
- typeof namespace === 'string' ? name.startsWith(namespace) : namespace.test(name),
205
- );
212
+ return this.namespaces().some(namespace =>
213
+ typeof namespace === 'string' ? name.startsWith(namespace) : namespace.test(name),
214
+ );
206
215
  }
207
216
 
208
217
  methodPath(methodName: string): string {
@@ -297,10 +306,7 @@ export class AdapterBase<T, C extends IContext> implements AdapterHandlerContext
297
306
  }
298
307
 
299
308
  forNamespaces<R>(reducer: (prev: R, current: unknown) => R, initial: R): R {
300
- const id = this.config.id.replace(/\./g, '-');
301
- return ([] as (string | RegExp)[])
302
- .concat(this.config.namespace || this.config.imports || id)
303
- .reduce(reducer.bind(this), initial);
309
+ return this.namespaces().reduce(reducer.bind(this), initial);
304
310
  }
305
311
 
306
312
  async start(): Promise<unknown> {
@@ -390,12 +396,34 @@ export default async function adapter<T, C extends IContext>(
390
396
 
391
397
  const base = new AdapterBase<T, C>(api, configBase, activationNames);
392
398
 
393
- const result = handlers!({utError, remote, type, schema: registry.objectSchema, manifest: api.manifest});
399
+ const result = handlers!({
400
+ utError,
401
+ remote,
402
+ type,
403
+ schema: registry.objectSchema,
404
+ manifest: api.manifest,
405
+ });
394
406
  let current = result;
395
407
  while (current.extends) {
396
408
  const parent = await (typeof current.extends === 'string'
397
- ? adapterFactory(current.extends)!({utError, remote, rpc, local, registry, schema, manifest: api.manifest})
398
- : current.extends({utError, remote, rpc, local, registry, schema, manifest: api.manifest}));
409
+ ? adapterFactory(current.extends)!({
410
+ utError,
411
+ remote,
412
+ rpc,
413
+ local,
414
+ registry,
415
+ schema,
416
+ manifest: api.manifest,
417
+ })
418
+ : current.extends({
419
+ utError,
420
+ remote,
421
+ rpc,
422
+ local,
423
+ registry,
424
+ schema,
425
+ manifest: api.manifest,
426
+ }));
399
427
  Object.setPrototypeOf(current, parent);
400
428
  current = parent;
401
429
  }
package/src/Gateway.ts CHANGED
@@ -78,6 +78,17 @@ interface IConfig extends IConfigMLE {
78
78
  * then enforces that every request's methodId is in the allowed list.
79
79
  */
80
80
  authorize?: string;
81
+ /**
82
+ * Optional access-check audit. When set, the gateway records every
83
+ * authorization decision (allow/deny) at the access-check point into the
84
+ * configured handler, plus sanitised DML context for access.* write
85
+ * methods. `exclude` lists methodId patterns (exact or `prefix*`) that
86
+ * must not be audited. Best-effort — audit failures never block requests.
87
+ */
88
+ audit?: {
89
+ handler: string;
90
+ exclude?: string[];
91
+ };
81
92
  errorFields: unknown[];
82
93
  jwt: {
83
94
  cache: object;
@@ -171,6 +182,7 @@ export default class Gateway extends Internal implements IGateway {
171
182
  debug: false,
172
183
  expectedErrors: false,
173
184
  authorize: undefined,
185
+ audit: undefined,
174
186
  errorFields: [],
175
187
  jwt: {
176
188
  cache: {},
@@ -369,6 +381,8 @@ export default class Gateway extends Internal implements IGateway {
369
381
  ...(value.bundle !== undefined && {bundle: value.bundle}),
370
382
  ...(value.creditCost !== undefined && {creditCost: value.creditCost}),
371
383
  ...(value.meter !== undefined && {meter: value.meter}),
384
+ ...(value.audit !== undefined && {audit: value.audit}),
385
+ ...(value.skipAuthorize && {skipAuthorize: true}),
372
386
  },
373
387
  schema: Type && {
374
388
  ...('body' in value
@@ -530,18 +544,23 @@ export default class Gateway extends Internal implements IGateway {
530
544
  httpResponse?: unknown;
531
545
  [key: string]: unknown;
532
546
  };
533
- if (
534
- isExpectedError(typedError.type as string | undefined, resolvedExpect)
535
- ) {
536
- request.log.debug(
537
- {err: error, method: methodName},
538
- 'gateway expected error',
539
- );
540
- } else {
541
- request.log.error(
542
- {err: error, method: methodName},
543
- 'gateway handler error',
544
- );
547
+ if (!typedError.silent) {
548
+ if (
549
+ isExpectedError(
550
+ typedError.type as string | undefined,
551
+ resolvedExpect,
552
+ )
553
+ ) {
554
+ request.log.debug?.(
555
+ {err: error, method: methodName},
556
+ 'gateway expected error',
557
+ );
558
+ } else {
559
+ request.log.error?.(
560
+ {err: error, method: methodName},
561
+ 'gateway handler error',
562
+ );
563
+ }
545
564
  }
546
565
  this._applyMeta(
547
566
  reply
@@ -614,6 +633,7 @@ export default class Gateway extends Internal implements IGateway {
614
633
  errors: this.#errors,
615
634
  audience: this.#config.jwt.audience,
616
635
  authorize: this.#config.authorize,
636
+ audit: this.#config.audit,
617
637
  local: this.#local,
618
638
  methodId: methodId,
619
639
  methodParts: methodParts,
@@ -1,3 +1,4 @@
1
+ /// <reference types="vite/client" />
1
2
  import {realm} from '@feasibleone/blong/types';
2
3
 
3
4
  export default realm(blong => ({
@@ -11,9 +11,11 @@ import {test} from 'tap';
11
11
 
12
12
  import {
13
13
  isDeadlock,
14
+ isRetryableConnectionError,
14
15
  parseJsonResult,
15
16
  parseJsonRow,
16
17
  stringifyJsonValues,
18
+ withConnectionRetry,
17
19
  wrapJsonBuilder,
18
20
  wrapKnex,
19
21
  } from './json.ts';
@@ -216,6 +218,173 @@ test('wrapKnex transaction() wraps the resolved trx (promise form)', async t =>
216
218
  t.end();
217
219
  });
218
220
 
221
+ const CONN_LOST = {
222
+ code: 'PROTOCOL_CONNECTION_LOST',
223
+ fatal: true,
224
+ sql: 'UPDATE `t` SET `x` = 1',
225
+ sqlMessage: 'Connection lost: The server closed the connection.',
226
+ message: 'Connection lost: The server closed the connection.',
227
+ };
228
+
229
+ const DUP_ENTRY = {errno: 1062, code: 'ER_DUP_ENTRY', message: 'Duplicate entry'};
230
+
231
+ const BOOM = new Error('boom');
232
+
233
+ test('isRetryableConnectionError detects transient connection errors only', t => {
234
+ t.equal(
235
+ isRetryableConnectionError({code: 'PROTOCOL_CONNECTION_LOST', fatal: true}),
236
+ true,
237
+ 'PROTOCOL_CONNECTION_LOST with fatal flag',
238
+ );
239
+ t.equal(isRetryableConnectionError({fatal: true}), true, 'fatal flag alone');
240
+ t.equal(isRetryableConnectionError({code: 'ECONNRESET'}), true, 'ECONNRESET');
241
+ t.equal(isRetryableConnectionError({code: 'ER_CON_COUNT_ERROR'}), true, 'too many connections');
242
+ t.equal(
243
+ isRetryableConnectionError({code: 'ER_LOCK_DEADLOCK', errno: 1213}),
244
+ false,
245
+ 'deadlock is not a connection error',
246
+ );
247
+ t.equal(isRetryableConnectionError(DUP_ENTRY), false, 'constraint violation');
248
+ t.equal(isRetryableConnectionError(BOOM), false, 'plain error');
249
+ t.equal(isRetryableConnectionError(null), false, 'null');
250
+ t.equal(isRetryableConnectionError('PROTOCOL_CONNECTION_LOST'), false, 'string');
251
+ t.end();
252
+ });
253
+
254
+ test('withConnectionRetry runs once when retry is disabled (default)', async t => {
255
+ let calls = 0;
256
+ const run = async () => {
257
+ calls += 1;
258
+ throw CONN_LOST;
259
+ };
260
+ await t.rejects(withConnectionRetry(run, {}), CONN_LOST, 'error propagates');
261
+ t.equal(calls, 1, 'exactly one attempt');
262
+ t.end();
263
+ });
264
+
265
+ test('withConnectionRetry retries transient connection errors and recovers', async t => {
266
+ let calls = 0;
267
+ const run = async () => {
268
+ calls += 1;
269
+ if (calls < 3) throw CONN_LOST;
270
+ return 'ok';
271
+ };
272
+ const result = await withConnectionRetry(run, {
273
+ retry: {enabled: true, maxRetries: 3, backoffMs: 1},
274
+ });
275
+ t.equal(result, 'ok');
276
+ t.equal(calls, 3, 'first attempt + two retries');
277
+ t.end();
278
+ });
279
+
280
+ test('withConnectionRetry does not retry non-connection errors', async t => {
281
+ let calls = 0;
282
+ const run = async () => {
283
+ calls += 1;
284
+ throw DUP_ENTRY;
285
+ };
286
+ await t.rejects(
287
+ withConnectionRetry(run, {retry: {enabled: true, maxRetries: 3, backoffMs: 1}}),
288
+ DUP_ENTRY,
289
+ );
290
+ t.equal(calls, 1, 'constraint violations are not retried');
291
+ t.end();
292
+ });
293
+
294
+ test('withConnectionRetry gives up after maxRetries and surfaces the error', async t => {
295
+ let calls = 0;
296
+ const run = async () => {
297
+ calls += 1;
298
+ throw CONN_LOST;
299
+ };
300
+ await t.rejects(
301
+ withConnectionRetry(run, {retry: {enabled: true, maxRetries: 2, backoffMs: 1}}),
302
+ CONN_LOST,
303
+ 'final error surfaces',
304
+ );
305
+ t.equal(calls, 3, 'first attempt + maxRetries retries');
306
+ t.end();
307
+ });
308
+
309
+ test('wrapJsonBuilder recovers a transient connection error via clone re-execution', async t => {
310
+ const builder = makeFlakyBuilder(2, CONN_LOST);
311
+ const wrapped = wrapJsonBuilder(builder, {
312
+ retry: {enabled: true, maxRetries: 5, backoffMs: 1},
313
+ });
314
+
315
+ const result = await wrapped.then((value: unknown) => value);
316
+ t.same(result, {ok: 3}, 'query succeeds after two dropped connections');
317
+ t.equal(builder.attempts(), 3, 'original + two clone re-executions');
318
+ t.end();
319
+ });
320
+
321
+ test('wrapJsonBuilder reports connection errors via onConnectionError', async t => {
322
+ const reported: unknown[] = [];
323
+ const builder = makeFlakyBuilder(1, CONN_LOST);
324
+ const wrapped = wrapJsonBuilder(builder, {
325
+ retry: {enabled: true, maxRetries: 3, backoffMs: 1},
326
+ onConnectionError: error => reported.push(error),
327
+ });
328
+
329
+ const result = await wrapped.then((value: unknown) => value);
330
+ t.same(result, {ok: 2}, 'recovers');
331
+ t.same(reported, [CONN_LOST], 'the dropped connection was reported');
332
+ t.end();
333
+ });
334
+
335
+ test('wrapJsonBuilder does not retry when retry is disabled', async t => {
336
+ const reported: unknown[] = [];
337
+ const builder = makeFlakyBuilder(1, CONN_LOST);
338
+ const wrapped = wrapJsonBuilder(builder, {
339
+ onConnectionError: error => reported.push(error),
340
+ });
341
+
342
+ await t.rejects(Promise.resolve(wrapped.then(null, null)), CONN_LOST, 'error propagates');
343
+ t.equal(builder.attempts(), 1, 'exactly one attempt');
344
+ t.same(reported, [CONN_LOST], 'connection error still reported for diagnostics');
345
+ t.end();
346
+ });
347
+
348
+ test('wrapJsonBuilder never retries non-connection errors', async t => {
349
+ const builder = makeFlakyBuilder(1, BOOM);
350
+ const wrapped = wrapJsonBuilder(builder, {
351
+ retry: {enabled: true, maxRetries: 3, backoffMs: 1},
352
+ });
353
+
354
+ await t.rejects(Promise.resolve(wrapped.then(null, null)), /boom/, 'error propagates');
355
+ t.equal(builder.attempts(), 1, 'plain errors are not retried');
356
+ t.end();
357
+ });
358
+
359
+ test('wrapKnex raw() retries transient connection errors by re-invoking raw', async t => {
360
+ let calls = 0;
361
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
362
+ const fakeKnex = (() => undefined) as any;
363
+ fakeKnex.raw = () => {
364
+ calls += 1;
365
+ const ok = calls > 1;
366
+ return {
367
+ then(
368
+ onFulfilled?: (value: unknown) => unknown,
369
+ onRejected?: (reason: unknown) => unknown,
370
+ ) {
371
+ return ok
372
+ ? Promise.resolve({rows: [{n: calls}]}).then(onFulfilled, onRejected)
373
+ : Promise.reject(CONN_LOST).then(onFulfilled, onRejected);
374
+ },
375
+ };
376
+ };
377
+ const wrapped = wrapKnex(fakeKnex, {
378
+ retry: {enabled: true, maxRetries: 3, backoffMs: 1},
379
+ });
380
+
381
+ const raw = wrapped.raw('CALL something()');
382
+ const result = await Promise.resolve(raw.then((value: unknown) => value));
383
+ t.same(result, {rows: [{n: 2}]}, 'raw re-invoked on retry');
384
+ t.equal(calls, 2, 'knex.raw called twice');
385
+ t.end();
386
+ });
387
+
219
388
  /** A minimal knex-like builder whose `then` rejects with `error`. */
220
389
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
221
390
  function makeRejectingBuilder(error: unknown): any {
@@ -241,3 +410,32 @@ function makeRejectingThenable(error: unknown): any {
241
410
  },
242
411
  };
243
412
  }
413
+
414
+ /**
415
+ * A knex-like builder whose first `failures` `then` calls reject with `error`
416
+ * and whose later calls resolve. `clone()` returns a fresh builder sharing the
417
+ * same attempt counter, mirroring how the retry path re-runs a cloned builder.
418
+ */
419
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
420
+ function makeFlakyBuilder(failures: number, error: unknown): any {
421
+ let attempts = 0;
422
+ const make = () => ({
423
+ insert(..._args: unknown[]) {
424
+ return this;
425
+ },
426
+ update(..._args: unknown[]) {
427
+ return this;
428
+ },
429
+ then(onFulfilled?: (value: unknown) => unknown, onRejected?: (reason: unknown) => unknown) {
430
+ attempts += 1;
431
+ return attempts <= failures
432
+ ? Promise.reject(error).then(onFulfilled, onRejected)
433
+ : Promise.resolve({ok: attempts}).then(onFulfilled, onRejected);
434
+ },
435
+ clone() {
436
+ return make();
437
+ },
438
+ attempts: () => attempts,
439
+ });
440
+ return make();
441
+ }
@@ -17,6 +17,8 @@
17
17
  * `access_credential.credentialParamsJSON`.
18
18
  */
19
19
 
20
+ import {type IKnexRetryOptions} from './types.ts';
21
+
20
22
  /** Column names ending in `JSON` are treated as JSON documents. */
21
23
  const JSON_COLUMN = /JSON$/;
22
24
 
@@ -36,11 +38,78 @@ export function isDeadlock(error: unknown): boolean {
36
38
  }
37
39
 
38
40
  /**
39
- * Optional hooks applied while wrapping the shared knex instance.
41
+ * Error codes that indicate a *transient* MySQL connection failure worth
42
+ * retrying — the server closed the socket, the pool hit the server's
43
+ * `max_connections`, or the TCP link was reset. `fatal: true` is the generic
44
+ * marker mysql2 sets for errors that invalidate the connection (it is set on
45
+ * `PROTOCOL_CONNECTION_LOST`, the error observed intermittently in CI).
46
+ */
47
+ const RETRYABLE_CONNECTION_CODES = new Set([
48
+ 'PROTOCOL_CONNECTION_LOST',
49
+ 'PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR',
50
+ 'ECONNRESET',
51
+ 'ETIMEDOUT',
52
+ 'ER_CON_COUNT_ERROR', // Too many connections (server max_connections)
53
+ 'ER_SERVER_SHUTDOWN', // Server shutting down
54
+ 'ER_ABORTING_CONNECTION',
55
+ ]);
56
+
57
+ /**
58
+ * Whether a query error is a transient connection failure the adapter may
59
+ * retry. Only connection-level failures qualify — deadlocks, constraint
60
+ * violations and ordinary SQL errors are never retried here.
61
+ */
62
+ export function isRetryableConnectionError(error: unknown): boolean {
63
+ if (!error || typeof error !== 'object') return false;
64
+ const {code, fatal} = error as {code?: unknown; fatal?: unknown};
65
+ return fatal === true || (typeof code === 'string' && RETRYABLE_CONNECTION_CODES.has(code));
66
+ }
67
+
68
+ /**
69
+ * Optional hooks and behaviour applied while wrapping the shared knex instance.
40
70
  */
41
71
  export interface WrapKnexOptions {
42
72
  /** Invoked when a query rejects with a deadlock error, before the caller's rejection handler. */
43
73
  onDeadlock?: (error: unknown) => void;
74
+ /**
75
+ * Invoked when a query rejects with a transient connection error (before the
76
+ * caller's rejection handler). Fired on every failed attempt — including when
77
+ * retry is disabled — so connection drops can be logged for diagnostics.
78
+ */
79
+ onConnectionError?: (error: unknown) => void;
80
+ /**
81
+ * Opt-in retry of transient connection errors. Non-prod only — disabled by
82
+ * default; enable via the `knex.retry` config block (see `srv.db`).
83
+ */
84
+ retry?: IKnexRetryOptions;
85
+ }
86
+
87
+ /**
88
+ * Run `run()` once; if it rejects with a transient connection error and retry
89
+ * is enabled, re-run it (up to `maxRetries`) with linear backoff
90
+ * (`backoffMs * attempt`). Between attempts the knex pool re-establishes a
91
+ * healthy connection, so a query that hit a dead socket usually succeeds on the
92
+ * next attempt. When retry is disabled (default) the query runs exactly once.
93
+ */
94
+ export async function withConnectionRetry<T>(
95
+ run: () => Promise<T>,
96
+ options: WrapKnexOptions,
97
+ ): Promise<T> {
98
+ const retry = options.retry;
99
+ if (!retry?.enabled) return run();
100
+ const maxRetries = retry.maxRetries ?? 3;
101
+ const backoffMs = retry.backoffMs ?? 250;
102
+ let attempt = 0;
103
+ for (;;) {
104
+ try {
105
+ return await run();
106
+ } catch (error) {
107
+ if (!isRetryableConnectionError(error)) throw error;
108
+ if (attempt >= maxRetries) throw error;
109
+ attempt += 1;
110
+ await new Promise(resolve => setTimeout(resolve, backoffMs * attempt));
111
+ }
112
+ }
44
113
  }
45
114
 
46
115
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -96,7 +165,8 @@ export function parseJsonResult(result: unknown): unknown {
96
165
  * Only `insert`, `update`, and `then` are intercepted — all other builder
97
166
  * behaviour is untouched. Chained calls keep working because knex builder
98
167
  * methods return the same instance. The `then` rejection path is also
99
- * intercepted to detect MySQL deadlocks and report them via `options.onDeadlock`.
168
+ * intercepted to detect MySQL deadlocks and report them via `options.onDeadlock`,
169
+ * and to retry transient connection errors via `options.retry`.
100
170
  */
101
171
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
102
172
  export function wrapJsonBuilder(builder: any, options: WrapKnexOptions = {}): any {
@@ -123,40 +193,83 @@ export function wrapJsonBuilder(builder: any, options: WrapKnexOptions = {}): an
123
193
  builder.then = (
124
194
  onFulfilled?: ((value: unknown) => unknown) | null,
125
195
  onRejected?: ((reason: unknown) => unknown) | null,
126
- ) =>
127
- originalThen(
128
- (result: unknown) => {
129
- const processed = parseJsonResult(result);
130
- return typeof onFulfilled === 'function' ? onFulfilled(processed) : processed;
131
- },
132
- (reason: unknown) => {
133
- if (isDeadlock(reason)) options.onDeadlock?.(reason);
134
- if (typeof onRejected === 'function') return onRejected(reason);
135
- throw reason;
136
- },
137
- );
196
+ ) => {
197
+ // Each attempt runs the query again. The first attempt executes the
198
+ // original builder via its captured `then`; retries re-run a fresh
199
+ // clone (knex builders are single-shot) whose `then` is the original
200
+ // prototype method re-acquiring a healthy connection from the pool.
201
+ let attempt = 0;
202
+ const execute = () => {
203
+ const cloneable = typeof builder.clone === 'function';
204
+ const target = attempt === 0 || !cloneable ? null : builder.clone();
205
+ const thenFn = target ? target.then.bind(target) : originalThen;
206
+ attempt += 1;
207
+ return new Promise<unknown>((resolve, reject) => {
208
+ thenFn(
209
+ (result: unknown) => {
210
+ const processed = parseJsonResult(result);
211
+ resolve(
212
+ typeof onFulfilled === 'function' ? onFulfilled(processed) : processed,
213
+ );
214
+ },
215
+ (reason: unknown) => {
216
+ if (isDeadlock(reason)) options.onDeadlock?.(reason);
217
+ if (isRetryableConnectionError(reason)) options.onConnectionError?.(reason);
218
+ reject(reason);
219
+ },
220
+ );
221
+ });
222
+ };
223
+ return withConnectionRetry(execute, options).then(undefined, (reason: unknown) => {
224
+ if (typeof onRejected === 'function') return onRejected(reason);
225
+ throw reason;
226
+ });
227
+ };
138
228
 
139
229
  return builder;
140
230
  }
141
231
 
142
232
  /**
143
233
  * Intercept the `then` rejection path of a thenable (e.g. a knex `Raw` result)
144
- * so MySQL deadlocks are reported via `options.onDeadlock`. Mirrors the
145
- * rejection interception in {@link wrapJsonBuilder} without the JSON column
146
- * handling, which does not apply to `raw` queries / stored-procedure calls.
234
+ * so MySQL deadlocks are reported via `options.onDeadlock` and transient
235
+ * connection errors are reported via `options.onConnectionError` / retried via
236
+ * `options.retry`. `rerun()` re-creates the query (e.g. re-invoking
237
+ * `knex.raw(...)` with the same args) so a connection failure can be retried.
238
+ * Mirrors the rejection interception in {@link wrapJsonBuilder} without the JSON
239
+ * column handling, which does not apply to `raw` queries / stored-procedure calls.
147
240
  */
148
241
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
149
- function attachDeadlockHook(thenable: any, options: WrapKnexOptions): any {
242
+ function attachQueryHook(thenable: any, options: WrapKnexOptions, rerun: () => any): any {
150
243
  const originalThen = thenable.then.bind(thenable);
151
244
  thenable.then = (
152
245
  onFulfilled?: ((value: unknown) => unknown) | null,
153
246
  onRejected?: ((reason: unknown) => unknown) | null,
154
- ) =>
155
- originalThen(onFulfilled, (reason: unknown) => {
156
- if (isDeadlock(reason)) options.onDeadlock?.(reason);
247
+ ) => {
248
+ // The first attempt runs the original thenable via its captured `then`;
249
+ // retries re-invoke `rerun()` to obtain a fresh thenable backed by a new
250
+ // query (and, for raw, a freshly acquired pool connection).
251
+ let attempt = 0;
252
+ const execute = () => {
253
+ const target = attempt === 0 ? null : rerun();
254
+ const thenFn = target ? target.then.bind(target) : originalThen;
255
+ attempt += 1;
256
+ return new Promise<unknown>((resolve, reject) => {
257
+ thenFn(
258
+ (result: unknown) =>
259
+ resolve(typeof onFulfilled === 'function' ? onFulfilled(result) : result),
260
+ (reason: unknown) => {
261
+ if (isDeadlock(reason)) options.onDeadlock?.(reason);
262
+ if (isRetryableConnectionError(reason)) options.onConnectionError?.(reason);
263
+ reject(reason);
264
+ },
265
+ );
266
+ });
267
+ };
268
+ return withConnectionRetry(execute, options).then(undefined, (reason: unknown) => {
157
269
  if (typeof onRejected === 'function') return onRejected(reason);
158
270
  throw reason;
159
271
  });
272
+ };
160
273
  return thenable;
161
274
  }
162
275
 
@@ -186,7 +299,8 @@ export function wrapKnex<T>(knex: T, options: WrapKnexOptions = {}): T {
186
299
  const original = Reflect.get(target, prop, target);
187
300
  if (typeof original !== 'function') return original;
188
301
  const bound = original.bind(target);
189
- return (...args: unknown[]) => attachDeadlockHook(bound(...args), options);
302
+ return (...args: unknown[]) =>
303
+ attachQueryHook(bound(...args), options, () => bound(...args));
190
304
  }
191
305
  if (prop === 'transaction') {
192
306
  const original = Reflect.get(target, prop, target);