@cosmicdrift/kumiko-framework 0.305.0 → 0.306.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/package.json +4 -4
- package/src/api/__tests__/server-boot-guards.test.ts +1 -0
- package/src/api/__tests__/server-error-logging.test.ts +71 -0
- package/src/api/request-context.ts +5 -4
- package/src/api/routes.ts +26 -1
- package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
- package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
- package/src/bun-db/query.ts +42 -18
- package/src/changes.json +66 -0
- package/src/db/__tests__/pg-error.test.ts +14 -0
- package/src/db/__tests__/system-db-view-export.test.ts +107 -0
- package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
- package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
- package/src/db/index.ts +1 -1
- package/src/db/pg-error.ts +13 -0
- package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
- package/src/db/tenant-db.ts +90 -13
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
- package/src/engine/__tests__/boot-validator.test.ts +1 -1
- package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
- package/src/engine/extension-names.ts +55 -25
- package/src/engine/extensions/storage-provider.ts +14 -41
- package/src/engine/extensions/tenant-data.ts +4 -0
- package/src/engine/extensions/tenant-resource.ts +40 -0
- package/src/engine/extensions/user-data.ts +8 -7
- package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
- package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
- package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
- package/src/engine/feature-ast/entity-field-types.ts +41 -0
- package/src/engine/feature-ast/extractors/handlers.ts +217 -84
- package/src/engine/feature-ast/extractors/hooks.ts +72 -15
- package/src/engine/feature-ast/extractors/round2.ts +21 -0
- package/src/engine/feature-ast/extractors/shared.ts +9 -0
- package/src/engine/feature-ast/index.ts +11 -1
- package/src/engine/feature-ast/patch.ts +338 -5
- package/src/engine/feature-ast/patcher.ts +2 -2
- package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
- package/src/engine/feature-ast/patterns.ts +22 -15
- package/src/engine/feature-ast/render.ts +1 -0
- package/src/engine/feature-ui-extensions.ts +8 -7
- package/src/engine/index.ts +21 -5
- package/src/engine/types/extension-options-map.ts +1 -0
- package/src/engine/types/index.ts +6 -0
- package/src/jobs/__tests__/job-retention.integration.test.ts +316 -0
- package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +3 -3
- package/src/jobs/job-runner.ts +151 -14
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
- package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
- package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
- package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
- package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
- package/src/pipeline/dispatch-batch.ts +56 -13
- package/src/pipeline/idempotency.ts +16 -0
- package/src/pipeline/system-identity-switch.ts +22 -4
- package/src/testing/closed-connection-error.ts +62 -0
- package/src/testing/index.ts +1 -0
- package/src/bun-db/__tests__/select-many-retry.test.ts +0 -79
|
@@ -223,6 +223,65 @@ describe("idempotency guard", () => {
|
|
|
223
223
|
if (final.status !== "cached") throw new Error("expected cached value");
|
|
224
224
|
expect(JSON.parse(final.result)).toEqual({ isSuccess: true, data: { owner: "reclaimer" } });
|
|
225
225
|
});
|
|
226
|
+
|
|
227
|
+
test("release() frees the lock — a waiting check() reclaims immediately instead of polling out", async () => {
|
|
228
|
+
const guard = createIdempotencyGuard(testRedis.redis, {
|
|
229
|
+
pendingTtlSeconds: 5,
|
|
230
|
+
pollIntervalMs: 20,
|
|
231
|
+
waitTimeoutMs: 10_000,
|
|
232
|
+
});
|
|
233
|
+
const requestId = "req-release-1";
|
|
234
|
+
|
|
235
|
+
const first = await guard.check(tenantA, userA, requestId);
|
|
236
|
+
expect(first.status).toBe("acquired");
|
|
237
|
+
if (first.status !== "acquired") throw new Error("expected to acquire the lock");
|
|
238
|
+
|
|
239
|
+
// Waiter starts BEFORE release — if release is a no-op this only resolves
|
|
240
|
+
// once waitTimeoutMs elapses (10s), which the 500ms race below catches.
|
|
241
|
+
const waiterPromise = guard.check(tenantA, userA, requestId);
|
|
242
|
+
|
|
243
|
+
await guard.release(tenantA, userA, requestId, first.token);
|
|
244
|
+
|
|
245
|
+
const raced = await Promise.race([
|
|
246
|
+
waiterPromise.then((v) => ({ done: true as const, v })),
|
|
247
|
+
new Promise<{ done: false }>((r) => setTimeout(() => r({ done: false }), 500)),
|
|
248
|
+
]);
|
|
249
|
+
expect(raced.done).toBe(true);
|
|
250
|
+
if (!raced.done) throw new Error("waiter did not reclaim after release()");
|
|
251
|
+
expect(raced.v.status).toBe("acquired");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("release() with a stale token is a no-op — it must not clear a new owner's lock", async () => {
|
|
255
|
+
const guard = createIdempotencyGuard(testRedis.redis, {
|
|
256
|
+
pendingTtlSeconds: 1,
|
|
257
|
+
pollIntervalMs: 20,
|
|
258
|
+
waitTimeoutMs: 6_000,
|
|
259
|
+
});
|
|
260
|
+
const requestId = "req-release-stale";
|
|
261
|
+
|
|
262
|
+
const original = await guard.check(tenantA, userA, requestId);
|
|
263
|
+
expect(original.status).toBe("acquired");
|
|
264
|
+
if (original.status !== "acquired") throw new Error("expected to acquire the lock");
|
|
265
|
+
|
|
266
|
+
// Let the lock expire, then a new owner reclaims it.
|
|
267
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
268
|
+
const reclaimer = await guard.check(tenantA, userA, requestId);
|
|
269
|
+
expect(reclaimer.status).toBe("acquired");
|
|
270
|
+
if (reclaimer.status !== "acquired") throw new Error("expected to reclaim the lock");
|
|
271
|
+
|
|
272
|
+
// The original (stale) owner's release() must not touch the reclaimer's lock.
|
|
273
|
+
await guard.release(tenantA, userA, requestId, original.token);
|
|
274
|
+
|
|
275
|
+
await guard.store(tenantA, userA, requestId, reclaimer.token, {
|
|
276
|
+
isSuccess: true,
|
|
277
|
+
data: { owner: "reclaimer" },
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const final = await guard.check(tenantA, userA, requestId);
|
|
281
|
+
expect(final.status).toBe("cached");
|
|
282
|
+
if (final.status !== "cached") throw new Error("expected cached value");
|
|
283
|
+
expect(JSON.parse(final.result)).toEqual({ isSuccess: true, data: { owner: "reclaimer" } });
|
|
284
|
+
});
|
|
226
285
|
});
|
|
227
286
|
|
|
228
287
|
// --- Event Dedup ---
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { requestContext } from "../api/request-context";
|
|
1
2
|
import type { DbConnection } from "../db/connection";
|
|
2
3
|
import { transaction } from "../db/query";
|
|
3
4
|
import type { DeleteContext, SaveContext, SessionUser, WriteResult } from "../engine/types";
|
|
@@ -22,6 +23,22 @@ export async function runBatch(
|
|
|
22
23
|
commands: readonly BatchCommand[],
|
|
23
24
|
user: SessionUser,
|
|
24
25
|
requestId?: string,
|
|
26
|
+
): Promise<BatchResult> {
|
|
27
|
+
const current = requestContext.get();
|
|
28
|
+
if (!current?.signal) {
|
|
29
|
+
return runBatchBody(ctx, commands, user, requestId);
|
|
30
|
+
}
|
|
31
|
+
// Strip the signal: a disconnect would roll back the tx, idempotency would
|
|
32
|
+
// cache a 500 for the uncommitted write and afterCommit effects would be lost.
|
|
33
|
+
const { signal: _signal, ...withoutSignal } = current;
|
|
34
|
+
return requestContext.run(withoutSignal, () => runBatchBody(ctx, commands, user, requestId));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function runBatchBody(
|
|
38
|
+
ctx: DispatchContext,
|
|
39
|
+
commands: readonly BatchCommand[],
|
|
40
|
+
user: SessionUser,
|
|
41
|
+
requestId?: string,
|
|
25
42
|
): Promise<BatchResult> {
|
|
26
43
|
const { idempotency, lifecycle, appContext: context } = ctx;
|
|
27
44
|
if (commands.length === 0) {
|
|
@@ -45,8 +62,8 @@ export async function runBatch(
|
|
|
45
62
|
}
|
|
46
63
|
}
|
|
47
64
|
|
|
48
|
-
//
|
|
49
|
-
//
|
|
65
|
+
// Cache the result under requestId so retries get the same answer. Only a
|
|
66
|
+
// provably rolled-back 5xx releases the lock instead (releaseOrFinalize).
|
|
50
67
|
const finalize = async (result: BatchResult): Promise<BatchResult> => {
|
|
51
68
|
if (requestId && idempotency && idempotencyToken) {
|
|
52
69
|
await idempotency.store(user.tenantId, user.id, requestId, idempotencyToken, result);
|
|
@@ -54,6 +71,19 @@ export async function runBatch(
|
|
|
54
71
|
return result;
|
|
55
72
|
};
|
|
56
73
|
|
|
74
|
+
// Never for the no-tx fallback: without a rollback, a re-run would repeat
|
|
75
|
+
// the side effects of the commands that already ran.
|
|
76
|
+
const releaseOrFinalize = async (
|
|
77
|
+
result: BatchResult,
|
|
78
|
+
isRetryableRollback: boolean,
|
|
79
|
+
): Promise<BatchResult> => {
|
|
80
|
+
if (isRetryableRollback && requestId && idempotency && idempotencyToken) {
|
|
81
|
+
await idempotency.release(user.tenantId, user.id, requestId, idempotencyToken);
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
return finalize(result);
|
|
85
|
+
};
|
|
86
|
+
|
|
57
87
|
const afterCommitHooks: AfterCommitHook[] = [];
|
|
58
88
|
const results: WriteResult[] = [];
|
|
59
89
|
|
|
@@ -134,6 +164,7 @@ export async function runBatch(
|
|
|
134
164
|
return finalize({ isSuccess: true, results });
|
|
135
165
|
}
|
|
136
166
|
|
|
167
|
+
let transactionCallbackCompleted = false;
|
|
137
168
|
try {
|
|
138
169
|
await transaction(db, async (tx) => {
|
|
139
170
|
for (let i = 0; i < commands.length; i++) {
|
|
@@ -153,22 +184,34 @@ export async function runBatch(
|
|
|
153
184
|
throw new BatchRollback(i, res.error);
|
|
154
185
|
}
|
|
155
186
|
}
|
|
187
|
+
transactionCallbackCompleted = true;
|
|
156
188
|
});
|
|
157
189
|
} catch (e) {
|
|
158
190
|
if (e instanceof BatchRollback) {
|
|
159
|
-
|
|
191
|
+
// Thrown inside the callback, so the tx rolled back. A 4xx is
|
|
192
|
+
// deterministic and stays cached; a 5xx may be transient.
|
|
193
|
+
return releaseOrFinalize(
|
|
194
|
+
{
|
|
195
|
+
isSuccess: false,
|
|
196
|
+
error: e.failureError,
|
|
197
|
+
failedIndex: e.failedIndex,
|
|
198
|
+
results,
|
|
199
|
+
},
|
|
200
|
+
e.failureError.httpStatus >= 500,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
// A completed callback means the throw came from COMMIT (outcome unknown),
|
|
204
|
+
// so cache it; otherwise COMMIT was never sent and releasing is safe.
|
|
205
|
+
const error = toWriteErrorInfo(wrapToKumiko(e));
|
|
206
|
+
return releaseOrFinalize(
|
|
207
|
+
{
|
|
160
208
|
isSuccess: false,
|
|
161
|
-
error
|
|
162
|
-
failedIndex:
|
|
209
|
+
error,
|
|
210
|
+
failedIndex: results.length,
|
|
163
211
|
results,
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
isSuccess: false,
|
|
168
|
-
error: toWriteErrorInfo(wrapToKumiko(e)),
|
|
169
|
-
failedIndex: results.length,
|
|
170
|
-
results,
|
|
171
|
-
});
|
|
212
|
+
},
|
|
213
|
+
!transactionCallbackCompleted && error.httpStatus >= 500,
|
|
214
|
+
);
|
|
172
215
|
}
|
|
173
216
|
|
|
174
217
|
// Commit succeeded — fire deferred side-effects.
|
|
@@ -19,6 +19,7 @@ export type IdempotencyGuard = {
|
|
|
19
19
|
token: string,
|
|
20
20
|
result: unknown,
|
|
21
21
|
): Promise<void>;
|
|
22
|
+
release(tenantId: string, userId: string, requestId: string, token: string): Promise<void>;
|
|
22
23
|
};
|
|
23
24
|
|
|
24
25
|
// Sentinel prefix stored under the key while the handler is running. Each
|
|
@@ -64,6 +65,16 @@ export function createIdempotencyGuard(
|
|
|
64
65
|
end
|
|
65
66
|
`;
|
|
66
67
|
|
|
68
|
+
// Same CAS guard as storeScript: only clear the lock if we still own it.
|
|
69
|
+
// A stale token (lock already reclaimed by a new owner) is a no-op.
|
|
70
|
+
const releaseScript = `
|
|
71
|
+
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
72
|
+
return redis.call("del", KEYS[1])
|
|
73
|
+
else
|
|
74
|
+
return 0
|
|
75
|
+
end
|
|
76
|
+
`;
|
|
77
|
+
|
|
67
78
|
async function tryAcquire(key: string): Promise<string | null> {
|
|
68
79
|
const token = `${PENDING_PREFIX}${generateId()}`;
|
|
69
80
|
const acquired = await redis.set(key, token, "EX", pendingTtl, "NX");
|
|
@@ -138,5 +149,10 @@ export function createIdempotencyGuard(
|
|
|
138
149
|
// SET and could stomp that fresher result with our stale one.
|
|
139
150
|
void written;
|
|
140
151
|
},
|
|
152
|
+
|
|
153
|
+
async release(tenantId, userId, requestId, token) {
|
|
154
|
+
const key = `${prefix}${tenantId}:${userId}:${requestId}`;
|
|
155
|
+
await redis.eval(releaseScript, 1, key, token);
|
|
156
|
+
},
|
|
141
157
|
};
|
|
142
158
|
}
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type TenantDb,
|
|
3
|
+
type UncheckedSystemDb,
|
|
4
|
+
withSystemDbUnsafeRawGrant,
|
|
5
|
+
withUnsafeRawGrant,
|
|
6
|
+
} from "../db/tenant-db";
|
|
2
7
|
import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user";
|
|
3
8
|
import type {
|
|
4
9
|
ActiveMembershipResult,
|
|
@@ -214,7 +219,10 @@ function readIdentitySwitchFn<
|
|
|
214
219
|
}
|
|
215
220
|
|
|
216
221
|
// context's own keys only — never touch a property of the resolved value, which may be a Proxy that throws on any get.
|
|
217
|
-
function readDbLikeValue(
|
|
222
|
+
function readDbLikeValue(
|
|
223
|
+
context: object,
|
|
224
|
+
key: "db" | "dbOutsideTransaction" | "systemDb",
|
|
225
|
+
): object | undefined {
|
|
218
226
|
if (!(key in context)) return undefined;
|
|
219
227
|
const value = (context as Record<string, unknown>)[key];
|
|
220
228
|
return typeof value === "object" && value !== null ? value : undefined;
|
|
@@ -340,6 +348,7 @@ export function withHookEscapeHatchGrant<TContext extends object>(
|
|
|
340
348
|
const ctxQueryProjection = readIdentitySwitchFn<ProjectionReader>(context, "queryProjection");
|
|
341
349
|
const ctxDb = readDbLikeValue(context, "db");
|
|
342
350
|
const ctxDbOutsideTransaction = readDbLikeValue(context, "dbOutsideTransaction");
|
|
351
|
+
const ctxSystemDb = readDbLikeValue(context, "systemDb");
|
|
343
352
|
if (
|
|
344
353
|
!ctxQueryAs &&
|
|
345
354
|
!ctxWriteAs &&
|
|
@@ -347,7 +356,8 @@ export function withHookEscapeHatchGrant<TContext extends object>(
|
|
|
347
356
|
!ctxQueryAsMember &&
|
|
348
357
|
!ctxQueryProjection &&
|
|
349
358
|
!ctxDb &&
|
|
350
|
-
!ctxDbOutsideTransaction
|
|
359
|
+
!ctxDbOutsideTransaction &&
|
|
360
|
+
!ctxSystemDb
|
|
351
361
|
) {
|
|
352
362
|
return context;
|
|
353
363
|
}
|
|
@@ -366,10 +376,18 @@ export function withHookEscapeHatchGrant<TContext extends object>(
|
|
|
366
376
|
...(ctxDbOutsideTransaction && {
|
|
367
377
|
dbOutsideTransaction: withUnsafeRawGrant(ctxDbOutsideTransaction as TenantDb, escapeHatch),
|
|
368
378
|
}),
|
|
379
|
+
// @cast-boundary engine-bridge — withSystemDbUnsafeRawGrant passes non-UncheckedSystemDb values through unchanged.
|
|
380
|
+
...(ctxSystemDb && {
|
|
381
|
+
systemDb: withSystemDbUnsafeRawGrant(
|
|
382
|
+
ctxSystemDb as UncheckedSystemDb,
|
|
383
|
+
escapeHatch,
|
|
384
|
+
callerLabel,
|
|
385
|
+
),
|
|
386
|
+
}),
|
|
369
387
|
};
|
|
370
388
|
}
|
|
371
389
|
|
|
372
|
-
// Re-gates a hook's own ctx.queryAs/ctx.writeAs/ctx.queryProjection/ctx.db/ctx.dbOutsideTransaction instead of inheriting the handler's grant.
|
|
390
|
+
// Re-gates a hook's own ctx.queryAs/ctx.writeAs/ctx.queryProjection/ctx.db/ctx.dbOutsideTransaction/ctx.systemDb instead of inheriting the handler's grant.
|
|
373
391
|
export function bindHookEscapeHatchGrant(
|
|
374
392
|
fn: LifecycleHookFn,
|
|
375
393
|
label: string,
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Captures a real closed-connection error instead of a hand-built fake —
|
|
2
|
+
// the production matcher checks driver-specific codes a fake can't reproduce.
|
|
3
|
+
|
|
4
|
+
import postgres from "postgres";
|
|
5
|
+
import { isClosedConnectionError } from "../bun-db/query";
|
|
6
|
+
|
|
7
|
+
export function testDatabaseUrl(): string {
|
|
8
|
+
return (
|
|
9
|
+
process.env["TEST_DATABASE_URL"] ??
|
|
10
|
+
process.env["DATABASE_URL"] ??
|
|
11
|
+
"postgresql://kumiko:kumiko@localhost:15432/kumiko_test"
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const MAX_WARMUP_ROUNDS = 10;
|
|
16
|
+
const READS_PER_ROUND = 5;
|
|
17
|
+
|
|
18
|
+
type AdminClient = { unsafe(sql: string, params?: readonly unknown[]): Promise<unknown> };
|
|
19
|
+
|
|
20
|
+
// A per-round admin client hits a postgres-js reconnect timing bug (the first
|
|
21
|
+
// retry after terminate hangs); a single long-lived admin client avoids it.
|
|
22
|
+
export async function terminateBackendsByApplicationName(
|
|
23
|
+
admin: AdminClient,
|
|
24
|
+
applicationName: string,
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
await admin.unsafe(
|
|
27
|
+
"select pg_terminate_backend(pid) from pg_stat_activity where application_name = $1",
|
|
28
|
+
[applicationName],
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Terminates a throwaway pool's backends and races reads right after — the
|
|
33
|
+
// dead-connection window is only a few ms wide, so this retries rounds until one lands.
|
|
34
|
+
export async function captureClosedConnectionError(
|
|
35
|
+
url: string = testDatabaseUrl(),
|
|
36
|
+
): Promise<unknown> {
|
|
37
|
+
const admin = postgres(url, { max: 1 });
|
|
38
|
+
try {
|
|
39
|
+
for (let round = 0; round < MAX_WARMUP_ROUNDS; round++) {
|
|
40
|
+
const applicationName = `kumiko-closed-conn-test-${crypto.randomUUID()}`;
|
|
41
|
+
const pool = postgres(url, { max: 1, connection: { application_name: applicationName } });
|
|
42
|
+
try {
|
|
43
|
+
await pool.unsafe("select 1");
|
|
44
|
+
await terminateBackendsByApplicationName(admin, applicationName);
|
|
45
|
+
for (let read = 0; read < READS_PER_ROUND; read++) {
|
|
46
|
+
try {
|
|
47
|
+
await pool.unsafe("select 1");
|
|
48
|
+
} catch (err) {
|
|
49
|
+
if (isClosedConnectionError(err)) return err;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} finally {
|
|
53
|
+
await pool.end({ timeout: 0 });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
throw new Error(
|
|
57
|
+
`captureClosedConnectionError: no closed-connection error observed after ${MAX_WARMUP_ROUNDS} rounds.`,
|
|
58
|
+
);
|
|
59
|
+
} finally {
|
|
60
|
+
await admin.end({ timeout: 0 });
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/testing/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ export { resetEntityFieldEncryptionCacheForTests } from "../db/entity-field-encr
|
|
|
15
15
|
export { rolesOf } from "./access-assertions";
|
|
16
16
|
export { expectError, expectSuccess } from "./assertions";
|
|
17
17
|
export { withBootValidatorFixture } from "./boot-validator-fixture";
|
|
18
|
+
export { captureClosedConnectionError } from "./closed-connection-error";
|
|
18
19
|
export { type ClearableTable, clearTables, resetTestTables } from "./db-cleanup";
|
|
19
20
|
export {
|
|
20
21
|
type E2EGeneratorOptions,
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
// #1163: Bun.SQL can hand out a closed connection under load (AbortError
|
|
2
|
-
// "The connection was closed."). Pure reads retry exactly once on a fresh
|
|
3
|
-
// pool checkout; tx handles, non-matching errors, and genuine user aborts
|
|
4
|
-
// must NOT retry.
|
|
5
|
-
|
|
6
|
-
import { describe, expect, test } from "bun:test";
|
|
7
|
-
import { buildEntityTable } from "../../db/table-builder";
|
|
8
|
-
import { selectMany } from "../query";
|
|
9
|
-
|
|
10
|
-
function closedConnectionError(): Error {
|
|
11
|
-
return Object.assign(new Error("The connection was closed."), { name: "AbortError" });
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
type FakeClient = {
|
|
15
|
-
unsafe: (sql: string, params?: readonly unknown[]) => Promise<readonly unknown[]>;
|
|
16
|
-
begin?: () => never;
|
|
17
|
-
calls: number;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
function fakeClient(failures: Error[], opts: { tx?: boolean } = {}): FakeClient {
|
|
21
|
-
const remaining = [...failures];
|
|
22
|
-
const client: FakeClient = {
|
|
23
|
-
calls: 0,
|
|
24
|
-
unsafe: async () => {
|
|
25
|
-
client.calls++;
|
|
26
|
-
const err = remaining.shift();
|
|
27
|
-
if (err) throw err;
|
|
28
|
-
return [{ id: "r1", title: "ok", tenant_id: "t1", inserted_at: null, updated_at: null }];
|
|
29
|
-
},
|
|
30
|
-
};
|
|
31
|
-
// A top-level pool client has begin(); a transaction handle does not.
|
|
32
|
-
if (!opts.tx)
|
|
33
|
-
client.begin = () => {
|
|
34
|
-
throw new Error("not used in test");
|
|
35
|
-
};
|
|
36
|
-
return client;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const table = buildEntityTable("note", {
|
|
40
|
-
fields: { title: { type: "text", required: true } },
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
describe("selectMany — closed-connection retry (#1163)", () => {
|
|
44
|
-
test("retries once on AbortError 'connection was closed' and returns rows", async () => {
|
|
45
|
-
const db = fakeClient([closedConnectionError()]);
|
|
46
|
-
const rows = await selectMany(db, table);
|
|
47
|
-
expect(rows).toHaveLength(1);
|
|
48
|
-
expect(rows[0]?.title).toBe("ok");
|
|
49
|
-
expect(db.calls).toBe(2);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test("gives up after the single retry when the connection stays closed", async () => {
|
|
53
|
-
const db = fakeClient([closedConnectionError(), closedConnectionError()]);
|
|
54
|
-
await expect(selectMany(db, table)).rejects.toThrow("connection was closed");
|
|
55
|
-
expect(db.calls).toBe(2);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
test("never retries on a transaction handle (no begin)", async () => {
|
|
59
|
-
const db = fakeClient([closedConnectionError()], { tx: true });
|
|
60
|
-
await expect(selectMany(db, table)).rejects.toThrow("connection was closed");
|
|
61
|
-
expect(db.calls).toBe(1);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test("does not retry a genuine user abort (different message)", async () => {
|
|
65
|
-
const userAbort = Object.assign(new Error("The operation was aborted."), {
|
|
66
|
-
name: "AbortError",
|
|
67
|
-
});
|
|
68
|
-
const db = fakeClient([userAbort]);
|
|
69
|
-
await expect(selectMany(db, table)).rejects.toThrow("operation was aborted");
|
|
70
|
-
expect(db.calls).toBe(1);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test("does not retry generic query errors", async () => {
|
|
74
|
-
const syntax = Object.assign(new Error("syntax error at or near"), { name: "PostgresError" });
|
|
75
|
-
const db = fakeClient([syntax]);
|
|
76
|
-
await expect(selectMany(db, table)).rejects.toThrow("syntax error");
|
|
77
|
-
expect(db.calls).toBe(1);
|
|
78
|
-
});
|
|
79
|
-
});
|