@cosmicdrift/kumiko-framework 0.304.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__/extra-route-rejection.test.ts +38 -0
- package/src/api/__tests__/extra-routes.integration.test.ts +30 -0
- package/src/api/__tests__/server-boot-guards.test.ts +1 -0
- package/src/api/__tests__/server-error-logging.test.ts +104 -0
- package/src/api/api-constants.ts +13 -0
- package/src/api/extra-route.ts +33 -4
- package/src/api/index.ts +1 -0
- package/src/api/request-context.ts +5 -4
- package/src/api/routes.ts +26 -1
- package/src/api/server.ts +8 -2
- 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 +108 -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/event-store-executor-write.ts +7 -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 +140 -16
- 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/boot-validator/access-declarations.ts +5 -66
- 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 +23 -5
- package/src/engine/personal-data-fields.ts +66 -0
- package/src/engine/registry-validate.ts +15 -0
- package/src/engine/registry.ts +2 -0
- package/src/engine/types/extension-options-map.ts +1 -0
- package/src/engine/types/index.ts +8 -0
- package/src/env/__tests__/dry-run.test.ts +43 -3
- package/src/env/dry-run.ts +28 -15
- package/src/errors/__tests__/write-failures.test.ts +47 -4
- package/src/errors/i18n/de.yaml +12 -0
- package/src/errors/i18n/en.yaml +12 -0
- package/src/errors/reasons.ts +4 -0
- package/src/errors/write-error-info.ts +12 -3
- package/src/jobs/__tests__/job-backoff.integration.test.ts +155 -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 +38 -3
- package/src/jobs/job-runner.ts +170 -19
- 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__/public-intake-runtime-gate.integration.test.ts +425 -0
- package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
- package/src/pipeline/active-membership.ts +5 -1
- package/src/pipeline/dispatch-batch.ts +59 -13
- package/src/pipeline/dispatch-query.ts +16 -5
- package/src/pipeline/dispatch-shared.ts +12 -5
- package/src/pipeline/dispatch-stream.ts +7 -2
- package/src/pipeline/dispatch-write.ts +22 -5
- package/src/pipeline/dispatcher.ts +9 -2
- package/src/pipeline/idempotency.ts +16 -0
- package/src/pipeline/member-reader.ts +3 -1
- package/src/pipeline/system-identity-switch.ts +22 -4
- package/src/pipeline/write-origin.ts +107 -0
- package/src/rate-limit/__tests__/middleware.integration.test.ts +40 -0
- package/src/rate-limit/middleware.ts +3 -0
- package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +79 -0
- package/src/stack/test-stack.ts +5 -0
- 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/src/stack/test-stack.ts
CHANGED
|
@@ -110,6 +110,10 @@ export type TestStackOptions = {
|
|
|
110
110
|
* MUST go through here (real HTTP via `stack.http`/`stack.app.fetch`),
|
|
111
111
|
* never `createTestDispatcher`. */
|
|
112
112
|
extraRoutes?: import("../api/server").ServerOptions["extraRoutes"];
|
|
113
|
+
/** Forwarded to buildServer like runProdApp. Without a PrometheusMeter-backed
|
|
114
|
+
* `observability` the route answers 503, so spread `resolveObservabilityWiring(token)`
|
|
115
|
+
* rather than setting `metrics` alone. */
|
|
116
|
+
metrics?: import("../api/server").ServerOptions["metrics"];
|
|
113
117
|
/** Inject a MasterKeyProvider for secrets-backed tests. Lands typed in
|
|
114
118
|
* AppContext — set/delete/get + rotation job pick it up. Omit for
|
|
115
119
|
* suites that don't touch secrets. */
|
|
@@ -410,6 +414,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
410
414
|
eventDedup,
|
|
411
415
|
sseBroker,
|
|
412
416
|
...(options.extraRoutes && { extraRoutes: options.extraRoutes }),
|
|
417
|
+
...(options.metrics && { metrics: options.metrics }),
|
|
413
418
|
// Tests drive the dispatcher via stack.eventDispatcher.runOnce() for
|
|
414
419
|
// deterministic drains — no timer-induced flakiness. pollIntervalMs
|
|
415
420
|
// stays short anyway in case a test opts into `.start()`. pgClient
|
|
@@ -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
|
-
});
|