@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.
Files changed (60) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/server-boot-guards.test.ts +1 -0
  3. package/src/api/__tests__/server-error-logging.test.ts +71 -0
  4. package/src/api/request-context.ts +5 -4
  5. package/src/api/routes.ts +26 -1
  6. package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
  7. package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
  8. package/src/bun-db/query.ts +42 -18
  9. package/src/changes.json +66 -0
  10. package/src/db/__tests__/pg-error.test.ts +14 -0
  11. package/src/db/__tests__/system-db-view-export.test.ts +107 -0
  12. package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
  13. package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
  14. package/src/db/index.ts +1 -1
  15. package/src/db/pg-error.ts +13 -0
  16. package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
  17. package/src/db/tenant-db.ts +90 -13
  18. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
  19. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
  20. package/src/engine/__tests__/boot-validator.test.ts +1 -1
  21. package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
  22. package/src/engine/extension-names.ts +55 -25
  23. package/src/engine/extensions/storage-provider.ts +14 -41
  24. package/src/engine/extensions/tenant-data.ts +4 -0
  25. package/src/engine/extensions/tenant-resource.ts +40 -0
  26. package/src/engine/extensions/user-data.ts +8 -7
  27. package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
  28. package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
  29. package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
  30. package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
  31. package/src/engine/feature-ast/entity-field-types.ts +41 -0
  32. package/src/engine/feature-ast/extractors/handlers.ts +217 -84
  33. package/src/engine/feature-ast/extractors/hooks.ts +72 -15
  34. package/src/engine/feature-ast/extractors/round2.ts +21 -0
  35. package/src/engine/feature-ast/extractors/shared.ts +9 -0
  36. package/src/engine/feature-ast/index.ts +11 -1
  37. package/src/engine/feature-ast/patch.ts +338 -5
  38. package/src/engine/feature-ast/patcher.ts +2 -2
  39. package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
  40. package/src/engine/feature-ast/patterns.ts +22 -15
  41. package/src/engine/feature-ast/render.ts +1 -0
  42. package/src/engine/feature-ui-extensions.ts +8 -7
  43. package/src/engine/index.ts +21 -5
  44. package/src/engine/types/extension-options-map.ts +1 -0
  45. package/src/engine/types/index.ts +6 -0
  46. package/src/jobs/__tests__/job-retention.integration.test.ts +316 -0
  47. package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
  48. package/src/jobs/__tests__/jobs.integration.test.ts +3 -3
  49. package/src/jobs/job-runner.ts +151 -14
  50. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
  51. package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
  52. package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
  53. package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
  54. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
  55. package/src/pipeline/dispatch-batch.ts +56 -13
  56. package/src/pipeline/idempotency.ts +16 -0
  57. package/src/pipeline/system-identity-switch.ts +22 -4
  58. package/src/testing/closed-connection-error.ts +62 -0
  59. package/src/testing/index.ts +1 -0
  60. 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";
@@ -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
- // #2323: event-store.ts's and projection-rebuild.ts's plain SELECT helpers
2
- // read via asRawClient(db).unsafe() directly, bypassing the #1163
3
- // closed-connection retry that only covered bun-db/query.ts's own
4
- // selectMany/countWhere. Routed the non-locking read call sites through
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
- function closedConnectionError(): Error {
26
- return Object.assign(new Error("The connection was closed."), { name: "AbortError" });
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: Error[], row: Record<string, unknown>): FakeClient {
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 (#2323)", () => {
55
+ describe("framework db/queries — closed-connection retry", () => {
58
56
  test("selectStreamMaxVersion retries once and returns the version", async () => {
59
- const db = fakeClient([closedConnectionError()], { v: 5 });
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()], { v: 7 });
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()], { max: 42n });
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()], { id: 43n });
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()], { id: "1", type: "created" });
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()], { n: 12n });
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 the single retry when the connection stays closed", async () => {
109
- const db = fakeClient([closedConnectionError(), closedConnectionError()], { v: 5 });
110
- await expect(selectStreamMaxVersion(db as never, "agg1", "t1")).rejects.toThrow(
111
- "connection was closed",
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(2);
113
+ expect(db.calls).toBe(3);
114
114
  });
115
115
  });
@@ -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 or " +
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
- // buildHandlerContext (pipeline/dispatch-shared.ts) always builds "system"
84
- // mode from the caller's own tenantId, never a foreign one.
85
- //
86
- // dbOutsideTransaction is optional so every existing single-arg call site
87
- // (jobs, tests, delivery-service.ts) keeps compiling — those callers have no
88
- // outside-tx source to hand in and never needed one. Only
89
- // buildHandlerContext passes it, which is also the only place `.outsideTransaction`
90
- // is reachable through `ctx.systemDb`.
91
- export function createUncheckedSystemDb(
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?: TenantDb,
94
- report: EscapeHatchReporter = fallbackEscapeHatchReporter(db.tenantId),
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
- r.useExtension("fileProvider", name);
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