@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
|
@@ -18,6 +18,20 @@ describe("extractPgError", () => {
|
|
|
18
18
|
test("returns null for non-objects", () => {
|
|
19
19
|
expect(extractPgError("nope")).toBeNull();
|
|
20
20
|
});
|
|
21
|
+
|
|
22
|
+
test("normalizes Bun.SQL's errno/constraint into code/constraint_name", () => {
|
|
23
|
+
const info = extractPgError({
|
|
24
|
+
code: "ERR_POSTGRES_SERVER_ERROR",
|
|
25
|
+
errno: "23505",
|
|
26
|
+
constraint: "users_email_uq",
|
|
27
|
+
});
|
|
28
|
+
expect(info).toEqual({ code: "23505", constraint_name: "users_email_uq" });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("leaves ERR_POSTGRES_SERVER_ERROR untouched when errno isn't a string", () => {
|
|
32
|
+
const info = extractPgError({ code: "ERR_POSTGRES_SERVER_ERROR" });
|
|
33
|
+
expect(info).toEqual({ code: "ERR_POSTGRES_SERVER_ERROR", constraint_name: undefined });
|
|
34
|
+
});
|
|
21
35
|
});
|
|
22
36
|
|
|
23
37
|
describe("isUniqueViolation", () => {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// createUncheckedSystemDb() is framework-private (not re-exported from
|
|
2
|
+
// db/index.ts); createSystemDbView is the public replacement, whose
|
|
3
|
+
// unsafeRaw follows the source TenantDb's own escapeHatch gate instead of
|
|
4
|
+
// handing out an ungated tenantDbRunner(db).
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import * as dbBarrel from "@cosmicdrift/kumiko-framework/db";
|
|
8
|
+
import { createSystemDbView, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
9
|
+
import { AccessDeniedError } from "../../errors";
|
|
10
|
+
import { testTenantId } from "../../stack";
|
|
11
|
+
import type { DbRunner } from "../connection";
|
|
12
|
+
import { unsafeRawForDeclaredStep, withSystemDbUnsafeRawGrant } from "../tenant-db";
|
|
13
|
+
|
|
14
|
+
const tenantId = testTenantId(1);
|
|
15
|
+
|
|
16
|
+
function fakeRunner(): DbRunner {
|
|
17
|
+
return {
|
|
18
|
+
unsafe: async () => {
|
|
19
|
+
throw new Error("system-db-view-export tests must not reach the DB");
|
|
20
|
+
},
|
|
21
|
+
begin: async () => {
|
|
22
|
+
throw new Error("system-db-view-export tests must not reach the DB");
|
|
23
|
+
},
|
|
24
|
+
} as unknown as DbRunner;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("db barrel export", () => {
|
|
28
|
+
test("createUncheckedSystemDb is no longer exported from @cosmicdrift/kumiko-framework/db", () => {
|
|
29
|
+
expect("createUncheckedSystemDb" in dbBarrel).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("createSystemDbView(...).unsafeRaw", () => {
|
|
34
|
+
test("denies without a grant on the source TenantDb, without reporting on either side", () => {
|
|
35
|
+
const sourceReports: Array<{ kind: string; reason: string }> = [];
|
|
36
|
+
const viewReports: Array<{ kind: string; reason: string }> = [];
|
|
37
|
+
const source = createTenantDb(
|
|
38
|
+
fakeRunner(),
|
|
39
|
+
tenantId,
|
|
40
|
+
"tenant",
|
|
41
|
+
undefined,
|
|
42
|
+
undefined,
|
|
43
|
+
undefined,
|
|
44
|
+
{ report: (kind, reason) => sourceReports.push({ kind, reason }) },
|
|
45
|
+
);
|
|
46
|
+
const view = createSystemDbView(source, undefined, (kind, reason) =>
|
|
47
|
+
viewReports.push({ kind, reason }),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
expect(() => view.unsafeRaw("x")).toThrow(AccessDeniedError);
|
|
51
|
+
expect(sourceReports).toEqual([]);
|
|
52
|
+
expect(viewReports).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("resolves to the source's own bound runner and reports through the source, not the view", () => {
|
|
56
|
+
const runner = fakeRunner();
|
|
57
|
+
const sourceReports: Array<{ kind: string; reason: string }> = [];
|
|
58
|
+
const viewReports: Array<{ kind: string; reason: string }> = [];
|
|
59
|
+
const source = createTenantDb(runner, tenantId, "tenant", undefined, undefined, undefined, {
|
|
60
|
+
unsafeRaw: { reason: "handler declared unsafeRaw" },
|
|
61
|
+
report: (kind, reason) => sourceReports.push({ kind, reason }),
|
|
62
|
+
});
|
|
63
|
+
const view = createSystemDbView(source, undefined, (kind, reason) =>
|
|
64
|
+
viewReports.push({ kind, reason }),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
expect(view.unsafeRaw("x")).toBe(runner);
|
|
68
|
+
expect(sourceReports).toEqual([{ kind: "unsafe-raw", reason: "x" }]);
|
|
69
|
+
expect(viewReports).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("memberReadOnly on the source denies even with an unsafeRaw grant", () => {
|
|
73
|
+
const source = createTenantDb(
|
|
74
|
+
fakeRunner(),
|
|
75
|
+
tenantId,
|
|
76
|
+
"tenant",
|
|
77
|
+
undefined,
|
|
78
|
+
undefined,
|
|
79
|
+
undefined,
|
|
80
|
+
{ unsafeRaw: { reason: "handler declared unsafeRaw" }, memberReadOnly: true },
|
|
81
|
+
);
|
|
82
|
+
const view = createSystemDbView(source);
|
|
83
|
+
|
|
84
|
+
expect(() => view.unsafeRaw("x")).toThrow(AccessDeniedError);
|
|
85
|
+
expect(() => view.unsafeRaw("x")).toThrow(/read-only/);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("unsafeRawForDeclaredStep(createSystemDbView(...))", () => {
|
|
90
|
+
test("throws AccessDeniedError, not InternalError, without a grant on the source", () => {
|
|
91
|
+
const source = createTenantDb(fakeRunner(), tenantId, "tenant");
|
|
92
|
+
const view = createSystemDbView(source);
|
|
93
|
+
|
|
94
|
+
expect(() => unsafeRawForDeclaredStep(view, "x")).toThrow(AccessDeniedError);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("withSystemDbUnsafeRawGrant(createSystemDbView(...))", () => {
|
|
99
|
+
test("returns the view unchanged — no rebind, so no rights upgrade over the source's own gate", () => {
|
|
100
|
+
const source = createTenantDb(fakeRunner(), tenantId, "tenant");
|
|
101
|
+
const view = createSystemDbView(source);
|
|
102
|
+
|
|
103
|
+
const rebound = withSystemDbUnsafeRawGrant(view, { reason: "hook grant" }, "some hook");
|
|
104
|
+
expect(rebound).toBe(view);
|
|
105
|
+
expect(() => rebound.unsafeRaw("x")).toThrow(AccessDeniedError);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -3,6 +3,7 @@ import type { TenantDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
|
3
3
|
import { InternalError } from "../../errors";
|
|
4
4
|
import { testTenantId } from "../../stack";
|
|
5
5
|
import type { DbRunner } from "../connection";
|
|
6
|
+
import { asRawClient } from "../query";
|
|
6
7
|
import { createTenantDb, createUncheckedSystemDb, withUnsafeRawGrant } from "../tenant-db";
|
|
7
8
|
import { tenantDbRunner } from "../tenant-db-runner";
|
|
8
9
|
|
|
@@ -26,6 +27,15 @@ describe("TenantDb has no .raw", () => {
|
|
|
26
27
|
// @ts-expect-error TenantDb no longer exposes `raw`.
|
|
27
28
|
expect(tdb.raw).toBeUndefined();
|
|
28
29
|
});
|
|
30
|
+
|
|
31
|
+
test("createTenantDb rejects an already tenant-scoped TenantDb: compile error, and raw access throws", () => {
|
|
32
|
+
const tdb = createTenantDb(fakeRunner(), tenantId);
|
|
33
|
+
expect(() => {
|
|
34
|
+
// @ts-expect-error TenantDb is already tenant-scoped; createTenantDb only accepts a raw DbRunner.
|
|
35
|
+
const rewrapped = createTenantDb(tdb, tenantId);
|
|
36
|
+
asRawClient(tenantDbRunner(rewrapped));
|
|
37
|
+
}).toThrow();
|
|
38
|
+
});
|
|
29
39
|
});
|
|
30
40
|
|
|
31
41
|
describe("tenantDbRunner", () => {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// fw#3198 — unit coverage for withSystemDbUnsafeRawGrant: passthrough for
|
|
2
|
+
// unknown values, no stacking across repeated rebinds, and the gated runner
|
|
3
|
+
// registered under declaredUnsafeRawRunners (unsafeRawForDeclaredStep).
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import type { UncheckedSystemDb } from "@cosmicdrift/kumiko-types/tenant-db-types";
|
|
7
|
+
import { AccessDeniedError } from "../../errors";
|
|
8
|
+
import { testTenantId } from "../../stack";
|
|
9
|
+
import type { DbRunner } from "../connection";
|
|
10
|
+
import {
|
|
11
|
+
createTenantDb,
|
|
12
|
+
createUncheckedSystemDb,
|
|
13
|
+
unsafeRawForDeclaredStep,
|
|
14
|
+
withSystemDbUnsafeRawGrant,
|
|
15
|
+
} from "../tenant-db";
|
|
16
|
+
|
|
17
|
+
const tenantId = testTenantId(1);
|
|
18
|
+
|
|
19
|
+
function fakeRunner(): DbRunner {
|
|
20
|
+
return {
|
|
21
|
+
unsafe: async () => [],
|
|
22
|
+
begin: async () => {
|
|
23
|
+
throw new Error("begin not used in these tests");
|
|
24
|
+
},
|
|
25
|
+
} as unknown as DbRunner;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("withSystemDbUnsafeRawGrant", () => {
|
|
29
|
+
test("passes through a value not built by createUncheckedSystemDb, unchanged", () => {
|
|
30
|
+
const notBuilt = { unsafeRaw: () => fakeRunner() } as unknown as UncheckedSystemDb;
|
|
31
|
+
const rebound = withSystemDbUnsafeRawGrant(notBuilt, { reason: "x" }, "some hook");
|
|
32
|
+
expect(rebound).toBe(notBuilt);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("without a grant, unsafeRaw denies with AccessDeniedError naming the caller", () => {
|
|
36
|
+
const runner = fakeRunner();
|
|
37
|
+
const tdb = createTenantDb(runner, tenantId, "system");
|
|
38
|
+
const systemDb = createUncheckedSystemDb(tdb);
|
|
39
|
+
const gated = withSystemDbUnsafeRawGrant(systemDb, undefined, 'postSave hook of feature "x"');
|
|
40
|
+
expect(() => gated.unsafeRaw("a reason")).toThrow(AccessDeniedError);
|
|
41
|
+
expect(() => gated.unsafeRaw("a reason")).toThrow(/postSave hook of feature "x"/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("with a grant, unsafeRaw resolves to the original runner", () => {
|
|
45
|
+
const runner = fakeRunner();
|
|
46
|
+
const tdb = createTenantDb(runner, tenantId, "system");
|
|
47
|
+
const systemDb = createUncheckedSystemDb(tdb);
|
|
48
|
+
const gated = withSystemDbUnsafeRawGrant(systemDb, { reason: "hook reason" }, "hook");
|
|
49
|
+
expect(gated.unsafeRaw("hook reason")).toBe(runner);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("repeated rebinding is not stacked — a second rebind reflects only its own grant", () => {
|
|
53
|
+
const runner = fakeRunner();
|
|
54
|
+
const tdb = createTenantDb(runner, tenantId, "system");
|
|
55
|
+
const systemDb = createUncheckedSystemDb(tdb);
|
|
56
|
+
const grantedFirst = withSystemDbUnsafeRawGrant(systemDb, { reason: "first" }, "hook-1");
|
|
57
|
+
const deniedSecond = withSystemDbUnsafeRawGrant(grantedFirst, undefined, "hook-2");
|
|
58
|
+
expect(() => deniedSecond.unsafeRaw("first")).toThrow(AccessDeniedError);
|
|
59
|
+
// Re-granting from the ORIGINAL systemDb (not stacked through grantedFirst) still works.
|
|
60
|
+
const grantedThird = withSystemDbUnsafeRawGrant(systemDb, { reason: "third" }, "hook-3");
|
|
61
|
+
expect(grantedThird.unsafeRaw("third")).toBe(runner);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("the gated runner is registered for unsafeRawForDeclaredStep", () => {
|
|
65
|
+
const runner = fakeRunner();
|
|
66
|
+
const tdb = createTenantDb(runner, tenantId, "system");
|
|
67
|
+
const systemDb = createUncheckedSystemDb(tdb);
|
|
68
|
+
const gated = withSystemDbUnsafeRawGrant(systemDb, { reason: "hook reason" }, "hook");
|
|
69
|
+
expect(unsafeRawForDeclaredStep(gated, "hook reason")).toBe(runner);
|
|
70
|
+
});
|
|
71
|
+
});
|
package/src/db/index.ts
CHANGED
|
@@ -155,7 +155,7 @@ export {
|
|
|
155
155
|
export type { TenantDb, TenantDbGrants, TenantDbMode, UncheckedSystemDb } from "./tenant-db";
|
|
156
156
|
export {
|
|
157
157
|
castTenantRows,
|
|
158
|
+
createSystemDbView,
|
|
158
159
|
createTenantDb,
|
|
159
|
-
createUncheckedSystemDb,
|
|
160
160
|
SYSTEM_SCOPE_CHECK_BRAND,
|
|
161
161
|
} from "./tenant-db";
|
package/src/db/pg-error.ts
CHANGED
|
@@ -21,6 +21,19 @@ export function extractPgError(e: unknown): PgErrorInfo | null {
|
|
|
21
21
|
// @cast-boundary error-details — postgres-js error shape (code, constraint_name)
|
|
22
22
|
const code = (layer as { code?: string }).code;
|
|
23
23
|
const constraintName = (layer as { constraint_name?: string }).constraint_name; // @cast-boundary error-details
|
|
24
|
+
// Bun.SQL carries the SQLSTATE in `errno` (string) and the constraint name
|
|
25
|
+
// in `constraint`, not in postgres-js's `code`/`constraint_name`.
|
|
26
|
+
if (code === "ERR_POSTGRES_SERVER_ERROR") {
|
|
27
|
+
const errno = (layer as { errno?: unknown }).errno; // @cast-boundary error-details
|
|
28
|
+
const constraint = (layer as { constraint?: unknown }).constraint; // @cast-boundary error-details
|
|
29
|
+
if (typeof errno === "string") {
|
|
30
|
+
return {
|
|
31
|
+
code: errno,
|
|
32
|
+
constraint_name:
|
|
33
|
+
constraintName ?? (typeof constraint === "string" ? constraint : undefined),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
24
37
|
if (code !== undefined || constraintName !== undefined) {
|
|
25
38
|
return { code, constraint_name: constraintName };
|
|
26
39
|
}
|
|
@@ -1,16 +1,10 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// unsafeReadRetrying instead — this test mirrors
|
|
6
|
-
// bun-db/__tests__/select-many-retry.test.ts's fake-client pattern to prove
|
|
7
|
-
// the retry now fires. Writes (insertSubsequentEventRow, upsertSnapshot,
|
|
8
|
-
// markProjectionRebuilding, ...) stay unretried per #1358, and the
|
|
9
|
-
// FOR UPDATE / FOR UPDATE SKIP LOCKED reads in event-consumer.ts are always
|
|
10
|
-
// called inside transaction() (verified against their only call sites) — the
|
|
11
|
-
// retry guard there is a no-op, so those are left as asRawClient calls too.
|
|
1
|
+
// event-store.ts's and projection-rebuild.ts's SELECT helpers run through
|
|
2
|
+
// unsafeReadRetrying; this proves the retry fires using a real captured
|
|
3
|
+
// driver error. Writes and the FOR UPDATE reads inside transaction() stay
|
|
4
|
+
// out of scope — unretried by design.
|
|
12
5
|
|
|
13
|
-
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import { captureClosedConnectionError } from "../../../testing/closed-connection-error";
|
|
14
8
|
import {
|
|
15
9
|
selectAggregateMaxVersion,
|
|
16
10
|
selectEventsHighWaterMark,
|
|
@@ -22,24 +16,28 @@ import {
|
|
|
22
16
|
selectEventsForProjectionRebuildBatch,
|
|
23
17
|
} from "../projection-rebuild";
|
|
24
18
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
19
|
+
let closedConnectionError: unknown;
|
|
20
|
+
|
|
21
|
+
beforeAll(async () => {
|
|
22
|
+
closedConnectionError = await captureClosedConnectionError();
|
|
23
|
+
});
|
|
28
24
|
|
|
29
25
|
type RecordedCall = { readonly sql: string; readonly params: readonly unknown[] | undefined };
|
|
30
26
|
|
|
31
27
|
type FakeClient = {
|
|
32
28
|
unsafe: (sql: string, params?: readonly unknown[]) => Promise<readonly unknown[]>;
|
|
33
29
|
begin: () => never;
|
|
30
|
+
options: { max: number };
|
|
34
31
|
calls: number;
|
|
35
32
|
recordedCalls: RecordedCall[];
|
|
36
33
|
};
|
|
37
34
|
|
|
38
|
-
function fakeClient(failures:
|
|
35
|
+
function fakeClient(failures: unknown[], row: Record<string, unknown>): FakeClient {
|
|
39
36
|
const remaining = [...failures];
|
|
40
37
|
const client: FakeClient = {
|
|
41
38
|
calls: 0,
|
|
42
39
|
recordedCalls: [],
|
|
40
|
+
options: { max: 1 },
|
|
43
41
|
unsafe: async (sql, params) => {
|
|
44
42
|
client.calls++;
|
|
45
43
|
client.recordedCalls.push({ sql, params });
|
|
@@ -54,9 +52,9 @@ function fakeClient(failures: Error[], row: Record<string, unknown>): FakeClient
|
|
|
54
52
|
return client;
|
|
55
53
|
}
|
|
56
54
|
|
|
57
|
-
describe("framework db/queries — closed-connection retry
|
|
55
|
+
describe("framework db/queries — closed-connection retry", () => {
|
|
58
56
|
test("selectStreamMaxVersion retries once and returns the version", async () => {
|
|
59
|
-
const db = fakeClient([closedConnectionError
|
|
57
|
+
const db = fakeClient([closedConnectionError], { v: 5 });
|
|
60
58
|
const result = await selectStreamMaxVersion(db as never, "agg1", "t1");
|
|
61
59
|
expect(result).toBe(5);
|
|
62
60
|
expect(db.calls).toBe(2);
|
|
@@ -65,28 +63,28 @@ describe("framework db/queries — closed-connection retry (#2323)", () => {
|
|
|
65
63
|
});
|
|
66
64
|
|
|
67
65
|
test("selectAggregateMaxVersion retries once and returns the version", async () => {
|
|
68
|
-
const db = fakeClient([closedConnectionError
|
|
66
|
+
const db = fakeClient([closedConnectionError], { v: 7 });
|
|
69
67
|
const result = await selectAggregateMaxVersion(db as never, "agg1");
|
|
70
68
|
expect(result).toBe(7);
|
|
71
69
|
expect(db.calls).toBe(2);
|
|
72
70
|
});
|
|
73
71
|
|
|
74
72
|
test("selectEventsHighWaterMark retries once and returns the max id", async () => {
|
|
75
|
-
const db = fakeClient([closedConnectionError
|
|
73
|
+
const db = fakeClient([closedConnectionError], { max: 42n });
|
|
76
74
|
const result = await selectEventsHighWaterMark(db as never);
|
|
77
75
|
expect(result).toBe(42n);
|
|
78
76
|
expect(db.calls).toBe(2);
|
|
79
77
|
});
|
|
80
78
|
|
|
81
79
|
test("selectNextEventIdAfter retries once and returns the next id", async () => {
|
|
82
|
-
const db = fakeClient([closedConnectionError
|
|
80
|
+
const db = fakeClient([closedConnectionError], { id: 43n });
|
|
83
81
|
const result = await selectNextEventIdAfter(db as never, 42n);
|
|
84
82
|
expect(result).toBe(43n);
|
|
85
83
|
expect(db.calls).toBe(2);
|
|
86
84
|
});
|
|
87
85
|
|
|
88
86
|
test("selectEventsForProjectionRebuildBatch retries once and returns rows", async () => {
|
|
89
|
-
const db = fakeClient([closedConnectionError
|
|
87
|
+
const db = fakeClient([closedConnectionError], { id: "1", type: "created" });
|
|
90
88
|
const rows = await selectEventsForProjectionRebuildBatch(
|
|
91
89
|
db as never,
|
|
92
90
|
["user"],
|
|
@@ -99,17 +97,19 @@ describe("framework db/queries — closed-connection retry (#2323)", () => {
|
|
|
99
97
|
});
|
|
100
98
|
|
|
101
99
|
test("countSubscribedEvents retries once and returns the count", async () => {
|
|
102
|
-
const db = fakeClient([closedConnectionError
|
|
100
|
+
const db = fakeClient([closedConnectionError], { n: 12n });
|
|
103
101
|
const result = await countSubscribedEvents(db as never, ["user"], ["user:created"]);
|
|
104
102
|
expect(result).toBe(12n);
|
|
105
103
|
expect(db.calls).toBe(2);
|
|
106
104
|
});
|
|
107
105
|
|
|
108
|
-
test("gives up after
|
|
109
|
-
const db = fakeClient([closedConnectionError
|
|
110
|
-
|
|
111
|
-
|
|
106
|
+
test("gives up after exhausting pool-bounded retries (max: 1 → 3 total calls)", async () => {
|
|
107
|
+
const db = fakeClient([closedConnectionError, closedConnectionError, closedConnectionError], {
|
|
108
|
+
v: 5,
|
|
109
|
+
});
|
|
110
|
+
await expect(selectStreamMaxVersion(db as never, "agg1", "t1")).rejects.toBe(
|
|
111
|
+
closedConnectionError,
|
|
112
112
|
);
|
|
113
|
-
expect(db.calls).toBe(
|
|
113
|
+
expect(db.calls).toBe(3);
|
|
114
114
|
});
|
|
115
115
|
});
|
package/src/db/tenant-db.ts
CHANGED
|
@@ -73,25 +73,49 @@ export function unsafeRawForDeclaredStep(
|
|
|
73
73
|
if (!runner) {
|
|
74
74
|
throw new InternalError({
|
|
75
75
|
message:
|
|
76
|
-
"unsafeRawForDeclaredStep received a holder not built by createTenantDb
|
|
77
|
-
"createUncheckedSystemDb — no declared unsafeRaw runner bound.",
|
|
76
|
+
"unsafeRawForDeclaredStep received a holder not built by createTenantDb, " +
|
|
77
|
+
"createUncheckedSystemDb, or createSystemDbView — no declared unsafeRaw runner bound.",
|
|
78
78
|
});
|
|
79
79
|
}
|
|
80
80
|
return runner(reason);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
export function
|
|
83
|
+
const systemDbRebinders = new WeakMap<
|
|
84
|
+
UncheckedSystemDb,
|
|
85
|
+
(grant: EscapeHatchDeclaration | undefined, deniedCallerLabel: string) => UncheckedSystemDb
|
|
86
|
+
>();
|
|
87
|
+
|
|
88
|
+
// Rebinds a hook's own escapeHatch onto ctx.systemDb, mirroring withUnsafeRawGrant: always
|
|
89
|
+
// rebuilt from the original db/dbOutsideTransaction/report, never stacked onto a prior rebind.
|
|
90
|
+
// Inputs not built by createUncheckedSystemDb pass through unchanged.
|
|
91
|
+
export function withSystemDbUnsafeRawGrant(
|
|
92
|
+
systemDb: UncheckedSystemDb,
|
|
93
|
+
grant: EscapeHatchDeclaration | undefined,
|
|
94
|
+
deniedCallerLabel: string,
|
|
95
|
+
): UncheckedSystemDb {
|
|
96
|
+
const rebind = systemDbRebinders.get(systemDb);
|
|
97
|
+
return rebind ? rebind(grant, deniedCallerLabel) : systemDb;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Ungated when `gate` is absent (the handler's own ctx.systemDb — systemScope() is
|
|
101
|
+
// itself the grant there). Gated by a hook's own escapeHatch when `gate.kind` is
|
|
102
|
+
// "hook-grant": unsafeRaw then denies without `hasGrant(gate.grant)`, same error shape
|
|
103
|
+
// as ctx.db.unsafeRaw's denial in createTenantDb below. Gated by the source TenantDb's
|
|
104
|
+
// own escapeHatch when `gate.kind` is "source-tenant-db" (createSystemDbView): unsafeRaw
|
|
105
|
+
// defers entirely to db's own declared runner (reason/memberReadOnly/grant check and
|
|
106
|
+
// report) — the view's own `report` is never called for unsafe-raw in that mode, and a
|
|
107
|
+
// source not built by createTenantDb fails closed.
|
|
108
|
+
function buildUncheckedSystemDb(
|
|
92
109
|
db: TenantDb,
|
|
93
|
-
dbOutsideTransaction
|
|
94
|
-
report: EscapeHatchReporter
|
|
110
|
+
dbOutsideTransaction: TenantDb | undefined,
|
|
111
|
+
report: EscapeHatchReporter,
|
|
112
|
+
gate?:
|
|
113
|
+
| {
|
|
114
|
+
readonly kind: "hook-grant";
|
|
115
|
+
readonly grant: EscapeHatchDeclaration | undefined;
|
|
116
|
+
readonly deniedCallerLabel: string;
|
|
117
|
+
}
|
|
118
|
+
| { readonly kind: "source-tenant-db" },
|
|
95
119
|
): UncheckedSystemDb {
|
|
96
120
|
const allowedTenantIds: readonly TenantId[] = [db.tenantId, SYSTEM_TENANT_ID];
|
|
97
121
|
|
|
@@ -113,9 +137,19 @@ export function createUncheckedSystemDb(
|
|
|
113
137
|
}
|
|
114
138
|
|
|
115
139
|
function grantedUnsafeRawRunner(reason: string): DbRunner {
|
|
140
|
+
if (gate?.kind === "source-tenant-db") {
|
|
141
|
+
return unsafeRawForDeclaredStep(db, reason);
|
|
142
|
+
}
|
|
116
143
|
if (reason.trim().length === 0) {
|
|
117
144
|
throw new Error("unsafeRaw requires a non-empty reason");
|
|
118
145
|
}
|
|
146
|
+
if (gate && !hasGrant(gate.grant)) {
|
|
147
|
+
throw new AccessDeniedError({
|
|
148
|
+
message:
|
|
149
|
+
'ctx.systemDb.unsafeRaw(reason): rejected — declare `escapeHatch: { reason: "..." }` on ' +
|
|
150
|
+
`${gate.deniedCallerLabel} to allow unsafeRaw.`,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
119
153
|
report("unsafe-raw", reason);
|
|
120
154
|
return tenantDbRunner(db);
|
|
121
155
|
}
|
|
@@ -178,9 +212,52 @@ export function createUncheckedSystemDb(
|
|
|
178
212
|
},
|
|
179
213
|
};
|
|
180
214
|
declaredUnsafeRawRunners.set(uncheckedSystemDb, grantedUnsafeRawRunner);
|
|
215
|
+
if (gate?.kind !== "source-tenant-db") {
|
|
216
|
+
systemDbRebinders.set(uncheckedSystemDb, (grant, deniedCallerLabel) =>
|
|
217
|
+
buildUncheckedSystemDb(
|
|
218
|
+
withUnsafeRawGrant(db, grant),
|
|
219
|
+
dbOutsideTransaction && withUnsafeRawGrant(dbOutsideTransaction, grant),
|
|
220
|
+
report,
|
|
221
|
+
{ kind: "hook-grant", grant, deniedCallerLabel },
|
|
222
|
+
),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
181
225
|
return uncheckedSystemDb;
|
|
182
226
|
}
|
|
183
227
|
|
|
228
|
+
// Framework-private (not re-exported from db/index.ts): buildHandlerContext
|
|
229
|
+
// (pipeline/dispatch-shared.ts) always builds "system" mode from the caller's
|
|
230
|
+
// own tenantId, never a foreign one.
|
|
231
|
+
//
|
|
232
|
+
// dbOutsideTransaction is optional so every existing single-arg call site
|
|
233
|
+
// (jobs/job-runner.ts, tests) keeps compiling — those callers have no
|
|
234
|
+
// outside-tx source to hand in and never needed one. Only
|
|
235
|
+
// buildHandlerContext passes it, which is also the only place `.outsideTransaction`
|
|
236
|
+
// is reachable through `ctx.systemDb`.
|
|
237
|
+
//
|
|
238
|
+
// Ungated here (the handler's own systemScope() is the grant); a hook's own
|
|
239
|
+
// escapeHatch is layered on afterwards via withSystemDbUnsafeRawGrant. Public
|
|
240
|
+
// callers use createSystemDbView instead, whose unsafeRaw follows the source
|
|
241
|
+
// TenantDb's own escapeHatch gate.
|
|
242
|
+
export function createUncheckedSystemDb(
|
|
243
|
+
db: TenantDb,
|
|
244
|
+
dbOutsideTransaction?: TenantDb,
|
|
245
|
+
report: EscapeHatchReporter = fallbackEscapeHatchReporter(db.tenantId),
|
|
246
|
+
): UncheckedSystemDb {
|
|
247
|
+
return buildUncheckedSystemDb(db, dbOutsideTransaction, report);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Public: must never grant more raw access than the source TenantDb — unlike
|
|
251
|
+
// createUncheckedSystemDb (framework-private; r.systemScope()/a job IS the
|
|
252
|
+
// declaration), this view's unsafeRaw defers entirely to db's own escapeHatch gate.
|
|
253
|
+
export function createSystemDbView(
|
|
254
|
+
db: TenantDb,
|
|
255
|
+
dbOutsideTransaction?: TenantDb,
|
|
256
|
+
report: EscapeHatchReporter = fallbackEscapeHatchReporter(db.tenantId),
|
|
257
|
+
): UncheckedSystemDb {
|
|
258
|
+
return buildUncheckedSystemDb(db, dbOutsideTransaction, report, { kind: "source-tenant-db" });
|
|
259
|
+
}
|
|
260
|
+
|
|
184
261
|
// @cast-boundary tenant-db-row
|
|
185
262
|
export function castTenantRows<T>(rows: readonly Record<string, unknown>[]): readonly T[] {
|
|
186
263
|
return rows as unknown as readonly T[];
|
|
@@ -15,7 +15,12 @@ import { defineFeature } from "../define-feature";
|
|
|
15
15
|
const udr = () => defineFeature("user-data-rights", () => {});
|
|
16
16
|
const fileProvider = (name: string) =>
|
|
17
17
|
defineFeature(`file-provider-${name}`, (r) => {
|
|
18
|
-
|
|
18
|
+
// build is never invoked here — the boot check only cares that a provider is mounted.
|
|
19
|
+
r.useExtension("fileProvider", name, {
|
|
20
|
+
build: async () => {
|
|
21
|
+
throw new Error("test stub — never invoked");
|
|
22
|
+
},
|
|
23
|
+
});
|
|
19
24
|
});
|
|
20
25
|
|
|
21
26
|
const S3_ENV = ["S3_BUCKET", "S3_REGION", "S3_ACCESS_KEY", "S3_SECRET_KEY"] as const;
|
|
@@ -104,8 +104,8 @@ describe("S0 Integration — full surface stack", () => {
|
|
|
104
104
|
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
-
r.useExtension(EXT_USER_DATA, "user", {});
|
|
108
|
-
r.useExtension(EXT_USER_DATA, "comment", {});
|
|
107
|
+
r.useExtension(EXT_USER_DATA, "user", { export: async () => null });
|
|
108
|
+
r.useExtension(EXT_USER_DATA, "comment", { export: async () => null });
|
|
109
109
|
|
|
110
110
|
r.writeHandler({
|
|
111
111
|
name: "user:rename",
|
|
@@ -358,7 +358,7 @@ describe("boot-validator", () => {
|
|
|
358
358
|
const self = defineFeature("tier-stub", (r) => {
|
|
359
359
|
r.extendsRegistrar("tenantTierResolver", { onRegister: () => {} });
|
|
360
360
|
r.entity("dummy", createEntity({ table: "Dummies", fields: {} }));
|
|
361
|
-
r.useExtension("tenantTierResolver", "dummy");
|
|
361
|
+
r.useExtension("tenantTierResolver", "dummy", { build: async () => () => new Set() });
|
|
362
362
|
});
|
|
363
363
|
expect(() => validateBoot([self])).not.toThrow();
|
|
364
364
|
});
|
|
@@ -15,7 +15,7 @@ function tierResolverFeature(name: string) {
|
|
|
15
15
|
return defineFeature(name, (r) => {
|
|
16
16
|
r.extendsRegistrar(TENANT_TIER_RESOLVER_EXT, { onRegister: () => {} });
|
|
17
17
|
r.entity("dummy", createEntity({ table: "Dummies", fields: {} }));
|
|
18
|
-
r.useExtension(TENANT_TIER_RESOLVER_EXT, "dummy");
|
|
18
|
+
r.useExtension(TENANT_TIER_RESOLVER_EXT, "dummy", { build: async () => () => new Set() });
|
|
19
19
|
});
|
|
20
20
|
}
|
|
21
21
|
|