@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.
- package/package.json +4 -4
- package/src/api/__tests__/redis-sse-broker.integration.test.ts +66 -0
- 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/__tests__/sse-broker.test.ts +49 -0
- package/src/api/redis-sse-broker.ts +17 -3
- package/src/api/request-context.ts +29 -4
- package/src/api/routes.ts +26 -1
- package/src/api/sse-broker.ts +29 -11
- 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 +92 -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/queries/event-consumer.ts +57 -3
- package/src/db/queries/event-store.ts +69 -0
- package/src/db/tenant-db.ts +133 -18
- 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/event-store/__tests__/event-attribution.integration.test.ts +53 -3
- package/src/event-store/admin-api.ts +5 -0
- package/src/event-store/event-store.ts +16 -7
- package/src/jobs/__tests__/job-public-intake-origin.integration.test.ts +536 -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 +211 -19
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
- package/src/pipeline/__tests__/dispatcher-utils.test.ts +8 -0
- package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
- package/src/pipeline/__tests__/event-dispatcher-commit-order.integration.test.ts +278 -0
- package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +1 -0
- package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.test.ts +6 -6
- package/src/pipeline/__tests__/event-dispatcher-per-consumer-turns.integration.test.ts +126 -0
- package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
- package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
- package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +261 -2
- package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
- package/src/pipeline/dispatch-batch.ts +102 -29
- package/src/pipeline/dispatch-stream.ts +7 -3
- package/src/pipeline/dispatcher-utils.ts +21 -2
- package/src/pipeline/dispatcher.ts +71 -6
- package/src/pipeline/event-consumer-state.ts +26 -0
- package/src/pipeline/event-dispatcher-admin.ts +32 -5
- package/src/pipeline/event-dispatcher-delivery.ts +109 -57
- package/src/pipeline/event-dispatcher.ts +167 -50
- package/src/pipeline/idempotency.ts +16 -0
- package/src/pipeline/pending-gap-ranges.ts +72 -0
- package/src/pipeline/system-hooks.ts +8 -1
- package/src/pipeline/system-identity-switch.ts +22 -4
- package/src/pipeline/write-origin.ts +31 -10
- package/src/stack/test-stack.ts +1 -1
- 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
|
@@ -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
|
});
|
|
@@ -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" = $
|
|
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
|
+
}
|
package/src/db/tenant-db.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
export function
|
|
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
|
|
94
|
-
report: EscapeHatchReporter
|
|
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
|
-
|
|
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 (!
|
|
493
|
+
if (!personalDataGate) return undefined;
|
|
379
494
|
try {
|
|
380
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|