@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
|
@@ -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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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}"
|
|
79
|
-
|
|
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
|
|
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);
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -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
|
|
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();
|