@cosmicdrift/kumiko-framework 0.306.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__/sse-broker.test.ts +49 -0
- package/src/api/redis-sse-broker.ts +17 -3
- package/src/api/request-context.ts +24 -0
- package/src/api/sse-broker.ts +29 -11
- package/src/changes.json +26 -0
- package/src/db/queries/event-consumer.ts +57 -3
- package/src/db/queries/event-store.ts +69 -0
- package/src/db/tenant-db.ts +43 -5
- 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/job-runner.ts +61 -6
- package/src/pipeline/__tests__/dispatcher-utils.test.ts +8 -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__/public-intake-runtime-gate.integration.test.ts +261 -2
- package/src/pipeline/dispatch-batch.ts +49 -19
- 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/pending-gap-ranges.ts +72 -0
- package/src/pipeline/system-hooks.ts +8 -1
- package/src/pipeline/write-origin.ts +31 -10
- package/src/stack/test-stack.ts +1 -1
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import type { SchemaTable } from "../../db";
|
|
7
|
+
import type { DbRunner } from "../../db/connection";
|
|
7
8
|
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
8
|
-
import { asRawClient, selectMany } from "../../db/query";
|
|
9
|
+
import { asRawClient, runInSavepoint, selectMany } from "../../db/query";
|
|
9
10
|
import { buildEntityTable } from "../../db/table-builder";
|
|
10
|
-
import type
|
|
11
|
+
import { createTenantDb, type TenantDb } from "../../db/tenant-db";
|
|
11
12
|
import { createEntity, createSystemUser, createTextField, defineFeature } from "../../engine";
|
|
12
13
|
import { SYSTEM_ROLE } from "../../engine/system-user";
|
|
13
14
|
import type { TenantId } from "../../engine/types";
|
|
@@ -273,6 +274,185 @@ const featureA = defineFeature("intakea", (r) => {
|
|
|
273
274
|
},
|
|
274
275
|
{ access: { roles: ["anonymous", "Admin"] }, rateLimit: RATE_LIMIT },
|
|
275
276
|
);
|
|
277
|
+
|
|
278
|
+
// (g) createTenantDb() built by the HANDLER ITSELF from ctx.db.unsafeRaw(reason) — the
|
|
279
|
+
// returned runner carries no TenantDb of its own, so without gate inheritance on the
|
|
280
|
+
// runner (tenant-db.ts's runnerPersonalDataGates) this fresh TenantDb would have no gate.
|
|
281
|
+
const UNSAFE_RAW_REASON =
|
|
282
|
+
"test: proves createTenantDb() built from ctx.db.unsafeRaw() inherits the caller's personal-data gate";
|
|
283
|
+
|
|
284
|
+
r.writeHandler(
|
|
285
|
+
"unsafe-raw-tenant-db-no-declare",
|
|
286
|
+
z.object({ note: z.string() }),
|
|
287
|
+
async (event, ctx) => {
|
|
288
|
+
const raw = ctx.db.unsafeRaw(UNSAFE_RAW_REASON);
|
|
289
|
+
await createTenantDb(raw, event.user.tenantId, "system").insertOne(
|
|
290
|
+
contactTable as unknown as SchemaTable,
|
|
291
|
+
{ email: "leak-unsafe-raw@example.com", note: event.payload.note },
|
|
292
|
+
);
|
|
293
|
+
return { isSuccess: true as const, data: { ok: true as const } };
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
access: { roles: ["anonymous"] },
|
|
297
|
+
rateLimit: RATE_LIMIT,
|
|
298
|
+
escapeHatch: { reason: UNSAFE_RAW_REASON },
|
|
299
|
+
},
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
r.writeHandler(
|
|
303
|
+
"unsafe-raw-tenant-db-declare",
|
|
304
|
+
z.object({ note: z.string() }),
|
|
305
|
+
async (event, ctx) => {
|
|
306
|
+
const raw = ctx.db.unsafeRaw(UNSAFE_RAW_REASON);
|
|
307
|
+
await createTenantDb(raw, event.user.tenantId, "system").insertOne(
|
|
308
|
+
contactTable as unknown as SchemaTable,
|
|
309
|
+
{ email: "leak-unsafe-raw-declared@example.com", note: event.payload.note },
|
|
310
|
+
);
|
|
311
|
+
return { isSuccess: true as const, data: { ok: true as const } };
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
access: { roles: ["anonymous"], personalData: "public-intake" },
|
|
315
|
+
rateLimit: RATE_LIMIT,
|
|
316
|
+
escapeHatch: { reason: UNSAFE_RAW_REASON },
|
|
317
|
+
},
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
// Nested tx: the handler already runs inside the request's own transaction, so its
|
|
321
|
+
// unsafeRaw runner is a tx handle (.savepoint, not .begin — see asRawClient's own
|
|
322
|
+
// comment). The savepoint-scoped tx a callback receives must inherit the same gate,
|
|
323
|
+
// otherwise a handler could dodge the gate by moving the write inside a savepoint.
|
|
324
|
+
r.writeHandler(
|
|
325
|
+
"unsafe-raw-savepoint-tenant-db-no-declare",
|
|
326
|
+
z.object({ note: z.string() }),
|
|
327
|
+
async (event, ctx) => {
|
|
328
|
+
const raw = ctx.db.unsafeRaw(UNSAFE_RAW_REASON);
|
|
329
|
+
await runInSavepoint(raw, async (sp) => {
|
|
330
|
+
await createTenantDb(sp as DbRunner, event.user.tenantId, "system").insertOne(
|
|
331
|
+
contactTable as unknown as SchemaTable,
|
|
332
|
+
{ email: "leak-unsafe-raw-tx@example.com", note: event.payload.note },
|
|
333
|
+
);
|
|
334
|
+
});
|
|
335
|
+
return { isSuccess: true as const, data: { ok: true as const } };
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
access: { roles: ["anonymous"] },
|
|
339
|
+
rateLimit: RATE_LIMIT,
|
|
340
|
+
escapeHatch: { reason: UNSAFE_RAW_REASON },
|
|
341
|
+
},
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
r.writeHandler(
|
|
345
|
+
"unsafe-raw-savepoint-tenant-db-declare",
|
|
346
|
+
z.object({ note: z.string() }),
|
|
347
|
+
async (event, ctx) => {
|
|
348
|
+
const raw = ctx.db.unsafeRaw(UNSAFE_RAW_REASON);
|
|
349
|
+
await runInSavepoint(raw, async (sp) => {
|
|
350
|
+
await createTenantDb(sp as DbRunner, event.user.tenantId, "system").insertOne(
|
|
351
|
+
contactTable as unknown as SchemaTable,
|
|
352
|
+
{ email: "leak-unsafe-raw-tx-declared@example.com", note: event.payload.note },
|
|
353
|
+
);
|
|
354
|
+
});
|
|
355
|
+
return { isSuccess: true as const, data: { ok: true as const } };
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
access: { roles: ["anonymous"], personalData: "public-intake" },
|
|
359
|
+
rateLimit: RATE_LIMIT,
|
|
360
|
+
escapeHatch: { reason: UNSAFE_RAW_REASON },
|
|
361
|
+
},
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
// Raw SQL through the gated proxy itself must stay ungated (decision: unsafeRaw's raw SQL
|
|
365
|
+
// is an escape hatch + audit trail, not something the personal-data gate blocks) — this
|
|
366
|
+
// just proves the proxy stays transparent for a direct tagged-template call.
|
|
367
|
+
r.writeHandler(
|
|
368
|
+
"unsafe-raw-tagged-query-declare",
|
|
369
|
+
z.object({ note: z.string() }),
|
|
370
|
+
async (event, ctx) => {
|
|
371
|
+
const raw = ctx.db.unsafeRaw(UNSAFE_RAW_REASON);
|
|
372
|
+
// @cast-boundary test-fixture — DbRunner's RawClient arm has no tagged-template call
|
|
373
|
+
// signature; the underlying driver instance (postgres-js/Bun.SQL) does.
|
|
374
|
+
const tagged = raw as unknown as (
|
|
375
|
+
strings: TemplateStringsArray,
|
|
376
|
+
...values: unknown[]
|
|
377
|
+
) => Promise<readonly Record<string, unknown>[]>;
|
|
378
|
+
const rows = await tagged`SELECT 1 AS one`;
|
|
379
|
+
if (rows[0]?.["one"] !== 1) throw new Error("tagged query through the proxy failed");
|
|
380
|
+
await createTenantDb(raw, event.user.tenantId, "system").insertOne(
|
|
381
|
+
contactTable as unknown as SchemaTable,
|
|
382
|
+
{ email: "leak-unsafe-raw-tagged@example.com", note: event.payload.note },
|
|
383
|
+
);
|
|
384
|
+
return { isSuccess: true as const, data: { ok: true as const } };
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
access: { roles: ["anonymous"], personalData: "public-intake" },
|
|
388
|
+
rateLimit: RATE_LIMIT,
|
|
389
|
+
escapeHatch: { reason: UNSAFE_RAW_REASON },
|
|
390
|
+
},
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
// Authenticated session: same ctx.db.unsafeRaw() -> createTenantDb() path, but the root
|
|
394
|
+
// isn't anonymous, so ctx.db carries no personal-data gate to inherit — unaffected.
|
|
395
|
+
r.writeHandler(
|
|
396
|
+
"unsafe-raw-tenant-db-authenticated",
|
|
397
|
+
z.object({ note: z.string() }),
|
|
398
|
+
async (event, ctx) => {
|
|
399
|
+
const raw = ctx.db.unsafeRaw(UNSAFE_RAW_REASON);
|
|
400
|
+
await createTenantDb(raw, event.user.tenantId, "system").insertOne(
|
|
401
|
+
contactTable as unknown as SchemaTable,
|
|
402
|
+
{ email: "leak-unsafe-raw-authenticated@example.com", note: event.payload.note },
|
|
403
|
+
);
|
|
404
|
+
return { isSuccess: true as const, data: { ok: true as const } };
|
|
405
|
+
},
|
|
406
|
+
{ access: { roles: ["Admin"] }, escapeHatch: { reason: UNSAFE_RAW_REASON } },
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
// The CRUD executor writes through tenantDbRunner + assertPersonalDataWrite, not
|
|
410
|
+
// insertOne — a TenantDb inheriting its gate only from the runner (not from an explicit
|
|
411
|
+
// grants.personalDataGate) must gate the executor path too, not just direct insertOne.
|
|
412
|
+
r.writeHandler(
|
|
413
|
+
"unsafe-raw-executor-no-declare",
|
|
414
|
+
z.object({ note: z.string() }),
|
|
415
|
+
async (event, ctx) => {
|
|
416
|
+
const scopedDb = createTenantDb(
|
|
417
|
+
ctx.db.unsafeRaw(UNSAFE_RAW_REASON),
|
|
418
|
+
event.user.tenantId,
|
|
419
|
+
"system",
|
|
420
|
+
);
|
|
421
|
+
const crud = createEventStoreExecutor(contactTable, contactEntity, { entityName: "contact" });
|
|
422
|
+
return crud.create(
|
|
423
|
+
{ email: "leak-unsafe-raw-executor@example.com", note: event.payload.note },
|
|
424
|
+
event.user,
|
|
425
|
+
scopedDb,
|
|
426
|
+
);
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
access: { roles: ["anonymous"] },
|
|
430
|
+
rateLimit: RATE_LIMIT,
|
|
431
|
+
escapeHatch: { reason: UNSAFE_RAW_REASON },
|
|
432
|
+
},
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
// Same inherited-gate TenantDb, but only a non-PII field — proves the proxy/executor
|
|
436
|
+
// combination still appends events and lets the projection run when there is nothing
|
|
437
|
+
// for the gate to block, not merely that it throws.
|
|
438
|
+
r.writeHandler(
|
|
439
|
+
"unsafe-raw-executor-non-pii-no-declare",
|
|
440
|
+
z.object({ note: z.string() }),
|
|
441
|
+
async (event, ctx) => {
|
|
442
|
+
const scopedDb = createTenantDb(
|
|
443
|
+
ctx.db.unsafeRaw(UNSAFE_RAW_REASON),
|
|
444
|
+
event.user.tenantId,
|
|
445
|
+
"system",
|
|
446
|
+
);
|
|
447
|
+
const crud = createEventStoreExecutor(probeTable, probeEntity, { entityName: "probe" });
|
|
448
|
+
return crud.create({ note: event.payload.note }, event.user, scopedDb);
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
access: { roles: ["anonymous"] },
|
|
452
|
+
rateLimit: RATE_LIMIT,
|
|
453
|
+
escapeHatch: { reason: UNSAFE_RAW_REASON },
|
|
454
|
+
},
|
|
455
|
+
);
|
|
276
456
|
});
|
|
277
457
|
|
|
278
458
|
describe("public-intake runtime gate", () => {
|
|
@@ -422,4 +602,83 @@ describe("public-intake runtime gate", () => {
|
|
|
422
602
|
expect(res.status).toBe(200);
|
|
423
603
|
expect(await rowCount()).toBe(1);
|
|
424
604
|
});
|
|
605
|
+
|
|
606
|
+
test("(g) createTenantDb(ctx.db.unsafeRaw(reason), ...) — blocked without declaration", async () => {
|
|
607
|
+
const res = await stack.http.raw("POST", "/api/write", {
|
|
608
|
+
type: "intakea:write:unsafe-raw-tenant-db-no-declare",
|
|
609
|
+
payload: { note: "x" },
|
|
610
|
+
});
|
|
611
|
+
expect(res.status).toBe(403);
|
|
612
|
+
const body = (await res.json()) as { error: { details: { reason: string } } };
|
|
613
|
+
expect(body.error.details.reason).toBe("public_intake_required");
|
|
614
|
+
expect(await rowCount()).toBe(0);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
test("(g) createTenantDb(ctx.db.unsafeRaw(reason), ...) — allowed once declared", async () => {
|
|
618
|
+
const res = await stack.http.raw("POST", "/api/write", {
|
|
619
|
+
type: "intakea:write:unsafe-raw-tenant-db-declare",
|
|
620
|
+
payload: { note: "x" },
|
|
621
|
+
});
|
|
622
|
+
expect(res.status).toBe(200);
|
|
623
|
+
expect(await rowCount()).toBe(1);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
test("(g) createTenantDb(tx, ...) inside savepoint() — blocked without declaration", async () => {
|
|
627
|
+
const res = await stack.http.raw("POST", "/api/write", {
|
|
628
|
+
type: "intakea:write:unsafe-raw-savepoint-tenant-db-no-declare",
|
|
629
|
+
payload: { note: "x" },
|
|
630
|
+
});
|
|
631
|
+
expect(res.status).toBe(403);
|
|
632
|
+
const body = (await res.json()) as { error: { details: { reason: string } } };
|
|
633
|
+
expect(body.error.details.reason).toBe("public_intake_required");
|
|
634
|
+
expect(await rowCount()).toBe(0);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
test("(g) createTenantDb(tx, ...) inside savepoint() — allowed once declared", async () => {
|
|
638
|
+
const res = await stack.http.raw("POST", "/api/write", {
|
|
639
|
+
type: "intakea:write:unsafe-raw-savepoint-tenant-db-declare",
|
|
640
|
+
payload: { note: "x" },
|
|
641
|
+
});
|
|
642
|
+
expect(res.status).toBe(200);
|
|
643
|
+
expect(await rowCount()).toBe(1);
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
test("(g) a tagged-template query through the gated unsafeRaw proxy still works", async () => {
|
|
647
|
+
const res = await stack.http.raw("POST", "/api/write", {
|
|
648
|
+
type: "intakea:write:unsafe-raw-tagged-query-declare",
|
|
649
|
+
payload: { note: "x" },
|
|
650
|
+
});
|
|
651
|
+
expect(res.status).toBe(200);
|
|
652
|
+
expect(await rowCount()).toBe(1);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
test("(g) authenticated session on the same unsafeRaw->createTenantDb path — unaffected", async () => {
|
|
656
|
+
const res = await stack.http.write(
|
|
657
|
+
"intakea:write:unsafe-raw-tenant-db-authenticated",
|
|
658
|
+
{ note: "x" },
|
|
659
|
+
TestUsers.admin,
|
|
660
|
+
);
|
|
661
|
+
expect(res.status).toBe(200);
|
|
662
|
+
expect(await rowCount()).toBe(1);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
test("(g) createEventStoreExecutor.create through an inherited-gate TenantDb — blocked without declaration", async () => {
|
|
666
|
+
const res = await stack.http.raw("POST", "/api/write", {
|
|
667
|
+
type: "intakea:write:unsafe-raw-executor-no-declare",
|
|
668
|
+
payload: { note: "x" },
|
|
669
|
+
});
|
|
670
|
+
expect(res.status).toBe(403);
|
|
671
|
+
const body = (await res.json()) as { error: { details: { reason: string } } };
|
|
672
|
+
expect(body.error.details.reason).toBe("public_intake_required");
|
|
673
|
+
expect(await rowCount()).toBe(0);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
test("(g) createEventStoreExecutor.create of a non-PII field through the same inherited-gate TenantDb — allowed", async () => {
|
|
677
|
+
const res = await stack.http.raw("POST", "/api/write", {
|
|
678
|
+
type: "intakea:write:unsafe-raw-executor-non-pii-no-declare",
|
|
679
|
+
payload: { note: "x" },
|
|
680
|
+
});
|
|
681
|
+
expect(res.status).toBe(200);
|
|
682
|
+
expect(await probeRowCount()).toBe(1);
|
|
683
|
+
});
|
|
425
684
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { WriteOrigin } from "@cosmicdrift/kumiko-types/event-store-types";
|
|
2
|
+
import { requestContext, runWithWriteOrigin } from "../api/request-context";
|
|
2
3
|
import type { DbConnection } from "../db/connection";
|
|
3
4
|
import { transaction } from "../db/query";
|
|
4
5
|
import type { DeleteContext, SaveContext, SessionUser, WriteResult } from "../engine/types";
|
|
@@ -14,7 +15,20 @@ import {
|
|
|
14
15
|
isLifecycleResult,
|
|
15
16
|
wrapToKumiko,
|
|
16
17
|
} from "./dispatcher-utils";
|
|
17
|
-
import { rootWriteOrigin } from "./write-origin";
|
|
18
|
+
import { effectiveWriteOrigin, isPersonalDataGated, rootWriteOrigin } from "./write-origin";
|
|
19
|
+
|
|
20
|
+
// afterCommit hooks fire in flushAfterCommit, outside the command's scope.
|
|
21
|
+
function rewrapHooksWithOrigin(
|
|
22
|
+
afterCommitHooks: AfterCommitHook[],
|
|
23
|
+
fromIndex: number,
|
|
24
|
+
origin: WriteOrigin,
|
|
25
|
+
): void {
|
|
26
|
+
for (let i = fromIndex; i < afterCommitHooks.length; i++) {
|
|
27
|
+
const original = afterCommitHooks[i];
|
|
28
|
+
if (!original) continue;
|
|
29
|
+
afterCommitHooks[i] = () => runWithWriteOrigin(origin, original);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
18
32
|
|
|
19
33
|
// Core batch logic extracted so write() and command() can reuse it
|
|
20
34
|
// (a single write = batch of one, running in its own transaction).
|
|
@@ -23,15 +37,18 @@ export async function runBatch(
|
|
|
23
37
|
commands: readonly BatchCommand[],
|
|
24
38
|
user: SessionUser,
|
|
25
39
|
requestId?: string,
|
|
40
|
+
inheritedOrigin?: WriteOrigin,
|
|
26
41
|
): Promise<BatchResult> {
|
|
27
42
|
const current = requestContext.get();
|
|
28
43
|
if (!current?.signal) {
|
|
29
|
-
return runBatchBody(ctx, commands, user, requestId);
|
|
44
|
+
return runBatchBody(ctx, commands, user, requestId, inheritedOrigin);
|
|
30
45
|
}
|
|
31
46
|
// Strip the signal: a disconnect would roll back the tx, idempotency would
|
|
32
47
|
// cache a 500 for the uncommitted write and afterCommit effects would be lost.
|
|
33
48
|
const { signal: _signal, ...withoutSignal } = current;
|
|
34
|
-
return requestContext.run(withoutSignal, () =>
|
|
49
|
+
return requestContext.run(withoutSignal, () =>
|
|
50
|
+
runBatchBody(ctx, commands, user, requestId, inheritedOrigin),
|
|
51
|
+
);
|
|
35
52
|
}
|
|
36
53
|
|
|
37
54
|
async function runBatchBody(
|
|
@@ -39,6 +56,7 @@ async function runBatchBody(
|
|
|
39
56
|
commands: readonly BatchCommand[],
|
|
40
57
|
user: SessionUser,
|
|
41
58
|
requestId?: string,
|
|
59
|
+
inheritedOrigin?: WriteOrigin,
|
|
42
60
|
): Promise<BatchResult> {
|
|
43
61
|
const { idempotency, lifecycle, appContext: context } = ctx;
|
|
44
62
|
if (commands.length === 0) {
|
|
@@ -109,10 +127,12 @@ async function runBatchBody(
|
|
|
109
127
|
}
|
|
110
128
|
};
|
|
111
129
|
|
|
130
|
+
const origins: WriteOrigin[] = [];
|
|
131
|
+
|
|
112
132
|
// Fires the batch-level system hooks with every successful save/delete
|
|
113
133
|
// context from this run. Called after flushAfterCommit so per-save hooks
|
|
114
134
|
// have all completed first; errors are isolated inside lifecycleHooks.
|
|
115
|
-
const
|
|
135
|
+
const flushBatchHooksInner = async () => {
|
|
116
136
|
try {
|
|
117
137
|
const saves: SaveContext[] = [];
|
|
118
138
|
const deletes: DeleteContext[] = [];
|
|
@@ -133,6 +153,14 @@ async function runBatchBody(
|
|
|
133
153
|
}
|
|
134
154
|
};
|
|
135
155
|
|
|
156
|
+
// Batch hooks see every command's saves, so they run under the strictest origin.
|
|
157
|
+
const flushBatchHooks = async () => {
|
|
158
|
+
const strictest = origins.find(isPersonalDataGated) ?? origins[0];
|
|
159
|
+
// skip: no command ran, so batch hooks have no saves or deletes to see
|
|
160
|
+
if (!strictest) return;
|
|
161
|
+
await runWithWriteOrigin(strictest, flushBatchHooksInner);
|
|
162
|
+
};
|
|
163
|
+
|
|
136
164
|
// batch() opens its own outer transaction — needs the top-level
|
|
137
165
|
// connection's `.begin()` (TransactionSql exposes only `.savepoint()`).
|
|
138
166
|
const db = resolveDbSource(ctx, undefined) as DbConnection | undefined;
|
|
@@ -143,15 +171,16 @@ async function runBatchBody(
|
|
|
143
171
|
for (let i = 0; i < commands.length; i++) {
|
|
144
172
|
const cmd = commands[i];
|
|
145
173
|
if (!cmd) continue;
|
|
146
|
-
const
|
|
147
|
-
ctx,
|
|
148
|
-
cmd.type,
|
|
149
|
-
cmd.payload,
|
|
150
|
-
user,
|
|
174
|
+
const origin = effectiveWriteOrigin(
|
|
151
175
|
rootWriteOrigin(ctx.registry, cmd.type, user),
|
|
152
|
-
|
|
153
|
-
afterCommitHooks,
|
|
176
|
+
inheritedOrigin,
|
|
154
177
|
);
|
|
178
|
+
origins.push(origin);
|
|
179
|
+
const hookStart = afterCommitHooks.length;
|
|
180
|
+
const res = await runWithWriteOrigin(origin, () =>
|
|
181
|
+
executeNestedWrite(ctx, cmd.type, cmd.payload, user, origin, undefined, afterCommitHooks),
|
|
182
|
+
);
|
|
183
|
+
rewrapHooksWithOrigin(afterCommitHooks, hookStart, origin);
|
|
155
184
|
results.push(res);
|
|
156
185
|
if (!res.isSuccess) {
|
|
157
186
|
// No tx means no rollback — but we still drop afterCommit hooks,
|
|
@@ -170,15 +199,16 @@ async function runBatchBody(
|
|
|
170
199
|
for (let i = 0; i < commands.length; i++) {
|
|
171
200
|
const cmd = commands[i];
|
|
172
201
|
if (!cmd) continue;
|
|
173
|
-
const
|
|
174
|
-
ctx,
|
|
175
|
-
cmd.type,
|
|
176
|
-
cmd.payload,
|
|
177
|
-
user,
|
|
202
|
+
const origin = effectiveWriteOrigin(
|
|
178
203
|
rootWriteOrigin(ctx.registry, cmd.type, user),
|
|
179
|
-
|
|
180
|
-
|
|
204
|
+
inheritedOrigin,
|
|
205
|
+
);
|
|
206
|
+
origins.push(origin);
|
|
207
|
+
const hookStart = afterCommitHooks.length;
|
|
208
|
+
const res = await runWithWriteOrigin(origin, () =>
|
|
209
|
+
executeNestedWrite(ctx, cmd.type, cmd.payload, user, origin, tx, afterCommitHooks),
|
|
181
210
|
);
|
|
211
|
+
rewrapHooksWithOrigin(afterCommitHooks, hookStart, origin);
|
|
182
212
|
results.push(res);
|
|
183
213
|
if (!res.isSuccess) {
|
|
184
214
|
throw new BatchRollback(i, res.error);
|
|
@@ -74,9 +74,13 @@ async function* executeStreamInner(
|
|
|
74
74
|
const invalidated = new Promise<void>((resolve) => {
|
|
75
75
|
resolveInvalidated = resolve;
|
|
76
76
|
});
|
|
77
|
-
const unsubscribeAccessInvalidation = ctx.sseBroker?.subscribeAccessInvalidation(
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
const unsubscribeAccessInvalidation = ctx.sseBroker?.subscribeAccessInvalidation(
|
|
78
|
+
user.id,
|
|
79
|
+
() => {
|
|
80
|
+
resolveInvalidated?.();
|
|
81
|
+
},
|
|
82
|
+
user.sid,
|
|
83
|
+
);
|
|
80
84
|
|
|
81
85
|
let iterator: AsyncIterator<unknown> | undefined;
|
|
82
86
|
// When access is revoked mid-pull, `iterator.next()` is still in flight.
|
|
@@ -6,7 +6,14 @@ import type {
|
|
|
6
6
|
SessionUser,
|
|
7
7
|
WriteResult,
|
|
8
8
|
} from "../engine/types";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
type FieldIssue,
|
|
11
|
+
type KumikoError,
|
|
12
|
+
toKumikoError,
|
|
13
|
+
VersionConflictError,
|
|
14
|
+
type WriteErrorInfo,
|
|
15
|
+
} from "../errors";
|
|
16
|
+
import { VersionConflictError as EventStoreVersionConflictError } from "../event-store/errors";
|
|
10
17
|
|
|
11
18
|
export type FailedWriteResult = Extract<WriteResult, { isSuccess: false }>;
|
|
12
19
|
|
|
@@ -177,4 +184,16 @@ export function resolveType(type: HandlerType): string {
|
|
|
177
184
|
return typeof type === "string" ? type : type.name;
|
|
178
185
|
}
|
|
179
186
|
|
|
180
|
-
|
|
187
|
+
// A custom write losing an append race (ctx.appendEvent, stream.append) is a
|
|
188
|
+
// retryable 409 like the CRUD executor's, not an internal error. Mapped here
|
|
189
|
+
// because errors/ is client code and must not import the event store.
|
|
190
|
+
// currentVersion -1 is the executor's "not looked up" sentinel.
|
|
191
|
+
export function wrapToKumiko(e: unknown): KumikoError {
|
|
192
|
+
if (e instanceof EventStoreVersionConflictError) {
|
|
193
|
+
return new VersionConflictError(
|
|
194
|
+
{ entityId: e.aggregateId, expectedVersion: e.expectedVersion, currentVersion: -1 },
|
|
195
|
+
{ cause: e },
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
return toKumikoError(e);
|
|
199
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { WriteOrigin } from "@cosmicdrift/kumiko-types/event-store-types";
|
|
2
|
+
import { runWithWriteOrigin } from "../api/request-context";
|
|
1
3
|
import type { SseBroker } from "../api/sse-broker";
|
|
2
4
|
import type { buildEntityTable } from "../db/table-builder";
|
|
3
5
|
import {
|
|
@@ -18,7 +20,7 @@ import type {
|
|
|
18
20
|
WriteResult,
|
|
19
21
|
} from "../engine/types";
|
|
20
22
|
import type { TenantId } from "../engine/types/identifiers";
|
|
21
|
-
import { reraiseAsKumikoError } from "../errors";
|
|
23
|
+
import { InternalError, reraiseAsKumikoError } from "../errors";
|
|
22
24
|
import { getFallbackMeter, getFallbackTracer, registerStandardMetrics } from "../observability";
|
|
23
25
|
import { createEscapeHatchReportWindow } from "../observability/escape-hatch-report";
|
|
24
26
|
import { INTERACTIVE_SIGN_IN_POLICY, resolveActiveMembershipFn } from "./active-membership";
|
|
@@ -32,7 +34,7 @@ import type { IdempotencyGuard } from "./idempotency";
|
|
|
32
34
|
import type { LifecycleHooks } from "./lifecycle-pipeline";
|
|
33
35
|
import { createMemberReaderFn } from "./member-reader";
|
|
34
36
|
import { createTenantTimezoneCache } from "./tenant-timezone-cache";
|
|
35
|
-
import { rootWriteOrigin } from "./write-origin";
|
|
37
|
+
import { effectiveWriteOrigin, isPersonalDataGated, rootWriteOrigin } from "./write-origin";
|
|
36
38
|
|
|
37
39
|
// Re-export for callers that reach for dispatcher-adjacent types (tests,
|
|
38
40
|
// HTTP-layer stubs) — dispatch consumes these, grouping the type-surface
|
|
@@ -111,12 +113,55 @@ export type Dispatcher = {
|
|
|
111
113
|
createMemberReader(tenantId: TenantId): MemberReader;
|
|
112
114
|
};
|
|
113
115
|
|
|
116
|
+
// Kept off the public Dispatcher type: only a job may pass an inherited origin.
|
|
117
|
+
type DispatcherInternals = {
|
|
118
|
+
writeWithOrigin: (
|
|
119
|
+
type: string,
|
|
120
|
+
payload: unknown,
|
|
121
|
+
user: SessionUser,
|
|
122
|
+
inheritedOrigin: WriteOrigin,
|
|
123
|
+
) => Promise<WriteResult>;
|
|
124
|
+
queryWithOrigin: (
|
|
125
|
+
type: string,
|
|
126
|
+
payload: unknown,
|
|
127
|
+
user: SessionUser,
|
|
128
|
+
inheritedOrigin: WriteOrigin,
|
|
129
|
+
) => Promise<unknown>;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const dispatcherInternals = new WeakMap<Dispatcher, DispatcherInternals>();
|
|
133
|
+
|
|
114
134
|
// Adapts Dispatcher's (type, payload, user) call shape to DispatchWriteRef's
|
|
115
135
|
// (user, qn, payload) — JobRunner.attachDispatcher needs the latter.
|
|
116
136
|
export function dispatcherToWriteRef(dispatcher: Dispatcher): DispatchWriteRef {
|
|
137
|
+
const internals = dispatcherInternals.get(dispatcher);
|
|
117
138
|
return {
|
|
118
|
-
write: (user, qn, payload) =>
|
|
119
|
-
|
|
139
|
+
write: (user, qn, payload, inheritedOrigin) => {
|
|
140
|
+
if (inheritedOrigin && isPersonalDataGated(inheritedOrigin)) {
|
|
141
|
+
if (!internals) {
|
|
142
|
+
throw new InternalError({
|
|
143
|
+
message:
|
|
144
|
+
`JobContext.write("${qn}") carries a gated origin but this dispatcher has no ` +
|
|
145
|
+
"registered origin-aware internals — refusing to fall open to the ungated path.",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return internals.writeWithOrigin(qn, payload, user, inheritedOrigin);
|
|
149
|
+
}
|
|
150
|
+
return dispatcher.write(qn, payload, user);
|
|
151
|
+
},
|
|
152
|
+
queryAs: (user, qn, payload, inheritedOrigin) => {
|
|
153
|
+
if (inheritedOrigin && isPersonalDataGated(inheritedOrigin)) {
|
|
154
|
+
if (!internals) {
|
|
155
|
+
throw new InternalError({
|
|
156
|
+
message:
|
|
157
|
+
`JobContext.queryAs("${qn}") carries a gated origin but this dispatcher has no ` +
|
|
158
|
+
"registered origin-aware internals — refusing to fall open to the ungated path.",
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return internals.queryWithOrigin(qn, payload, user, inheritedOrigin);
|
|
162
|
+
}
|
|
163
|
+
return dispatcher.query(qn, payload, user);
|
|
164
|
+
},
|
|
120
165
|
createMemberReader: (tenantId) => dispatcher.createMemberReader(tenantId),
|
|
121
166
|
};
|
|
122
167
|
}
|
|
@@ -173,7 +218,7 @@ export function createDispatcher(
|
|
|
173
218
|
membershipQuery,
|
|
174
219
|
};
|
|
175
220
|
|
|
176
|
-
|
|
221
|
+
const dispatcher: Dispatcher = {
|
|
177
222
|
async write(typeOrRef, payload, user, requestId?) {
|
|
178
223
|
const type = resolveType(typeOrRef);
|
|
179
224
|
// Idempotency handled inside runBatch (caches BatchResult under requestId).
|
|
@@ -185,7 +230,8 @@ export function createDispatcher(
|
|
|
185
230
|
|
|
186
231
|
query: (typeOrRef, payload, user) => {
|
|
187
232
|
const type = resolveType(typeOrRef);
|
|
188
|
-
|
|
233
|
+
const origin = rootWriteOrigin(registry, type, user);
|
|
234
|
+
return runWithWriteOrigin(origin, () => executeQuery(ctx, type, payload, user, origin));
|
|
189
235
|
},
|
|
190
236
|
|
|
191
237
|
stream: (typeOrRef, payload, user) => {
|
|
@@ -210,4 +256,23 @@ export function createDispatcher(
|
|
|
210
256
|
|
|
211
257
|
createMemberReader: (tenantId) => createMemberReaderFn(ctx, tenantId),
|
|
212
258
|
};
|
|
259
|
+
|
|
260
|
+
dispatcherInternals.set(dispatcher, {
|
|
261
|
+
writeWithOrigin: async (type, payload, user, inheritedOrigin) => {
|
|
262
|
+
const batchResult = await runBatch(
|
|
263
|
+
ctx,
|
|
264
|
+
[{ type, payload }],
|
|
265
|
+
user,
|
|
266
|
+
undefined,
|
|
267
|
+
inheritedOrigin,
|
|
268
|
+
);
|
|
269
|
+
return unwrapSingle(batchResult);
|
|
270
|
+
},
|
|
271
|
+
queryWithOrigin: (type, payload, user, inheritedOrigin) => {
|
|
272
|
+
const origin = effectiveWriteOrigin(rootWriteOrigin(registry, type, user), inheritedOrigin);
|
|
273
|
+
return runWithWriteOrigin(origin, () => executeQuery(ctx, type, payload, user, origin));
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
return dispatcher;
|
|
213
278
|
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
index,
|
|
6
6
|
instant,
|
|
7
7
|
integer,
|
|
8
|
+
jsonb,
|
|
8
9
|
table as pgTable,
|
|
9
10
|
primaryKey,
|
|
10
11
|
sql,
|
|
@@ -64,6 +65,21 @@ export const SHARED_INSTANCE_SENTINEL = "__shared__";
|
|
|
64
65
|
// The default(sql`0`) on lastProcessedEventId mirrors projection-state.ts:
|
|
65
66
|
// drizzle-kit's JSON snapshot generator can't serialise a bigint literal, so
|
|
66
67
|
// the server-side default is specified as raw SQL instead of .default(0n).
|
|
68
|
+
|
|
69
|
+
// A contiguous run of ids below the cursor invisible on some earlier turn,
|
|
70
|
+
// not yet resolved (delivered) or proven burnt (see event-dispatcher.ts's
|
|
71
|
+
// processConsumer). Ranges, not individual ids — a large historical gap
|
|
72
|
+
// (retention prune, or a new consumer starting "beginning" over pruned
|
|
73
|
+
// history) then costs O(1) entries instead of one per missing id. xmax is
|
|
74
|
+
// the pg_snapshot_xmax() captured when the range was recorded: once a later
|
|
75
|
+
// turn's xmin passes it, every xact that could still produce a row in this
|
|
76
|
+
// range has finished, so it's provably rolled back, not just slow.
|
|
77
|
+
// bigint/xid8 travel as strings — JS bigint doesn't round-trip through jsonb.
|
|
78
|
+
export type PendingGapEntry = {
|
|
79
|
+
readonly from: string;
|
|
80
|
+
readonly to: string;
|
|
81
|
+
readonly xmax: string;
|
|
82
|
+
};
|
|
67
83
|
export const eventConsumerStateTable = pgTable(
|
|
68
84
|
"kumiko_event_consumers",
|
|
69
85
|
{
|
|
@@ -82,6 +98,7 @@ export const eventConsumerStateTable = pgTable(
|
|
|
82
98
|
// poisoned), as does a manual restartConsumer()/enableConsumer()/
|
|
83
99
|
// skipPoisonEvent() — an operator vouching the consumer is healthy.
|
|
84
100
|
rearmCount: integer("rearm_count").notNull().default(0),
|
|
101
|
+
pendingGaps: jsonb("pending_gaps").$type<PendingGapEntry[]>().default([]).notNull(),
|
|
85
102
|
lastError: text("last_error"),
|
|
86
103
|
updatedAt: instant("updated_at", { precision: 3 }).notNull().default(sql`now()`),
|
|
87
104
|
},
|
|
@@ -142,6 +159,15 @@ export async function createEventConsumerStateTable(db: DbConnection): Promise<v
|
|
|
142
159
|
" NOT NULL",
|
|
143
160
|
/* ifNotExists */ true,
|
|
144
161
|
);
|
|
162
|
+
await alterTableAddColumn(
|
|
163
|
+
db,
|
|
164
|
+
"kumiko_event_consumers",
|
|
165
|
+
"pending_gaps",
|
|
166
|
+
"jsonb",
|
|
167
|
+
" DEFAULT '[]'::jsonb",
|
|
168
|
+
" NOT NULL",
|
|
169
|
+
/* ifNotExists */ true,
|
|
170
|
+
);
|
|
145
171
|
// Runs on every boot, including mid-rolling-deploy: a new pod can delete
|
|
146
172
|
// an old pod's still-in-use per-instance row out from under it. That old
|
|
147
173
|
// pod's next acquireConsumer then finds no row, delivers no more SSE/
|