@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 +22 -0
- package/package.json +2 -1
- package/src/AdapterBase.ts +41 -13
- package/src/Gateway.ts +32 -12
- package/src/adapter/browser.ts +1 -0
- package/src/adapter/schema/knex/json.test.ts +198 -0
- package/src/adapter/schema/knex/json.ts +136 -22
- package/src/adapter/schema/knex/types.ts +91 -0
- package/src/adapter/server/knex-deadlock.test.ts +73 -1
- package/src/adapter/server/knex.ts +478 -65
- package/src/codec/adapter/mle/ready.ts +89 -17
- package/src/globals.d.ts +0 -6
- package/src/jwt.ts +160 -14
- package/src/load.ts +5 -0
- package/template/browser.ts +2 -2
- package/template/index.browser.ts +4 -0
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.
|
|
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": {
|
package/src/AdapterBase.ts
CHANGED
|
@@ -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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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!({
|
|
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)!({
|
|
398
|
-
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
)
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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,
|
package/src/adapter/browser.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
145
|
-
*
|
|
146
|
-
*
|
|
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
|
|
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
|
-
|
|
156
|
-
|
|
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[]) =>
|
|
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);
|