@cosmicdrift/kumiko-framework 0.305.0 → 0.307.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 (87) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/redis-sse-broker.integration.test.ts +66 -0
  3. package/src/api/__tests__/server-boot-guards.test.ts +1 -0
  4. package/src/api/__tests__/server-error-logging.test.ts +71 -0
  5. package/src/api/__tests__/sse-broker.test.ts +49 -0
  6. package/src/api/redis-sse-broker.ts +17 -3
  7. package/src/api/request-context.ts +29 -4
  8. package/src/api/routes.ts +26 -1
  9. package/src/api/sse-broker.ts +29 -11
  10. package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
  11. package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
  12. package/src/bun-db/query.ts +42 -18
  13. package/src/changes.json +92 -0
  14. package/src/db/__tests__/pg-error.test.ts +14 -0
  15. package/src/db/__tests__/system-db-view-export.test.ts +107 -0
  16. package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
  17. package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
  18. package/src/db/index.ts +1 -1
  19. package/src/db/pg-error.ts +13 -0
  20. package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
  21. package/src/db/queries/event-consumer.ts +57 -3
  22. package/src/db/queries/event-store.ts +69 -0
  23. package/src/db/tenant-db.ts +133 -18
  24. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
  25. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
  26. package/src/engine/__tests__/boot-validator.test.ts +1 -1
  27. package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
  28. package/src/engine/extension-names.ts +55 -25
  29. package/src/engine/extensions/storage-provider.ts +14 -41
  30. package/src/engine/extensions/tenant-data.ts +4 -0
  31. package/src/engine/extensions/tenant-resource.ts +40 -0
  32. package/src/engine/extensions/user-data.ts +8 -7
  33. package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
  34. package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
  35. package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
  36. package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
  37. package/src/engine/feature-ast/entity-field-types.ts +41 -0
  38. package/src/engine/feature-ast/extractors/handlers.ts +217 -84
  39. package/src/engine/feature-ast/extractors/hooks.ts +72 -15
  40. package/src/engine/feature-ast/extractors/round2.ts +21 -0
  41. package/src/engine/feature-ast/extractors/shared.ts +9 -0
  42. package/src/engine/feature-ast/index.ts +11 -1
  43. package/src/engine/feature-ast/patch.ts +338 -5
  44. package/src/engine/feature-ast/patcher.ts +2 -2
  45. package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
  46. package/src/engine/feature-ast/patterns.ts +22 -15
  47. package/src/engine/feature-ast/render.ts +1 -0
  48. package/src/engine/feature-ui-extensions.ts +8 -7
  49. package/src/engine/index.ts +21 -5
  50. package/src/engine/types/extension-options-map.ts +1 -0
  51. package/src/engine/types/index.ts +6 -0
  52. package/src/event-store/__tests__/event-attribution.integration.test.ts +53 -3
  53. package/src/event-store/admin-api.ts +5 -0
  54. package/src/event-store/event-store.ts +16 -7
  55. package/src/jobs/__tests__/job-public-intake-origin.integration.test.ts +536 -0
  56. package/src/jobs/__tests__/job-retention.integration.test.ts +316 -0
  57. package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
  58. package/src/jobs/__tests__/jobs.integration.test.ts +3 -3
  59. package/src/jobs/job-runner.ts +211 -19
  60. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
  61. package/src/pipeline/__tests__/dispatcher-utils.test.ts +8 -0
  62. package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
  63. package/src/pipeline/__tests__/event-dispatcher-commit-order.integration.test.ts +278 -0
  64. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +1 -0
  65. package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.test.ts +6 -6
  66. package/src/pipeline/__tests__/event-dispatcher-per-consumer-turns.integration.test.ts +126 -0
  67. package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
  68. package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
  69. package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +261 -2
  70. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
  71. package/src/pipeline/dispatch-batch.ts +102 -29
  72. package/src/pipeline/dispatch-stream.ts +7 -3
  73. package/src/pipeline/dispatcher-utils.ts +21 -2
  74. package/src/pipeline/dispatcher.ts +71 -6
  75. package/src/pipeline/event-consumer-state.ts +26 -0
  76. package/src/pipeline/event-dispatcher-admin.ts +32 -5
  77. package/src/pipeline/event-dispatcher-delivery.ts +109 -57
  78. package/src/pipeline/event-dispatcher.ts +167 -50
  79. package/src/pipeline/idempotency.ts +16 -0
  80. package/src/pipeline/pending-gap-ranges.ts +72 -0
  81. package/src/pipeline/system-hooks.ts +8 -1
  82. package/src/pipeline/system-identity-switch.ts +22 -4
  83. package/src/pipeline/write-origin.ts +31 -10
  84. package/src/stack/test-stack.ts +1 -1
  85. package/src/testing/closed-connection-error.ts +62 -0
  86. package/src/testing/index.ts +1 -0
  87. package/src/bun-db/__tests__/select-many-retry.test.ts +0 -79
@@ -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
  });
@@ -1,6 +1,30 @@
1
+ import type { PendingGapEntry } from "../../pipeline/event-consumer-state";
1
2
  import type { AnyDb } from "../query";
2
3
  import { asRawClient } from "../query";
3
4
 
5
+ // Per-turn snapshot bounds for pending-gap finality (event-dispatcher.ts's
6
+ // processConsumer). pg_current_snapshot() is this transaction's MVCC view;
7
+ // xmin is the oldest still-in-progress xact id in it, xmax the next
8
+ // unassigned one. Both travel as strings — bigint/xid8 doesn't round-trip
9
+ // through the driver as a JS number.
10
+ export async function selectSnapshotXmin(db: AnyDb): Promise<string> {
11
+ const rows = (await asRawClient(db).unsafe(
12
+ `SELECT pg_snapshot_xmin(pg_current_snapshot())::text AS xmin`,
13
+ )) as ReadonlyArray<{ xmin: string }>;
14
+ const xmin = rows[0]?.xmin;
15
+ if (xmin === undefined) throw new Error("selectSnapshotXmin: no row returned");
16
+ return xmin;
17
+ }
18
+
19
+ export async function selectSnapshotXmax(db: AnyDb): Promise<string> {
20
+ const rows = (await asRawClient(db).unsafe(
21
+ `SELECT pg_snapshot_xmax(pg_current_snapshot())::text AS xmax`,
22
+ )) as ReadonlyArray<{ xmax: string }>;
23
+ const xmax = rows[0]?.xmax;
24
+ if (xmax === undefined) throw new Error("selectSnapshotXmax: no row returned");
25
+ return xmax;
26
+ }
27
+
4
28
  /** Serialise against consumer-bootstrap INSERTs during event retention prune. */
5
29
  export async function lockEventConsumersShareMode(db: AnyDb): Promise<void> {
6
30
  await asRawClient(db).unsafe(`LOCK TABLE "kumiko_event_consumers" IN SHARE MODE`);
@@ -116,6 +140,7 @@ export type ConsumerDeliveryOutcome = {
116
140
  readonly lastError: string | null;
117
141
  readonly deadLettered: boolean;
118
142
  readonly processed: number;
143
+ readonly pendingGaps: readonly PendingGapEntry[];
119
144
  };
120
145
 
121
146
  export async function updateConsumerDeliveryOutcome(
@@ -136,14 +161,17 @@ export async function updateConsumerDeliveryOutcome(
136
161
  "status" = $3,
137
162
  "last_error" = $4,
138
163
  "rearm_count" = CASE WHEN $5 THEN 0 ELSE "rearm_count" END,
164
+ -- text param + cast: a JS string bound straight to ::jsonb double-encodes under Bun.SQL
165
+ "pending_gaps" = $6::text::jsonb,
139
166
  "updated_at" = now()
140
- WHERE "name" = $6 AND "instance_id" = $7`,
167
+ WHERE "name" = $7 AND "instance_id" = $8`,
141
168
  [
142
169
  outcome.cursor,
143
170
  outcome.attempts,
144
171
  outcome.deadLettered ? "dead" : "idle",
145
172
  outcome.lastError,
146
173
  resetRearmCount,
174
+ JSON.stringify(outcome.pendingGaps),
147
175
  name,
148
176
  instanceId,
149
177
  ],
@@ -192,13 +220,14 @@ export async function resetConsumerForMspRebuild(
192
220
  instanceId: string,
193
221
  ): Promise<void> {
194
222
  await asRawClient(db).unsafe(
195
- `INSERT INTO "kumiko_event_consumers" ("name", "instance_id", "last_processed_event_id", "status")
196
- VALUES ($1, $2, 0, 'idle')
223
+ `INSERT INTO "kumiko_event_consumers" ("name", "instance_id", "last_processed_event_id", "status", "pending_gaps")
224
+ VALUES ($1, $2, 0, 'idle', '[]'::jsonb)
197
225
  ON CONFLICT ("name", "instance_id") DO UPDATE SET
198
226
  "last_processed_event_id" = 0,
199
227
  "status" = 'idle',
200
228
  "attempts" = 0,
201
229
  "last_error" = NULL,
230
+ "pending_gaps" = '[]'::jsonb,
202
231
  "updated_at" = now()`,
203
232
  [name, instanceId],
204
233
  );
@@ -269,3 +298,28 @@ export async function rearmDeadConsumer(
269
298
  )) as ReadonlyArray<Record<string, unknown>>;
270
299
  return rows[0];
271
300
  }
301
+
302
+ // skipPoisonEvent's pending-gap branch: the poison is a pending id below the
303
+ // cursor, so it's removed from pending_gaps directly instead of advancing
304
+ // last_processed_event_id (advancing it here would be a regression — the
305
+ // cursor already sits above this id).
306
+ export async function removePendingGapReturning(
307
+ db: AnyDb,
308
+ name: string,
309
+ instanceId: string,
310
+ newPendingGaps: readonly PendingGapEntry[],
311
+ ): Promise<Record<string, unknown> | undefined> {
312
+ const rows = (await asRawClient(db).unsafe(
313
+ `UPDATE "kumiko_event_consumers" SET
314
+ "pending_gaps" = $1::text::jsonb,
315
+ "status" = 'idle',
316
+ "attempts" = 0,
317
+ "last_error" = NULL,
318
+ "rearm_count" = 0,
319
+ "updated_at" = now()
320
+ WHERE "name" = $2 AND "instance_id" = $3
321
+ RETURNING *`,
322
+ [JSON.stringify(newPendingGaps), name, instanceId],
323
+ )) as ReadonlyArray<Record<string, unknown>>;
324
+ return rows[0];
325
+ }
@@ -7,6 +7,15 @@ import {
7
7
  import type { AnyDb } from "../query";
8
8
  import { asRawClient, unsafeReadRetrying } from "../query";
9
9
 
10
+ // Gap-finality (event-dispatcher pending_gaps) needs every holder of an
11
+ // event id to already have a *real* xact id by the time it inserts — Postgres
12
+ // assigns those lazily on first write, so a bare INSERT alone doesn't
13
+ // guarantee one exists yet for comparison against a later snapshot's
14
+ // xmin/xmax. pg_current_xact_id() forces the allocation.
15
+ export async function claimXactId(db: AnyDb): Promise<void> {
16
+ await asRawClient(db).unsafe(`SELECT pg_current_xact_id()`);
17
+ }
18
+
10
19
  /** NOTIFY on commit — wakes LISTEN subscribers (event-dispatcher). */
11
20
  export async function notifyPgChannel(db: AnyDb, channel: string): Promise<void> {
12
21
  await asRawClient(db).unsafe(`SELECT pg_notify($1, '')`, [channel]);
@@ -283,3 +292,63 @@ export async function upsertArchivedStream(db: AnyDb, params: ArchiveStreamParam
283
292
  [params.tenantId, params.aggregateId, params.aggregateType, params.archivedBy, params.reason],
284
293
  );
285
294
  }
295
+
296
+ export type PendingIdRange = { readonly from: bigint; readonly to: bigint };
297
+
298
+ // Per-consumer turn fetch: the plain `id > cursor` window plus any ranges the
299
+ // consumer is still watching as pending gaps (ids invisible on an earlier
300
+ // turn that may have become visible since). Raw SQL — the typed builder's
301
+ // WhereObject is an AND of fields, it can't express this OR.
302
+ export async function selectPendingAndNewEventRows(
303
+ db: AnyDb,
304
+ cursor: bigint,
305
+ pendingRanges: readonly PendingIdRange[],
306
+ batchSize: number,
307
+ ): Promise<ReadonlyArray<Record<string, unknown>>> {
308
+ if (pendingRanges.length === 0) {
309
+ return unsafeReadRetrying(
310
+ db,
311
+ `SELECT * FROM "kumiko_events" WHERE "id" > $1 ORDER BY "id" ASC LIMIT $2`,
312
+ [cursor, batchSize],
313
+ );
314
+ }
315
+ return unsafeReadRetrying(
316
+ db,
317
+ // UNION ALL of two primary-key range scans: an `id > $1 OR EXISTS (…)`
318
+ // predicate can't use the index and would seq-scan kumiko_events per turn.
319
+ // Ranges sit below $1, so the branches never overlap.
320
+ `(SELECT e.* FROM unnest($2::bigint[], $3::bigint[]) AS g(f, t)
321
+ CROSS JOIN LATERAL (
322
+ SELECT * FROM "kumiko_events" WHERE "id" BETWEEN g.f AND g.t ORDER BY "id" ASC LIMIT $4
323
+ ) e)
324
+ UNION ALL
325
+ (SELECT * FROM "kumiko_events" WHERE "id" > $1 ORDER BY "id" ASC LIMIT $4)
326
+ ORDER BY "id" ASC LIMIT $4`,
327
+ [
328
+ cursor,
329
+ pendingRanges.map((r) => r.from.toString()),
330
+ pendingRanges.map((r) => r.to.toString()),
331
+ batchSize,
332
+ ],
333
+ );
334
+ }
335
+
336
+ // Smallest committed-and-visible id inside any pending range — lets
337
+ // skipPoisonEvent (event-dispatcher-admin.ts) tell a live poison apart from
338
+ // a still-invisible gap without pulling full rows.
339
+ export async function selectSmallestVisibleIdInRanges(
340
+ db: AnyDb,
341
+ pendingRanges: readonly PendingIdRange[],
342
+ ): Promise<bigint | null> {
343
+ if (pendingRanges.length === 0) return null;
344
+ const rows = (await unsafeReadRetrying(
345
+ db,
346
+ `SELECT MIN("id")::text AS id FROM "kumiko_events"
347
+ WHERE EXISTS (
348
+ SELECT 1 FROM unnest($1::bigint[], $2::bigint[]) AS g(f, t) WHERE "id" BETWEEN g.f AND g.t
349
+ )`,
350
+ [pendingRanges.map((r) => r.from.toString()), pendingRanges.map((r) => r.to.toString())],
351
+ )) as ReadonlyArray<{ id: string | null }>;
352
+ const id = rows[0]?.id;
353
+ return id === null || id === undefined ? null : BigInt(id);
354
+ }
@@ -51,6 +51,41 @@ const declaredUnsafeRawRunners = new WeakMap<
51
51
  // (withUnsafeRawGrant, acknowledgeConventionCrossTenant) carry it too.
52
52
  const personalDataGates = new WeakMap<TenantDb, PersonalDataGate>();
53
53
 
54
+ // Lets createTenantDb(ctx.db.unsafeRaw(reason), ...) inherit the gate. Keyed by a
55
+ // per-grant proxy, never the shared pool/tx: tagging that would gate every sibling TenantDb.
56
+ const runnerPersonalDataGates = new WeakMap<DbRunner, PersonalDataGate>();
57
+
58
+ function gatedRunner(runner: DbRunner, gate: PersonalDataGate): DbRunner {
59
+ const proxy = new Proxy(runner as object, {
60
+ // Tagged-template calls need the real driver object as `this`.
61
+ apply(target, _thisArg, args) {
62
+ return Reflect.apply(target as (...callArgs: unknown[]) => unknown, target, args);
63
+ },
64
+ get(target, prop, _receiver) {
65
+ const value = Reflect.get(target, prop, target);
66
+ if (typeof value !== "function") return value;
67
+ if (prop === "begin" || prop === "savepoint") {
68
+ // createTenantDb(tx, ...) inside the callback must inherit the gate too.
69
+ return (...args: unknown[]) => {
70
+ const callback = args[args.length - 1];
71
+ if (typeof callback !== "function") {
72
+ return Reflect.apply(value, target, args);
73
+ }
74
+ const gatedArgs = [
75
+ ...args.slice(0, -1),
76
+ (tx: unknown) => callback(gatedRunner(tx as DbRunner, gate)),
77
+ ];
78
+ return Reflect.apply(value, target, gatedArgs);
79
+ };
80
+ }
81
+ return value.bind(target);
82
+ },
83
+ // @cast-boundary proxy-erasure — Proxy<object> re-tags as the wrapped DbRunner shape.
84
+ }) as DbRunner;
85
+ runnerPersonalDataGates.set(proxy, gate);
86
+ return proxy;
87
+ }
88
+
54
89
  // The executor passes its entity so the check does not depend on the table-name lookup.
55
90
  export function assertPersonalDataWrite(
56
91
  db: TenantDb,
@@ -73,25 +108,49 @@ export function unsafeRawForDeclaredStep(
73
108
  if (!runner) {
74
109
  throw new InternalError({
75
110
  message:
76
- "unsafeRawForDeclaredStep received a holder not built by createTenantDb or " +
77
- "createUncheckedSystemDb — no declared unsafeRaw runner bound.",
111
+ "unsafeRawForDeclaredStep received a holder not built by createTenantDb, " +
112
+ "createUncheckedSystemDb, or createSystemDbView — no declared unsafeRaw runner bound.",
78
113
  });
79
114
  }
80
115
  return runner(reason);
81
116
  }
82
117
 
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(
118
+ const systemDbRebinders = new WeakMap<
119
+ UncheckedSystemDb,
120
+ (grant: EscapeHatchDeclaration | undefined, deniedCallerLabel: string) => UncheckedSystemDb
121
+ >();
122
+
123
+ // Rebinds a hook's own escapeHatch onto ctx.systemDb, mirroring withUnsafeRawGrant: always
124
+ // rebuilt from the original db/dbOutsideTransaction/report, never stacked onto a prior rebind.
125
+ // Inputs not built by createUncheckedSystemDb pass through unchanged.
126
+ export function withSystemDbUnsafeRawGrant(
127
+ systemDb: UncheckedSystemDb,
128
+ grant: EscapeHatchDeclaration | undefined,
129
+ deniedCallerLabel: string,
130
+ ): UncheckedSystemDb {
131
+ const rebind = systemDbRebinders.get(systemDb);
132
+ return rebind ? rebind(grant, deniedCallerLabel) : systemDb;
133
+ }
134
+
135
+ // Ungated when `gate` is absent (the handler's own ctx.systemDb — systemScope() is
136
+ // itself the grant there). Gated by a hook's own escapeHatch when `gate.kind` is
137
+ // "hook-grant": unsafeRaw then denies without `hasGrant(gate.grant)`, same error shape
138
+ // as ctx.db.unsafeRaw's denial in createTenantDb below. Gated by the source TenantDb's
139
+ // own escapeHatch when `gate.kind` is "source-tenant-db" (createSystemDbView): unsafeRaw
140
+ // defers entirely to db's own declared runner (reason/memberReadOnly/grant check and
141
+ // report) — the view's own `report` is never called for unsafe-raw in that mode, and a
142
+ // source not built by createTenantDb fails closed.
143
+ function buildUncheckedSystemDb(
92
144
  db: TenantDb,
93
- dbOutsideTransaction?: TenantDb,
94
- report: EscapeHatchReporter = fallbackEscapeHatchReporter(db.tenantId),
145
+ dbOutsideTransaction: TenantDb | undefined,
146
+ report: EscapeHatchReporter,
147
+ gate?:
148
+ | {
149
+ readonly kind: "hook-grant";
150
+ readonly grant: EscapeHatchDeclaration | undefined;
151
+ readonly deniedCallerLabel: string;
152
+ }
153
+ | { readonly kind: "source-tenant-db" },
95
154
  ): UncheckedSystemDb {
96
155
  const allowedTenantIds: readonly TenantId[] = [db.tenantId, SYSTEM_TENANT_ID];
97
156
 
@@ -113,11 +172,23 @@ export function createUncheckedSystemDb(
113
172
  }
114
173
 
115
174
  function grantedUnsafeRawRunner(reason: string): DbRunner {
175
+ if (gate?.kind === "source-tenant-db") {
176
+ return unsafeRawForDeclaredStep(db, reason);
177
+ }
116
178
  if (reason.trim().length === 0) {
117
179
  throw new Error("unsafeRaw requires a non-empty reason");
118
180
  }
181
+ if (gate && !hasGrant(gate.grant)) {
182
+ throw new AccessDeniedError({
183
+ message:
184
+ 'ctx.systemDb.unsafeRaw(reason): rejected — declare `escapeHatch: { reason: "..." }` on ' +
185
+ `${gate.deniedCallerLabel} to allow unsafeRaw.`,
186
+ });
187
+ }
119
188
  report("unsafe-raw", reason);
120
- return tenantDbRunner(db);
189
+ const runner = tenantDbRunner(db);
190
+ const personalDataGate = personalDataGates.get(db);
191
+ return personalDataGate ? gatedRunner(runner, personalDataGate) : runner;
121
192
  }
122
193
 
123
194
  const uncheckedSystemDb: UncheckedSystemDb = {
@@ -178,9 +249,52 @@ export function createUncheckedSystemDb(
178
249
  },
179
250
  };
180
251
  declaredUnsafeRawRunners.set(uncheckedSystemDb, grantedUnsafeRawRunner);
252
+ if (gate?.kind !== "source-tenant-db") {
253
+ systemDbRebinders.set(uncheckedSystemDb, (grant, deniedCallerLabel) =>
254
+ buildUncheckedSystemDb(
255
+ withUnsafeRawGrant(db, grant),
256
+ dbOutsideTransaction && withUnsafeRawGrant(dbOutsideTransaction, grant),
257
+ report,
258
+ { kind: "hook-grant", grant, deniedCallerLabel },
259
+ ),
260
+ );
261
+ }
181
262
  return uncheckedSystemDb;
182
263
  }
183
264
 
265
+ // Framework-private (not re-exported from db/index.ts): buildHandlerContext
266
+ // (pipeline/dispatch-shared.ts) always builds "system" mode from the caller's
267
+ // own tenantId, never a foreign one.
268
+ //
269
+ // dbOutsideTransaction is optional so every existing single-arg call site
270
+ // (jobs/job-runner.ts, tests) keeps compiling — those callers have no
271
+ // outside-tx source to hand in and never needed one. Only
272
+ // buildHandlerContext passes it, which is also the only place `.outsideTransaction`
273
+ // is reachable through `ctx.systemDb`.
274
+ //
275
+ // Ungated here (the handler's own systemScope() is the grant); a hook's own
276
+ // escapeHatch is layered on afterwards via withSystemDbUnsafeRawGrant. Public
277
+ // callers use createSystemDbView instead, whose unsafeRaw follows the source
278
+ // TenantDb's own escapeHatch gate.
279
+ export function createUncheckedSystemDb(
280
+ db: TenantDb,
281
+ dbOutsideTransaction?: TenantDb,
282
+ report: EscapeHatchReporter = fallbackEscapeHatchReporter(db.tenantId),
283
+ ): UncheckedSystemDb {
284
+ return buildUncheckedSystemDb(db, dbOutsideTransaction, report);
285
+ }
286
+
287
+ // Public: must never grant more raw access than the source TenantDb — unlike
288
+ // createUncheckedSystemDb (framework-private; r.systemScope()/a job IS the
289
+ // declaration), this view's unsafeRaw defers entirely to db's own escapeHatch gate.
290
+ export function createSystemDbView(
291
+ db: TenantDb,
292
+ dbOutsideTransaction?: TenantDb,
293
+ report: EscapeHatchReporter = fallbackEscapeHatchReporter(db.tenantId),
294
+ ): UncheckedSystemDb {
295
+ return buildUncheckedSystemDb(db, dbOutsideTransaction, report, { kind: "source-tenant-db" });
296
+ }
297
+
184
298
  // @cast-boundary tenant-db-row
185
299
  export function castTenantRows<T>(rows: readonly Record<string, unknown>[]): readonly T[] {
186
300
  return rows as unknown as readonly T[];
@@ -275,6 +389,7 @@ export function createTenantDb(
275
389
  ): TenantDb {
276
390
  if (meter) registerStandardMetrics(meter);
277
391
  const report = grants?.report ?? fallbackEscapeHatchReporter(tenantId);
392
+ const personalDataGate = grants?.personalDataGate ?? runnerPersonalDataGates.get(db);
278
393
 
279
394
  function withDbSpan<T>(
280
395
  operation: "select" | "insert" | "update" | "delete",
@@ -375,9 +490,9 @@ export function createTenantDb(
375
490
  table: Table | EntityTableMeta,
376
491
  keys: readonly string[],
377
492
  ): AccessDeniedError | undefined {
378
- if (!grants?.personalDataGate) return undefined;
493
+ if (!personalDataGate) return undefined;
379
494
  try {
380
- grants.personalDataGate(tableNameOf(table), keys);
495
+ personalDataGate(tableNameOf(table), keys);
381
496
  return undefined;
382
497
  } catch (e) {
383
498
  if (e instanceof AccessDeniedError) return e;
@@ -476,7 +591,7 @@ export function createTenantDb(
476
591
  });
477
592
  }
478
593
  report("unsafe-raw", reason);
479
- return db;
594
+ return personalDataGate ? gatedRunner(db, personalDataGate) : db;
480
595
  }
481
596
 
482
597
  const tenantDb: TenantDb = {
@@ -571,7 +686,7 @@ export function createTenantDb(
571
686
  return createTenantDb(db, tenantId, "system", tracer, meter, signal, grants);
572
687
  });
573
688
  bindTenantDbRunner(tenantDb, db);
574
- if (grants?.personalDataGate) personalDataGates.set(tenantDb, grants.personalDataGate);
689
+ if (personalDataGate) personalDataGates.set(tenantDb, personalDataGate);
575
690
  return tenantDb;
576
691
  }
577
692
 
@@ -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