@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
|
@@ -14,12 +14,13 @@
|
|
|
14
14
|
// and stay readable without the new fields.
|
|
15
15
|
|
|
16
16
|
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
17
|
-
import { UNATTRIBUTED_ORIGIN } from "@cosmicdrift/kumiko-types/event-store-types";
|
|
17
|
+
import { UNATTRIBUTED_ORIGIN, type WriteOrigin } from "@cosmicdrift/kumiko-types/event-store-types";
|
|
18
18
|
import { z } from "zod";
|
|
19
19
|
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
20
20
|
import { selectMany } from "../../db/query";
|
|
21
21
|
import { buildEntityTable } from "../../db/table-builder";
|
|
22
22
|
import { createEntity, createTextField, defineFeature } from "../../engine";
|
|
23
|
+
import type { TenantId } from "../../engine/types";
|
|
23
24
|
import {
|
|
24
25
|
resetEventStore,
|
|
25
26
|
setupTestStack,
|
|
@@ -43,8 +44,11 @@ const orderTable = buildEntityTable("attr-order", orderEntity);
|
|
|
43
44
|
|
|
44
45
|
const PLACED = "attribution:event:placed";
|
|
45
46
|
const CONFIRMED = "attribution:event:confirmed";
|
|
47
|
+
const PROBED = "attribution:event:probed";
|
|
46
48
|
const PLACE_HANDLER = "attribution:write:order:place";
|
|
49
|
+
const PROBE_HANDLER = "attribution:write:order:probe";
|
|
47
50
|
const CONFIRMER_MSP = "attribution:projection:confirmer";
|
|
51
|
+
const TENANT_ID = "00000000-0000-4000-8000-000000000002" as TenantId;
|
|
48
52
|
|
|
49
53
|
const attributionFeature = defineFeature("attribution", (r) => {
|
|
50
54
|
r.entity("attr-order", orderEntity);
|
|
@@ -75,6 +79,26 @@ const attributionFeature = defineFeature("attribution", (r) => {
|
|
|
75
79
|
{ access: { roles: ["Admin"] } },
|
|
76
80
|
);
|
|
77
81
|
|
|
82
|
+
const probed = r.defineEvent("probed", z.object({ orderId: z.uuid() }), { piiFields: "none" });
|
|
83
|
+
|
|
84
|
+
// Anonymous, no public-intake declared, no PII field written (nothing to gate).
|
|
85
|
+
r.writeHandler(
|
|
86
|
+
"order:probe",
|
|
87
|
+
z.object({ item: z.string() }),
|
|
88
|
+
async (event, ctx) => {
|
|
89
|
+
const created = await orderExecutor.create({ item: event.payload.item }, event.user, ctx.db);
|
|
90
|
+
if (!created.isSuccess) return created;
|
|
91
|
+
await ctx.unsafeAppendEvent({
|
|
92
|
+
aggregateId: String(created.data.id),
|
|
93
|
+
aggregateType: "attr-order",
|
|
94
|
+
type: probed.name,
|
|
95
|
+
payload: { orderId: String(created.data.id) },
|
|
96
|
+
});
|
|
97
|
+
return created;
|
|
98
|
+
},
|
|
99
|
+
{ access: { roles: ["anonymous"] } },
|
|
100
|
+
);
|
|
101
|
+
|
|
78
102
|
r.multiStreamProjection({
|
|
79
103
|
name: "confirmer",
|
|
80
104
|
apply: {
|
|
@@ -95,7 +119,11 @@ let stack: TestStack;
|
|
|
95
119
|
const admin = TestUsers.admin;
|
|
96
120
|
|
|
97
121
|
beforeAll(async () => {
|
|
98
|
-
stack = await setupTestStack({
|
|
122
|
+
stack = await setupTestStack({
|
|
123
|
+
features: [attributionFeature],
|
|
124
|
+
systemHooks: [],
|
|
125
|
+
anonymousAccess: { defaultTenantId: TENANT_ID },
|
|
126
|
+
});
|
|
99
127
|
await unsafeCreateEntityTable(stack.db, orderEntity, "attr-order");
|
|
100
128
|
});
|
|
101
129
|
|
|
@@ -107,7 +135,7 @@ afterEach(async () => {
|
|
|
107
135
|
await resetEventStore(stack, ["read_attribution_orders"]);
|
|
108
136
|
});
|
|
109
137
|
|
|
110
|
-
type Origin = { feature?: string; handler?: string };
|
|
138
|
+
type Origin = { feature?: string; handler?: string; writeOrigin?: WriteOrigin };
|
|
111
139
|
|
|
112
140
|
async function originOf(type: string): Promise<Origin> {
|
|
113
141
|
const rows = await selectMany(stack.db, eventsTable);
|
|
@@ -165,6 +193,28 @@ describe("#3043 — event attribution from the execution scope", () => {
|
|
|
165
193
|
});
|
|
166
194
|
});
|
|
167
195
|
|
|
196
|
+
test("authenticated write: the event carries no writeOrigin at all", async () => {
|
|
197
|
+
await stack.http.writeOk(PLACE_HANDLER, { item: "cog" }, admin);
|
|
198
|
+
|
|
199
|
+
const origin = await originOf(PLACED);
|
|
200
|
+
expect(origin.writeOrigin).toBeUndefined();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("anonymous non-intake write: the event is stamped with the gated origin", async () => {
|
|
204
|
+
const res = await stack.http.raw("POST", "/api/write", {
|
|
205
|
+
type: PROBE_HANDLER,
|
|
206
|
+
payload: { item: "washer" },
|
|
207
|
+
});
|
|
208
|
+
expect(res.status).toBe(200);
|
|
209
|
+
|
|
210
|
+
const origin = await originOf(PROBED);
|
|
211
|
+
expect(origin.writeOrigin).toMatchObject({
|
|
212
|
+
rootHandler: PROBE_HANDLER,
|
|
213
|
+
anonymousRoot: true,
|
|
214
|
+
publicIntake: false,
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
168
218
|
test("appendRaw keeps historical metadata verbatim and stays readable", async () => {
|
|
169
219
|
const aggregateId = uuid();
|
|
170
220
|
await appendRaw(stack.db, {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import type { DbRunner } from "../db";
|
|
11
11
|
import { constraintOf, isUniqueViolation } from "../db/pg-error";
|
|
12
|
+
import { claimXactId } from "../db/queries/event-store";
|
|
12
13
|
import {
|
|
13
14
|
eventPredecessorExists,
|
|
14
15
|
findExistingEventVersion,
|
|
@@ -54,6 +55,9 @@ export async function appendRaw(runner: DbRunner, event: RawEventToAppend): Prom
|
|
|
54
55
|
const eventVersion = event.eventVersion ?? 1;
|
|
55
56
|
|
|
56
57
|
try {
|
|
58
|
+
// See db/queries/event-store.ts's claimXactId — gap-finality needs a
|
|
59
|
+
// real xact id assigned before this holder's insert.
|
|
60
|
+
await claimXactId(runner);
|
|
57
61
|
if (event.expectedVersion === 0) {
|
|
58
62
|
await insertRawFirst(runner, event, newVersion, eventVersion);
|
|
59
63
|
} else {
|
|
@@ -145,6 +149,7 @@ export async function appendRawBatch(
|
|
|
145
149
|
});
|
|
146
150
|
|
|
147
151
|
try {
|
|
152
|
+
await claimXactId(runner);
|
|
148
153
|
await insertRawEventBatch(runner, valuesClauses.join(", "), params);
|
|
149
154
|
} catch (e) {
|
|
150
155
|
if (isUniqueViolation(e)) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type EventMetadata,
|
|
3
|
+
isPersonalDataGated,
|
|
3
4
|
type StoredEvent,
|
|
4
5
|
UNATTRIBUTED_ORIGIN,
|
|
5
6
|
} from "@cosmicdrift/kumiko-types/event-store-types";
|
|
@@ -15,6 +16,7 @@ import { encryptEventPayloadPii } from "../crypto/event-pii";
|
|
|
15
16
|
import type { DbRunner } from "../db";
|
|
16
17
|
import { constraintOf, isUniqueViolation } from "../db/pg-error";
|
|
17
18
|
import {
|
|
19
|
+
claimXactId,
|
|
18
20
|
insertSubsequentEventRow,
|
|
19
21
|
notifyPgChannel,
|
|
20
22
|
selectAggregateMaxVersion,
|
|
@@ -93,14 +95,16 @@ export async function append(db: DbRunner, event: EventToAppend): Promise<Stored
|
|
|
93
95
|
const eventVersion = toStore.eventVersion ?? 1;
|
|
94
96
|
|
|
95
97
|
try {
|
|
98
|
+
await claimXactId(db);
|
|
99
|
+
|
|
96
100
|
const row =
|
|
97
101
|
toStore.expectedVersion === 0
|
|
98
102
|
? await insertFirstEvent(db, toStore, newVersion, eventVersion)
|
|
99
103
|
: await insertSubsequentEvent(db, toStore, newVersion, eventVersion);
|
|
100
104
|
|
|
101
|
-
// NOTIFY
|
|
102
|
-
//
|
|
103
|
-
//
|
|
105
|
+
// NOTIFY after the INSERT: outside a transaction each statement commits
|
|
106
|
+
// on its own, so a NOTIFY sent first would wake the dispatcher before
|
|
107
|
+
// the row exists.
|
|
104
108
|
await notifyPgChannel(db, EVENTS_PUBSUB_CHANNEL);
|
|
105
109
|
|
|
106
110
|
return buildStoredEvent(toStore, newVersion, eventVersion, row);
|
|
@@ -127,12 +131,15 @@ export async function append(db: DbRunner, event: EventToAppend): Promise<Stored
|
|
|
127
131
|
// bypass this deliberately — they replay historical rows verbatim.
|
|
128
132
|
function stampOrigin(event: EventToAppend): EventToAppend {
|
|
129
133
|
const origin = requestContext.get();
|
|
134
|
+
const { writeOrigin: _droppedCallerWriteOrigin, ...restMetadata } = event.metadata;
|
|
130
135
|
return {
|
|
131
136
|
...event,
|
|
132
137
|
metadata: {
|
|
133
|
-
...
|
|
138
|
+
...restMetadata,
|
|
134
139
|
feature: origin?.feature ?? UNATTRIBUTED_ORIGIN,
|
|
135
140
|
handler: origin?.handler ?? UNATTRIBUTED_ORIGIN,
|
|
141
|
+
...(origin?.writeOrigin &&
|
|
142
|
+
isPersonalDataGated(origin.writeOrigin) && { writeOrigin: origin.writeOrigin }),
|
|
136
143
|
},
|
|
137
144
|
};
|
|
138
145
|
}
|
|
@@ -380,9 +387,11 @@ export async function loadAllEventsByType(
|
|
|
380
387
|
|
|
381
388
|
// Stream every event for an aggregate_type across all tenants, batchwise
|
|
382
389
|
// instead of buffered. Memory-bounded: never more than `batchSize` rows
|
|
383
|
-
// resident. Cursor walks `events.id` (bigserial monotonic
|
|
384
|
-
//
|
|
385
|
-
//
|
|
390
|
+
// resident. Cursor walks `events.id` (bigserial monotonic, but ids are
|
|
391
|
+
// assigned at INSERT time, not commit time — a sweep can overtake a still-
|
|
392
|
+
// open transaction and its lower id, same gap the event-dispatcher's
|
|
393
|
+
// pending_gaps tracks). Fine for this generator's use case: history sweeps
|
|
394
|
+
// (projection-rebuild, tests) that don't need live, just-committed rows.
|
|
386
395
|
//
|
|
387
396
|
// Use case: projection-rebuild on a large event log (>100k events per
|
|
388
397
|
// aggregate-type). loadAllEventsByType would OOM; this iterator yields
|