@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
@@ -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({ features: [attributionFeature], systemHooks: [] });
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 fires on commit (PG buffers NOTIFY per TX), so subscribers never
102
- // see a wake-up for an event that later rolled back. Harmless no-op when
103
- // no LISTENer is attached.
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
- ...event.metadata,
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 — concurrent
384
- // inserts get distinct ids in commit order, so no duplicates and no skips
385
- // past the cursor).
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