@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.
Files changed (33) 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__/sse-broker.test.ts +49 -0
  4. package/src/api/redis-sse-broker.ts +17 -3
  5. package/src/api/request-context.ts +24 -0
  6. package/src/api/sse-broker.ts +29 -11
  7. package/src/changes.json +26 -0
  8. package/src/db/queries/event-consumer.ts +57 -3
  9. package/src/db/queries/event-store.ts +69 -0
  10. package/src/db/tenant-db.ts +43 -5
  11. package/src/event-store/__tests__/event-attribution.integration.test.ts +53 -3
  12. package/src/event-store/admin-api.ts +5 -0
  13. package/src/event-store/event-store.ts +16 -7
  14. package/src/jobs/__tests__/job-public-intake-origin.integration.test.ts +536 -0
  15. package/src/jobs/job-runner.ts +61 -6
  16. package/src/pipeline/__tests__/dispatcher-utils.test.ts +8 -0
  17. package/src/pipeline/__tests__/event-dispatcher-commit-order.integration.test.ts +278 -0
  18. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +1 -0
  19. package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.test.ts +6 -6
  20. package/src/pipeline/__tests__/event-dispatcher-per-consumer-turns.integration.test.ts +126 -0
  21. package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +261 -2
  22. package/src/pipeline/dispatch-batch.ts +49 -19
  23. package/src/pipeline/dispatch-stream.ts +7 -3
  24. package/src/pipeline/dispatcher-utils.ts +21 -2
  25. package/src/pipeline/dispatcher.ts +71 -6
  26. package/src/pipeline/event-consumer-state.ts +26 -0
  27. package/src/pipeline/event-dispatcher-admin.ts +32 -5
  28. package/src/pipeline/event-dispatcher-delivery.ts +109 -57
  29. package/src/pipeline/event-dispatcher.ts +167 -50
  30. package/src/pipeline/pending-gap-ranges.ts +72 -0
  31. package/src/pipeline/system-hooks.ts +8 -1
  32. package/src/pipeline/write-origin.ts +31 -10
  33. package/src/stack/test-stack.ts +1 -1
@@ -0,0 +1,72 @@
1
+ import type { PendingIdRange } from "../db/queries/event-store";
2
+ import type { PendingGapEntry } from "./event-consumer-state";
3
+
4
+ export function toIdRanges(gaps: readonly PendingGapEntry[]): PendingIdRange[] {
5
+ return gaps.map((g) => ({ from: BigInt(g.from), to: BigInt(g.to) }));
6
+ }
7
+
8
+ export function rangeContainsId(range: PendingGapEntry, id: bigint): boolean {
9
+ return id >= BigInt(range.from) && id <= BigInt(range.to);
10
+ }
11
+
12
+ // A range is only provably burnt (rolled back, not just slow) once it's
13
+ // certain the fetch would have surfaced any of its ids had they been
14
+ // visible. LIMIT can cut a fetch off before reaching the range's ids —
15
+ // `to < maxFetchedId` proves the scan passed the range regardless of the
16
+ // cutoff, since ORDER BY id ASC LIMIT N always includes every matching id
17
+ // below the largest one it did return.
18
+ export function isRangeFullyCoveredByFetch(
19
+ range: PendingGapEntry,
20
+ truncated: boolean,
21
+ maxFetchedId: bigint | null,
22
+ ): boolean {
23
+ if (!truncated) return true;
24
+ if (maxFetchedId === null) return false;
25
+ return BigInt(range.to) < maxFetchedId;
26
+ }
27
+
28
+ // A gap is burnt when none of its ids showed up in this turn's fetch, the
29
+ // fetch is proven to have covered it, and its recorded xmax is behind this
30
+ // turn's xmin (every xact that could still produce a row has finished).
31
+ export function partitionBurntGaps(
32
+ gaps: readonly PendingGapEntry[],
33
+ fetchedIds: readonly bigint[],
34
+ truncated: boolean,
35
+ xminNow: string,
36
+ ): { readonly burnt: PendingGapEntry[]; readonly surviving: PendingGapEntry[] } {
37
+ const maxFetchedId = fetchedIds.at(-1) ?? null;
38
+ const burnt: PendingGapEntry[] = [];
39
+ const surviving: PendingGapEntry[] = [];
40
+ for (const gap of gaps) {
41
+ const hasVisibleId = fetchedIds.some((id) => rangeContainsId(gap, id));
42
+ const isBurnt =
43
+ !hasVisibleId &&
44
+ isRangeFullyCoveredByFetch(gap, truncated, maxFetchedId) &&
45
+ BigInt(gap.xmax) <= BigInt(xminNow);
46
+ (isBurnt ? burnt : surviving).push(gap);
47
+ }
48
+ return { burnt, surviving };
49
+ }
50
+
51
+ // Carves `excludeIds` (delivered or skip-applied this turn) out of `range`,
52
+ // yielding 0-2 sub-ranges. xmax is preserved on every surviving piece.
53
+ export function splitRangeExcludingIds(
54
+ range: PendingGapEntry,
55
+ excludeIds: readonly bigint[],
56
+ ): PendingGapEntry[] {
57
+ const from = BigInt(range.from);
58
+ const to = BigInt(range.to);
59
+ const inRange = excludeIds
60
+ .filter((id) => id >= from && id <= to)
61
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
62
+ if (inRange.length === 0) return [range];
63
+ const parts: PendingGapEntry[] = [];
64
+ let cursor = from;
65
+ for (const id of inRange) {
66
+ if (id > cursor)
67
+ parts.push({ from: cursor.toString(), to: (id - 1n).toString(), xmax: range.xmax });
68
+ cursor = id + 1n;
69
+ }
70
+ if (cursor <= to) parts.push({ from: cursor.toString(), to: to.toString(), xmax: range.xmax });
71
+ return parts;
72
+ }
@@ -490,6 +490,13 @@ function readUserIdFromPreviousSnapshot(payload: Record<string, unknown>): strin
490
490
  return typeof userId === "string" && userId.length > 0 ? userId : undefined;
491
491
  }
492
492
 
493
+ // A "revoke all others" write must not cut the caller's own live stream, so
494
+ // session-revoked carries the spared sid. Missing or malformed → userwide.
495
+ function readSessionRevokedKeptSessionId(payload: Record<string, unknown>): string | undefined {
496
+ const keptSessionId = payload["keptSessionId"];
497
+ return typeof keptSessionId === "string" && keptSessionId.length > 0 ? keptSessionId : undefined;
498
+ }
499
+
493
500
  export function createAccessInvalidationEventConsumer(sseBroker: SseBroker): EventConsumer {
494
501
  return {
495
502
  name: ACCESS_INVALIDATION_CONSUMER_NAME,
@@ -511,7 +518,7 @@ export function createAccessInvalidationEventConsumer(sseBroker: SseBroker): Eve
511
518
  // poison would otherwise permanently stop access-invalidation for
512
519
  // every user behind one bad row).
513
520
  if (typeof userId !== "string" || userId.length === 0) return;
514
- sseBroker.publishAccessInvalidation(userId);
521
+ sseBroker.publishAccessInvalidation(userId, readSessionRevokedKeptSessionId(event.payload));
515
522
  }
516
523
 
517
524
  if (
@@ -1,6 +1,8 @@
1
1
  // The static check in boot-validator/access-declarations.ts only sees a handler's own
2
2
  // input schema; writes reached via ctx.write/writeAs/queryAs, hooks or foreign-feature
3
3
  // tables are only visible at the actual write, so the gate runs there at runtime.
4
+ import { isPersonalDataGated, type WriteOrigin } from "@cosmicdrift/kumiko-types/event-store-types";
5
+ import { z } from "zod";
4
6
  import { buildEntityTable } from "../db/table-builder";
5
7
  import { type PersonalDataGate, tableNameOf } from "../db/tenant-db";
6
8
  import {
@@ -15,12 +17,29 @@ import { AccessDeniedError } from "../errors";
15
17
  import { FrameworkReasons } from "../errors/reasons";
16
18
  import { toSnakeCase } from "../utils/case";
17
19
 
18
- export type WriteOrigin = {
19
- readonly rootHandler: string;
20
- readonly anonymousRoot: boolean;
21
- // Only a write-handler root can declare public-intake; a query/stream root never does.
22
- readonly publicIntake: boolean;
23
- };
20
+ export { isPersonalDataGated, type WriteOrigin };
21
+
22
+ // Only narrows: an inherited origin can add a gate, never lift the root's.
23
+ export function effectiveWriteOrigin(root: WriteOrigin, inherited?: WriteOrigin): WriteOrigin {
24
+ if (!inherited) return root;
25
+ if (isPersonalDataGated(root)) return root;
26
+ if (isPersonalDataGated(inherited)) return inherited;
27
+ return root;
28
+ }
29
+
30
+ const writeOriginSchema = z
31
+ .object({
32
+ rootHandler: z.string(),
33
+ anonymousRoot: z.boolean(),
34
+ publicIntake: z.boolean(),
35
+ viaJob: z.string().optional(),
36
+ })
37
+ .strict();
38
+
39
+ export function parseWriteOrigin(value: unknown): WriteOrigin | undefined {
40
+ const parsed = writeOriginSchema.safeParse(value);
41
+ return parsed.success ? parsed.data : undefined;
42
+ }
24
43
 
25
44
  function declaresPublicIntake(access: AccessRule): boolean {
26
45
  return accessAllowsAnonymous(access) && declaredPersonalData(access) === "public-intake";
@@ -67,7 +86,7 @@ function personalDataTableMap(registry: Registry): ReadonlyMap<string, ReadonlyS
67
86
  }
68
87
 
69
88
  // Names fields only, never values: the error reaches the anonymous HTTP caller.
70
- function publicIntakeRequiredError(
89
+ export function publicIntakeRequiredError(
71
90
  origin: WriteOrigin,
72
91
  target: string,
73
92
  fields: readonly string[],
@@ -75,14 +94,16 @@ function publicIntakeRequiredError(
75
94
  return new AccessDeniedError({
76
95
  message:
77
96
  `Anonymous root handler "${origin.rootHandler}" wrote personal-data field(s) ` +
78
- `${fields.map((f) => `"${f}"`).join(", ")} on "${target}". Declare ` +
79
- 'access: { roles: [..., "anonymous"], personalData: "public-intake" } on ' +
97
+ `${fields.map((f) => `"${f}"`).join(", ")} on "${target}"` +
98
+ (origin.viaJob ? ` via job "${origin.viaJob}"` : "") +
99
+ '. Declare access: { roles: [..., "anonymous"], personalData: "public-intake" } on ' +
80
100
  `"${origin.rootHandler}" to allow anonymous callers to write personal data.`,
81
101
  details: {
82
102
  reason: FrameworkReasons.publicIntakeRequired,
83
103
  rootHandler: origin.rootHandler,
84
104
  target,
85
105
  fields,
106
+ ...(origin.viaJob !== undefined && { job: origin.viaJob }),
86
107
  },
87
108
  });
88
109
  }
@@ -93,7 +114,7 @@ export function buildPersonalDataGate(
93
114
  registry: Registry,
94
115
  origin: WriteOrigin,
95
116
  ): PersonalDataGate | undefined {
96
- if (!origin.anonymousRoot || origin.publicIntake) return undefined;
117
+ if (!isPersonalDataGated(origin)) return undefined;
97
118
  const map = personalDataTableMap(registry);
98
119
  return (tableName, keys, entity) => {
99
120
  const personalFields = entity ? personalColumnNames(entity) : map.get(tableName);
@@ -470,7 +470,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
470
470
 
471
471
  // Pre-register consumer state rows so tests can call runOnce() directly
472
472
  // without a preceding explicit start(). Timer fires at pollIntervalMs=50
473
- // but passInFlight serialises concurrent passes — tests that drain via
473
+ // but each consumer runs at most one turn at a time — tests that drain via
474
474
  // runOnce() remain deterministic. Tests that specifically exercise the
475
475
  // timer loop call start() again (idempotent) after setup.
476
476
  if (eventDispatcher) await eventDispatcher.ensureRegistered();