@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.306.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -198,8 +198,8 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-http": "0.
|
|
202
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-http": "0.306.0",
|
|
202
|
+
"@cosmicdrift/kumiko-types": "0.306.0",
|
|
203
203
|
"bullmq": "^5.76.7",
|
|
204
204
|
"bun-types": "^1.3.13",
|
|
205
205
|
"hono": "^4.13.1",
|
|
@@ -215,7 +215,7 @@
|
|
|
215
215
|
"zod": "^4.4.3"
|
|
216
216
|
},
|
|
217
217
|
"devDependencies": {
|
|
218
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
218
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.306.0",
|
|
219
219
|
"bun-types": "^1.3.13",
|
|
220
220
|
"pino-pretty": "^13.1.3"
|
|
221
221
|
},
|
|
@@ -228,6 +228,7 @@ describe("buildServer — auth membershipQuery requires a principalStatus provid
|
|
|
228
228
|
r.extendsRegistrar(EXT_PRINCIPAL_STATUS, {});
|
|
229
229
|
r.useExtension(EXT_PRINCIPAL_STATUS, "has-principal-status", {
|
|
230
230
|
resolveStatus: async () => "active" as const,
|
|
231
|
+
resolveProfile: async () => ({ globalRoles: [] }),
|
|
231
232
|
});
|
|
232
233
|
});
|
|
233
234
|
|
|
@@ -66,6 +66,16 @@ const boomFeature = defineFeature("boom", (r) => {
|
|
|
66
66
|
async () => ({ ok: true }),
|
|
67
67
|
openToAll,
|
|
68
68
|
);
|
|
69
|
+
// Same abort path TenantDb uses (signal.throwIfAborted()), without a DB.
|
|
70
|
+
r.queryHandler(
|
|
71
|
+
"abort-probe",
|
|
72
|
+
z.object({}),
|
|
73
|
+
async (_query, ctx) => {
|
|
74
|
+
ctx.signal?.throwIfAborted();
|
|
75
|
+
return { ok: true };
|
|
76
|
+
},
|
|
77
|
+
openToAll,
|
|
78
|
+
);
|
|
69
79
|
});
|
|
70
80
|
|
|
71
81
|
const { app, jwt } = buildServer({
|
|
@@ -178,6 +188,67 @@ describe("HTTP layer logs unexpected 5xx faults", () => {
|
|
|
178
188
|
expect(status).toBe(404);
|
|
179
189
|
expect(apiFaultLog(errors)).toBeUndefined();
|
|
180
190
|
});
|
|
191
|
+
|
|
192
|
+
test("a pre-aborted client signal 499s and logs a warn, not '[api] handler failed'", async () => {
|
|
193
|
+
const controller = new AbortController();
|
|
194
|
+
controller.abort();
|
|
195
|
+
const warnings: unknown[][] = [];
|
|
196
|
+
const errors: unknown[][] = [];
|
|
197
|
+
const warnSpy = spyOn(console, "warn").mockImplementation((...args) => {
|
|
198
|
+
warnings.push(args);
|
|
199
|
+
});
|
|
200
|
+
const errorSpy = spyOn(console, "error").mockImplementation((...args) => {
|
|
201
|
+
errors.push(args);
|
|
202
|
+
});
|
|
203
|
+
try {
|
|
204
|
+
const res = await app.request(
|
|
205
|
+
new Request("http://test.local/api/query", {
|
|
206
|
+
method: "POST",
|
|
207
|
+
headers: await auth(),
|
|
208
|
+
body: JSON.stringify({ type: "boom:query:abort-probe", payload: {} }),
|
|
209
|
+
signal: controller.signal,
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
expect(res.status).toBe(499);
|
|
213
|
+
expect(apiFaultLog(errors)).toBeUndefined();
|
|
214
|
+
const hit = warnings.find(
|
|
215
|
+
(args) =>
|
|
216
|
+
typeof args[0] === "string" && args[0].includes("[api] request aborted by client"),
|
|
217
|
+
);
|
|
218
|
+
expect(hit).toBeDefined();
|
|
219
|
+
const data = hit?.[1];
|
|
220
|
+
expect(isRecord(data)).toBe(true);
|
|
221
|
+
if (!isRecord(data)) return;
|
|
222
|
+
expect(data["status"]).toBe(499);
|
|
223
|
+
expect(data["type"]).toBe("boom:query:abort-probe");
|
|
224
|
+
} finally {
|
|
225
|
+
warnSpy.mockRestore();
|
|
226
|
+
errorSpy.mockRestore();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("a pre-aborted client signal still 500s + logs when the handler fails for an unrelated reason", async () => {
|
|
231
|
+
const controller = new AbortController();
|
|
232
|
+
controller.abort();
|
|
233
|
+
const calls: unknown[][] = [];
|
|
234
|
+
const spy = spyOn(console, "error").mockImplementation((...args) => {
|
|
235
|
+
calls.push(args);
|
|
236
|
+
});
|
|
237
|
+
try {
|
|
238
|
+
const res = await app.request(
|
|
239
|
+
new Request("http://test.local/api/query", {
|
|
240
|
+
method: "POST",
|
|
241
|
+
headers: await auth(),
|
|
242
|
+
body: JSON.stringify({ type: "boom:query:explode", payload: {} }),
|
|
243
|
+
signal: controller.signal,
|
|
244
|
+
}),
|
|
245
|
+
);
|
|
246
|
+
expect(res.status).toBe(500);
|
|
247
|
+
expect(apiFaultLog(calls)).toBeDefined();
|
|
248
|
+
} finally {
|
|
249
|
+
spy.mockRestore();
|
|
250
|
+
}
|
|
251
|
+
});
|
|
181
252
|
});
|
|
182
253
|
|
|
183
254
|
describe("HTTP layer logs 4xx client faults on warn (#3077)", () => {
|
|
@@ -17,10 +17,11 @@ import { generateId } from "../utils";
|
|
|
17
17
|
// with correlationId, forms a causal DAG across streams.
|
|
18
18
|
// signal — AbortSignal from the underlying HTTP request. Aborts
|
|
19
19
|
// when the client disconnects (mobile back-press, tab
|
|
20
|
-
// close).
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// Undefined for non-HTTP
|
|
20
|
+
// close). Query/stream handlers check signal.aborted at
|
|
21
|
+
// chunk or query boundaries; runBatch (write dispatch)
|
|
22
|
+
// strips it before executing so a disconnect can't abort
|
|
23
|
+
// a transaction mid-commit. Undefined for non-HTTP
|
|
24
|
+
// entry-points (jobs, MSP-applies) and inside write batches.
|
|
24
25
|
export type RequestContextData = {
|
|
25
26
|
readonly requestId: string;
|
|
26
27
|
readonly correlationId: string;
|
package/src/api/routes.ts
CHANGED
|
@@ -29,6 +29,10 @@ export const StreamFrame = {
|
|
|
29
29
|
error: "error",
|
|
30
30
|
} as const;
|
|
31
31
|
|
|
32
|
+
// Non-standard but widely used for "client hung up" — distinct from a real
|
|
33
|
+
// 5xx so a disconnect never gets logged as a server fault.
|
|
34
|
+
const CLIENT_CLOSED_REQUEST_STATUS = 499;
|
|
35
|
+
|
|
32
36
|
export type ApiRoutesOptions = {
|
|
33
37
|
// Override the SSE heartbeat interval (ms). Default SSE_HEARTBEAT_INTERVAL_MS.
|
|
34
38
|
// Deployment-tunable for proxies with different idle timeouts — also used
|
|
@@ -203,7 +207,7 @@ export function createApiRoutes(dispatcher: Dispatcher, options: ApiRoutesOption
|
|
|
203
207
|
// in-flight .next(), so awaiting here would block the response until
|
|
204
208
|
// that pending pull resolves (which may be never for an idle stream).
|
|
205
209
|
void generator.return(undefined).catch(() => {});
|
|
206
|
-
return c.body(null,
|
|
210
|
+
return c.body(null, CLIENT_CLOSED_REQUEST_STATUS as ContentfulStatusCode); // @cast-boundary non-standard client-closed-request status, Hono's union doesn't include it
|
|
207
211
|
}
|
|
208
212
|
|
|
209
213
|
return streamSSE(c, async (stream) => {
|
|
@@ -364,6 +368,13 @@ function logClientFault(err: KumikoError, requestId: string | undefined, type?:
|
|
|
364
368
|
});
|
|
365
369
|
}
|
|
366
370
|
|
|
371
|
+
// Identity-checked against THIS request's signal — signal.aborted alone
|
|
372
|
+
// would also match a real 5xx that happens to race a disconnect.
|
|
373
|
+
function isClientAbort(err: KumikoError): boolean {
|
|
374
|
+
const signal = requestContext.get()?.signal;
|
|
375
|
+
return signal?.aborted === true && err.cause !== undefined && err.cause === signal.reason;
|
|
376
|
+
}
|
|
377
|
+
|
|
367
378
|
// Unexpected server faults (5xx) carry their diagnostic stack only on the
|
|
368
379
|
// in-process error — serializeError strips cause/details from the wire body.
|
|
369
380
|
// Without this a wrapped throw (InternalError{cause}) returns a 500 with zero
|
|
@@ -401,6 +412,20 @@ function writeErrorResponse(c: Context, err: KumikoError, type?: string) {
|
|
|
401
412
|
// keep the same lean shape on failure — only the `error` key.
|
|
402
413
|
function queryErrorResponse(c: Context, err: KumikoError, type?: string) {
|
|
403
414
|
const requestId = requestContext.get()?.requestId;
|
|
415
|
+
if (isClientAbort(err)) {
|
|
416
|
+
if (clientFaultLoggingEnabled()) {
|
|
417
|
+
const startedAt = requestContext.get()?.startedAt;
|
|
418
|
+
createFallbackLogger("api").warn("request aborted by client", {
|
|
419
|
+
requestId,
|
|
420
|
+
type: type?.slice(0, MAX_LOGGED_TYPE_LENGTH),
|
|
421
|
+
status: CLIENT_CLOSED_REQUEST_STATUS,
|
|
422
|
+
...(startedAt === undefined
|
|
423
|
+
? {}
|
|
424
|
+
: { durationMs: Math.round(performance.now() - startedAt) }),
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
return c.body(null, CLIENT_CLOSED_REQUEST_STATUS as ContentfulStatusCode); // @cast-boundary non-standard client-closed-request status, Hono's union doesn't include it
|
|
428
|
+
}
|
|
404
429
|
logServerFault(err, requestId, type);
|
|
405
430
|
const body = serializeError(err, requestId);
|
|
406
431
|
return c.json(body, err.httpStatus as ContentfulStatusCode); // @cast-boundary engine-payload
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Proves the closed-connection retry against a real postgres-js driver error,
|
|
2
|
+
// plus the Bun.SQL matcher and both drivers' extractPgError — see query.ts.
|
|
3
|
+
|
|
4
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
5
|
+
import postgres from "postgres";
|
|
6
|
+
import { constraintOf, extractPgError, isUniqueViolation } from "../../db/pg-error";
|
|
7
|
+
import { testDatabaseUrl } from "../../testing/closed-connection-error";
|
|
8
|
+
import { waitFor } from "../../testing/wait-for";
|
|
9
|
+
import { isClosedConnectionError, unsafeReadRetrying } from "../query";
|
|
10
|
+
|
|
11
|
+
const DATABASE_URL = testDatabaseUrl();
|
|
12
|
+
|
|
13
|
+
// Terminate targets only the backend running the slow query so the retry
|
|
14
|
+
// lands on the pool's idle connection instead of reconnecting — a postgres-js
|
|
15
|
+
// reconnect under Bun can hang until connect_timeout (30s).
|
|
16
|
+
const adminClient = postgres(DATABASE_URL, { max: 1 });
|
|
17
|
+
afterAll(async () => {
|
|
18
|
+
await adminClient.end({ timeout: 0 });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
type UnsafeFn = (sql: string, params?: readonly unknown[]) => Promise<readonly unknown[]>;
|
|
22
|
+
type Pool = { unsafe: UnsafeFn };
|
|
23
|
+
|
|
24
|
+
// Counts unsafe() calls while forwarding everything (including begin/
|
|
25
|
+
// savepoint/options) to the real handle — no mocked behavior, only counting.
|
|
26
|
+
type CountingClient<T> = T & { readonly calls: number };
|
|
27
|
+
function countingClient<T extends Pool>(real: T): CountingClient<T> {
|
|
28
|
+
let calls = 0;
|
|
29
|
+
return new Proxy(real, {
|
|
30
|
+
get(target, prop, receiver) {
|
|
31
|
+
if (prop === "calls") return calls;
|
|
32
|
+
if (prop === "unsafe") {
|
|
33
|
+
return async (...args: Parameters<UnsafeFn>) => {
|
|
34
|
+
calls++;
|
|
35
|
+
return target.unsafe(...args);
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const value = Reflect.get(target, prop, receiver);
|
|
39
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
40
|
+
},
|
|
41
|
+
}) as CountingClient<T>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function randomAppName(): string {
|
|
45
|
+
return `kumiko-cc-retry-${crypto.randomUUID()}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Waits for the slow query's backend to show up as the single active
|
|
49
|
+
// session before terminating exactly that one — the pg_sleep(0.3) window
|
|
50
|
+
// bounds this, so short fixed delays instead of waitFor's default backoff.
|
|
51
|
+
async function pollForActivePid(applicationName: string, queryLike: string): Promise<number> {
|
|
52
|
+
let pid: number | undefined;
|
|
53
|
+
await waitFor(
|
|
54
|
+
async () => {
|
|
55
|
+
const rows = (await adminClient.unsafe(
|
|
56
|
+
"select pid from pg_stat_activity where application_name = $1 and state = 'active' and query like $2",
|
|
57
|
+
[applicationName, queryLike],
|
|
58
|
+
)) as readonly { pid: number }[];
|
|
59
|
+
if (rows.length === 1 && rows[0]?.pid !== undefined) {
|
|
60
|
+
pid = rows[0].pid;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
},
|
|
65
|
+
{ delays: Array(100).fill(10) },
|
|
66
|
+
);
|
|
67
|
+
if (pid === undefined) {
|
|
68
|
+
throw new Error(`pollForActivePid: no single active backend found for ${applicationName}`);
|
|
69
|
+
}
|
|
70
|
+
return pid;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function terminateBackendPid(pid: number): Promise<void> {
|
|
74
|
+
await adminClient.unsafe("select pg_terminate_backend($1)", [pid]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type DriverKind = "postgres-js" | "bun-sql";
|
|
78
|
+
|
|
79
|
+
function makePool(kind: DriverKind, max: number, applicationName: string): Pool {
|
|
80
|
+
if (kind === "postgres-js") {
|
|
81
|
+
return postgres(DATABASE_URL, {
|
|
82
|
+
max,
|
|
83
|
+
connection: { application_name: applicationName },
|
|
84
|
+
}) as unknown as Pool;
|
|
85
|
+
}
|
|
86
|
+
return new Bun.SQL({
|
|
87
|
+
url: DATABASE_URL,
|
|
88
|
+
max,
|
|
89
|
+
connection: { application_name: applicationName },
|
|
90
|
+
}) as unknown as Pool;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function closePool(kind: DriverKind, pool: unknown): Promise<void> {
|
|
94
|
+
if (kind === "postgres-js") {
|
|
95
|
+
await (pool as { end: (opts?: { timeout?: number }) => Promise<void> }).end({ timeout: 0 });
|
|
96
|
+
} else {
|
|
97
|
+
await (pool as { close: () => Promise<void> }).close();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const drivers: DriverKind[] = ["postgres-js", "bun-sql"];
|
|
102
|
+
|
|
103
|
+
// postgres-js only: Bun.SQL sporadically emits an unhandled reject from its
|
|
104
|
+
// internal handleClose in this terminate window, and isn't the prod driver.
|
|
105
|
+
// Multi-retry (≥3 calls) is covered deterministically by the fake-client
|
|
106
|
+
// test in select-many-retry.integration.test.ts instead of a second live test.
|
|
107
|
+
test("retries through a real server-side connection close", async () => {
|
|
108
|
+
const applicationName = randomAppName();
|
|
109
|
+
const pool = postgres(DATABASE_URL, {
|
|
110
|
+
max: 2,
|
|
111
|
+
connection: { application_name: applicationName },
|
|
112
|
+
});
|
|
113
|
+
try {
|
|
114
|
+
await Promise.all([pool.unsafe("select 1"), pool.unsafe("select 1")]);
|
|
115
|
+
const counted = countingClient(pool as unknown as Pool);
|
|
116
|
+
const pending = unsafeReadRetrying(counted as never, "select 1 as x from pg_sleep(0.3)", []);
|
|
117
|
+
const pid = await pollForActivePid(applicationName, "%pg_sleep(0.3)%");
|
|
118
|
+
await terminateBackendPid(pid);
|
|
119
|
+
const rows = await pending;
|
|
120
|
+
expect([...rows]).toEqual([{ x: 1 }]);
|
|
121
|
+
expect(counted.calls).toBe(2);
|
|
122
|
+
} finally {
|
|
123
|
+
await pool.end({ timeout: 0 });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("Bun.SQL closed-connection error matches isClosedConnectionError", async () => {
|
|
128
|
+
const pool = new Bun.SQL({ url: DATABASE_URL, max: 1 });
|
|
129
|
+
await pool.unsafe("select 1");
|
|
130
|
+
await pool.close();
|
|
131
|
+
const caught = await pool.unsafe("select 1").catch((e: unknown) => e);
|
|
132
|
+
// @cast-boundary error-details — asserting the real driver error shape
|
|
133
|
+
expect((caught as { code?: string }).code).toBe("ERR_POSTGRES_CONNECTION_CLOSED");
|
|
134
|
+
expect(isClosedConnectionError(caught)).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe.each(drivers)("extractPgError — %s unique-violation SQLSTATE", (kind) => {
|
|
138
|
+
test("isUniqueViolation and constraintOf resolve the real driver error", async () => {
|
|
139
|
+
const applicationName = randomAppName();
|
|
140
|
+
const pool = makePool(kind, 1, applicationName);
|
|
141
|
+
const tableName = `cc_retry_uniq_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
|
142
|
+
await pool.unsafe(
|
|
143
|
+
`create table "${tableName}" (id uuid not null, constraint "${tableName}_pk" primary key (id))`,
|
|
144
|
+
);
|
|
145
|
+
try {
|
|
146
|
+
const fixedId = crypto.randomUUID();
|
|
147
|
+
await pool.unsafe(`insert into "${tableName}" (id) values ($1)`, [fixedId]);
|
|
148
|
+
const caught = await pool
|
|
149
|
+
.unsafe(`insert into "${tableName}" (id) values ($1)`, [fixedId])
|
|
150
|
+
.catch((e: unknown) => e);
|
|
151
|
+
expect(isUniqueViolation(caught)).toBe(true);
|
|
152
|
+
expect(extractPgError(caught)?.code).toBe("23505");
|
|
153
|
+
expect(constraintOf(caught)).toBeDefined();
|
|
154
|
+
} finally {
|
|
155
|
+
await pool.unsafe(`drop table if exists "${tableName}"`);
|
|
156
|
+
await closePool(kind, pool);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// selectMany retries a real captured closed-connection error, never a
|
|
2
|
+
// client abort or a transaction/reserved handle. Live-driver proof is in
|
|
3
|
+
// closed-connection-retry.integration.test.ts.
|
|
4
|
+
|
|
5
|
+
import { beforeAll, describe, expect, test } from "bun:test";
|
|
6
|
+
import { buildEntityTable } from "../../db/table-builder";
|
|
7
|
+
import { captureClosedConnectionError } from "../../testing/closed-connection-error";
|
|
8
|
+
import { selectMany } from "../query";
|
|
9
|
+
|
|
10
|
+
let closedConnectionError: unknown;
|
|
11
|
+
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
closedConnectionError = await captureClosedConnectionError();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
type FakeClientOptions = {
|
|
17
|
+
reserve?: boolean;
|
|
18
|
+
begin?: boolean;
|
|
19
|
+
savepoint?: boolean;
|
|
20
|
+
release?: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type FakeClient = {
|
|
24
|
+
unsafe: (sql: string, params?: readonly unknown[]) => Promise<readonly unknown[]>;
|
|
25
|
+
begin?: () => never;
|
|
26
|
+
savepoint?: () => never;
|
|
27
|
+
release?: () => never;
|
|
28
|
+
reserve?: () => never;
|
|
29
|
+
options?: { max: number };
|
|
30
|
+
calls: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function fakeClient(failures: unknown[], opts: FakeClientOptions = {}): FakeClient {
|
|
34
|
+
const remaining = [...failures];
|
|
35
|
+
const client: FakeClient = {
|
|
36
|
+
calls: 0,
|
|
37
|
+
unsafe: async () => {
|
|
38
|
+
client.calls++;
|
|
39
|
+
const err = remaining.shift();
|
|
40
|
+
if (err) throw err;
|
|
41
|
+
return [{ id: "r1", title: "ok", tenant_id: "t1", inserted_at: null, updated_at: null }];
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
const { begin = true, savepoint = false, release = false, reserve = true } = opts;
|
|
45
|
+
if (begin) {
|
|
46
|
+
client.begin = () => {
|
|
47
|
+
throw new Error("not used in test");
|
|
48
|
+
};
|
|
49
|
+
client.options = { max: 1 };
|
|
50
|
+
}
|
|
51
|
+
if (savepoint)
|
|
52
|
+
client.savepoint = () => {
|
|
53
|
+
throw new Error("not used in test");
|
|
54
|
+
};
|
|
55
|
+
if (release)
|
|
56
|
+
client.release = () => {
|
|
57
|
+
throw new Error("not used in test");
|
|
58
|
+
};
|
|
59
|
+
if (reserve)
|
|
60
|
+
client.reserve = () => {
|
|
61
|
+
throw new Error("not used in test");
|
|
62
|
+
};
|
|
63
|
+
return client;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const table = buildEntityTable("note", {
|
|
67
|
+
fields: { title: { type: "text", required: true } },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("selectMany — closed-connection retry", () => {
|
|
71
|
+
test("retries once on a real closed-connection error and returns rows", async () => {
|
|
72
|
+
const db = fakeClient([closedConnectionError]);
|
|
73
|
+
const rows = await selectMany(db, table);
|
|
74
|
+
expect(rows).toHaveLength(1);
|
|
75
|
+
expect(rows[0]?.title).toBe("ok");
|
|
76
|
+
expect(db.calls).toBe(2);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("survives two dead connections in a row and returns rows", async () => {
|
|
80
|
+
const db = fakeClient([closedConnectionError, closedConnectionError]);
|
|
81
|
+
const rows = await selectMany(db, table);
|
|
82
|
+
expect(rows).toHaveLength(1);
|
|
83
|
+
expect(db.calls).toBe(3);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("gives up after exhausting pool-bounded retries (max: 1 → 3 total calls)", async () => {
|
|
87
|
+
const db = fakeClient([closedConnectionError, closedConnectionError, closedConnectionError]);
|
|
88
|
+
await expect(selectMany(db, table)).rejects.toBe(closedConnectionError);
|
|
89
|
+
expect(db.calls).toBe(3);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("never retries on a transaction handle (savepoint, no begin)", async () => {
|
|
93
|
+
const db = fakeClient([closedConnectionError], {
|
|
94
|
+
begin: false,
|
|
95
|
+
savepoint: true,
|
|
96
|
+
reserve: false,
|
|
97
|
+
});
|
|
98
|
+
await expect(selectMany(db, table)).rejects.toBe(closedConnectionError);
|
|
99
|
+
expect(db.calls).toBe(1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("never retries on a Bun.SQL-tx-shaped handle (begin + savepoint)", async () => {
|
|
103
|
+
const db = fakeClient([closedConnectionError], {
|
|
104
|
+
begin: true,
|
|
105
|
+
savepoint: true,
|
|
106
|
+
reserve: false,
|
|
107
|
+
});
|
|
108
|
+
await expect(selectMany(db, table)).rejects.toBe(closedConnectionError);
|
|
109
|
+
expect(db.calls).toBe(1);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("never retries on a reserved handle (begin + release)", async () => {
|
|
113
|
+
const db = fakeClient([closedConnectionError], { begin: true, release: true, reserve: false });
|
|
114
|
+
await expect(selectMany(db, table)).rejects.toBe(closedConnectionError);
|
|
115
|
+
expect(db.calls).toBe(1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("does not retry a genuine user abort", async () => {
|
|
119
|
+
const userAbort = new DOMException("The operation was aborted.", "AbortError");
|
|
120
|
+
const db = fakeClient([userAbort]);
|
|
121
|
+
await expect(selectMany(db, table)).rejects.toBe(userAbort);
|
|
122
|
+
expect(db.calls).toBe(1);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("does not retry a client abort whose message mentions a closed connection", async () => {
|
|
126
|
+
const clientAbort = new DOMException("The connection was closed.", "AbortError");
|
|
127
|
+
const db = fakeClient([clientAbort]);
|
|
128
|
+
await expect(selectMany(db, table)).rejects.toBe(clientAbort);
|
|
129
|
+
expect(db.calls).toBe(1);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("does not retry generic query errors", async () => {
|
|
133
|
+
const syntax = Object.assign(new Error("syntax error at or near"), { name: "PostgresError" });
|
|
134
|
+
const db = fakeClient([syntax]);
|
|
135
|
+
await expect(selectMany(db, table)).rejects.toThrow("syntax error");
|
|
136
|
+
expect(db.calls).toBe(1);
|
|
137
|
+
});
|
|
138
|
+
});
|
package/src/bun-db/query.ts
CHANGED
|
@@ -729,26 +729,41 @@ function buildWhereClause(
|
|
|
729
729
|
return { sqlText: conditions.join(" AND "), values };
|
|
730
730
|
}
|
|
731
731
|
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
|
|
735
|
-
//
|
|
736
|
-
|
|
737
|
-
//
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
const
|
|
732
|
+
// A pool can briefly keep handing out connections the server just closed.
|
|
733
|
+
// Each failed attempt discards one, so retries are bounded by pool size.
|
|
734
|
+
const CLOSED_CONNECTION_CODES: ReadonlySet<string> = new Set([
|
|
735
|
+
"CONNECTION_CLOSED", // postgres-js
|
|
736
|
+
"ERR_POSTGRES_CONNECTION_CLOSED", // Bun.SQL
|
|
737
|
+
"57P01", // PG SQLSTATE: admin_shutdown / terminated backend
|
|
738
|
+
]);
|
|
739
|
+
|
|
740
|
+
export function isClosedConnectionError(err: unknown): boolean {
|
|
741
|
+
const code = extractPgError(err)?.code;
|
|
742
|
+
return code !== undefined && CLOSED_CONNECTION_CODES.has(code);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Tx/reserved handles are pinned to one physical connection — a retry there
|
|
746
|
+
// would run on the same dead connection, not swap in a fresh one.
|
|
747
|
+
function isPooledClient(raw: unknown): boolean {
|
|
748
|
+
if (raw === null || (typeof raw !== "object" && typeof raw !== "function")) return false;
|
|
749
|
+
// @cast-boundary driver handle shape — begin/savepoint/release are optional across drivers
|
|
750
|
+
const r = raw as { begin?: unknown; savepoint?: unknown; release?: unknown };
|
|
742
751
|
return (
|
|
743
|
-
|
|
744
|
-
typeof
|
|
745
|
-
|
|
752
|
+
typeof r.begin === "function" &&
|
|
753
|
+
typeof r.savepoint !== "function" &&
|
|
754
|
+
typeof r.release !== "function"
|
|
746
755
|
);
|
|
747
756
|
}
|
|
748
757
|
|
|
758
|
+
function poolMaxOf(raw: unknown): number {
|
|
759
|
+
// @cast-boundary driver pool options — both postgres-js and Bun.SQL expose options.max
|
|
760
|
+
const max = (raw as { options?: { max?: unknown } }).options?.max;
|
|
761
|
+
return typeof max === "number" && Number.isInteger(max) && max > 0 ? max : 10;
|
|
762
|
+
}
|
|
763
|
+
|
|
749
764
|
// Exported so raw-SQL query modules outside bun-db (e.g. bundled-features'
|
|
750
|
-
// db/queries/*.ts) can opt into the same
|
|
751
|
-
// asRawClient(db).unsafe(...) directly and losing it.
|
|
765
|
+
// db/queries/*.ts) can opt into the same closed-connection retry instead of
|
|
766
|
+
// calling asRawClient(db).unsafe(...) directly and losing it.
|
|
752
767
|
// READS ONLY — retry re-executes the statement; never pass INSERT/UPDATE/DELETE.
|
|
753
768
|
export async function unsafeReadRetrying<TRow>(
|
|
754
769
|
db: AnyDb,
|
|
@@ -759,9 +774,18 @@ export async function unsafeReadRetrying<TRow>(
|
|
|
759
774
|
try {
|
|
760
775
|
return (await raw.unsafe(sqlText, params)) as readonly TRow[];
|
|
761
776
|
} catch (err) {
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
777
|
+
if (!isPooledClient(raw) || !isClosedConnectionError(err)) throw err;
|
|
778
|
+
const maxAttempts = poolMaxOf(raw) + 1;
|
|
779
|
+
let lastErr: unknown = err;
|
|
780
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
781
|
+
try {
|
|
782
|
+
return (await raw.unsafe(sqlText, params)) as readonly TRow[];
|
|
783
|
+
} catch (retryErr) {
|
|
784
|
+
lastErr = retryErr;
|
|
785
|
+
if (!isClosedConnectionError(retryErr)) throw retryErr;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
throw lastErr;
|
|
765
789
|
}
|
|
766
790
|
}
|
|
767
791
|
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,70 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.306.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "A lifecycle hook's own escapeHatch now gates ctx.systemDb.unsafeRaw (fw#3198)",
|
|
6
|
+
"migration": "Hooks, die in r.systemScope()-Handlern ctx.systemDb.unsafeRaw nutzen, deklarieren escapeHatch: { reason } in den r.hook-Optionen"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.306.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "BullMQ jobs no longer stay in Redis forever: completed jobs are kept 24h, failed jobs 7d (fw#3199)",
|
|
12
|
+
"detail": "Both job-runner lane queues now set age-only retention via defaultJobOptions, so dispatch(), handleEvent(), perTenant wrappers and children, cron, runOnBoot and sequential re-enqueues are all covered. BullMQ sweeps retention queue-wide, so there is no count limit and no per-job retention: the cron template's count-based removeOnComplete/removeOnFail is gone because it evicted boot jobs and perTenant children in the same queue. runOnBoot now dedupes via a persistent per-queue marker, so it still runs at most once per Redis dataset after its job hash ages out. On existing datasets a boot job re-runs once only if its job hash was already evicted. A perTenant job whose retry window reaches the completed retention now fails at job-runner construction. Cron iterations scheduled before the deploy keep the old template options for one more run."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.306.0",
|
|
16
|
+
"type": "fix",
|
|
17
|
+
"title": "Client disconnects answer 499 on queries and no longer abort writes",
|
|
18
|
+
"detail": "- Queries: a failure caused by this request's own abort signal now answers `499` and logs `[api] request aborted by client` on warn instead of a 5xx server fault.\n- Writes (`/api/write`, `/api/batch`, `command`): write dispatch no longer receives the request's abort signal, so a disconnect can't roll back a transaction halfway and leave a cached 500 under the request's idempotency key. `ctx.signal` is `undefined` inside write handlers and their hooks."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.306.0",
|
|
22
|
+
"type": "breaking",
|
|
23
|
+
"title": "createUncheckedSystemDb is no longer exported from /db; use createSystemDbView, whose unsafeRaw follows the source TenantDb's escapeHatch gate (fw#3205)",
|
|
24
|
+
"migration": "Import auf createSystemDbView umstellen; wer unsafeRaw auf einem selbstgebauten systemDb braucht, übergibt eine TenantDb mit unsafeRaw-Grant (createTenantDb(..., { unsafeRaw: { reason } })).\nDelivery: ein tenantUserIdsQuery-Handler ohne r.systemScope() bekommt jetzt wie im Dispatcher eine tenant-mode ctx.db und kein ctx.systemDb; Handler, die Cross-Tenant-Zugriff brauchen, deklarieren r.systemScope()."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"version": "0.306.0",
|
|
28
|
+
"type": "fix",
|
|
29
|
+
"title": "Feature-AST keeps referenced handler access/rateLimit headers",
|
|
30
|
+
"detail": "Handler headers (access, rateLimit, escapeHatch, agent on write/query/stream handlers, and escapeHatch on r.hook) authored as an imported or same-file const now round-trip verbatim through the feature AST instead of being silently dropped. `rateLimit: { disabled: true, reason }` is now extracted. streamHandler's escapeHatch is now extracted and rendered. A new ParseError: a fully literal header value with an unrecognized shape (used to silently drop the header instead). Handler calls whose object/options contain a spread, an unmodeled key (e.g. `outputSchema`, `perform`) or a non-literal options argument are now kept verbatim as an opaque pattern instead of losing those parts on render. `parsePatternChanges` accepts these reference and disabled-rate-limit shapes too."
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"version": "0.306.0",
|
|
34
|
+
"type": "improvement",
|
|
35
|
+
"title": "PatternChange gets an update op that changes individual handler header fields (access, rateLimit, description, agent, escapeHatch, unsafeSkipTransitionGuard) without resending schema or handler bodies",
|
|
36
|
+
"detail": "applyChanges/updatePattern edit only the named properties of a write/query/stream handler's inline object literal; bodies, comments and all other properties stay byte-identical. parsePatternChanges validates set/unset per handler kind with exact paths (access cannot be unset). escapeHatch.reason must now be non-empty in PatternChange input, matching the boot validator."
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"version": "0.306.0",
|
|
40
|
+
"type": "breaking",
|
|
41
|
+
"title": "r.useExtension options are typed per extension point; a hook with the wrong ctx signature is a compile error",
|
|
42
|
+
"migration": "Registrations of known extension points (tenantData, userData, fileProvider, derivativeRenderer, derivativeOverlayResolver, derivativePublicPredicate, principalStatus, tenantLifecycleStatus, tokenVerifier, sessionStore, tenantResolver, tenantExistence) now type-check their options, and options are required for them. Fix the reported mismatches: tenantData destroy hooks take TenantDataHookCtx and use ctx.db.* methods. For raw access such as archiveStream, declare escapeHatch: { reason } on the r.useExtension registration (runtime grant), call declareEscapeHatch({ reason }) as a direct statement in the hook body (Escape-Hatch-Declared guard), then use ctx.db.unsafeRaw(reason). userData registrations need at least one of export/delete (plus optional order). PrincipalStatusPlugin needs resolveProfile, FileProviderPlugin fakes need list(). App-owned points can opt in by augmenting KumikoExtensionOptionsMap via declare module \"@cosmicdrift/kumiko-framework/engine\"; unknown names keep the untyped options bag."
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"version": "0.306.0",
|
|
46
|
+
"type": "fix",
|
|
47
|
+
"title": "Read retry recognizes real closed pool connections instead of client aborts",
|
|
48
|
+
"detail": "The closed-connection read retry now checks driver error codes (postgres-js CONNECTION_CLOSED, Bun ERR_POSTGRES_CONNECTION_CLOSED, SQLSTATE 57P01) instead of an AbortError name and message. It retries up to pool size plus one attempt, never retries a genuine client abort, and never retries on a transaction or reserved handle. extractPgError/isUniqueViolation/constraintOf now also work against Bun.SQL errors."
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"version": "0.306.0",
|
|
52
|
+
"type": "breaking",
|
|
53
|
+
"title": "Tenant-resource and tenantTierResolver extension options are typed; invalid registrations fail tenant destroy loudly",
|
|
54
|
+
"migration": "Registrations of storageProvider, searchAdapter, externalResource and infraResource now require options of type TenantResourceExtensionHooks (destroyTenant(tenantId, ctx) => Promise<void>); tenantTierResolver requires a TierResolverPlugin with build. StorageProvider* types remain as aliases of the new TenantResource* types. A tenantData or tenant-resource registration whose destroy hook is missing now fails the destruction stage with the extension and entity name instead of being skipped."
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"version": "0.306.0",
|
|
58
|
+
"type": "breaking",
|
|
59
|
+
"title": "Idempotent retries re-run after a rolled-back 5xx instead of replaying it",
|
|
60
|
+
"detail": "Because the retry re-runs, non-transactional side effects of the failed attempt (writes through `ctx.dbOutsideTransaction`, external calls made inside the handler) run again.\n`IdempotencyGuard` has a new required method `release(tenantId, userId, requestId, token)` that frees the in-progress lock (token-guarded, like `store`) instead of persisting a result.",
|
|
61
|
+
"migration": "Custom IdempotencyGuard implementations must add release(tenantId, userId, requestId, token), which deletes the pending lock only if it still holds that token"
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"version": "0.306.0",
|
|
65
|
+
"type": "fix",
|
|
66
|
+
"title": "Cron, runOnBoot, perTenant and sequential re-enqueued jobs now retry per their retries/backoff (fw#3184)"
|
|
67
|
+
},
|
|
2
68
|
{
|
|
3
69
|
"version": "0.305.0",
|
|
4
70
|
"type": "breaking",
|