@cosmicdrift/kumiko-framework 0.305.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 (87) 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__/server-boot-guards.test.ts +1 -0
  4. package/src/api/__tests__/server-error-logging.test.ts +71 -0
  5. package/src/api/__tests__/sse-broker.test.ts +49 -0
  6. package/src/api/redis-sse-broker.ts +17 -3
  7. package/src/api/request-context.ts +29 -4
  8. package/src/api/routes.ts +26 -1
  9. package/src/api/sse-broker.ts +29 -11
  10. package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
  11. package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
  12. package/src/bun-db/query.ts +42 -18
  13. package/src/changes.json +92 -0
  14. package/src/db/__tests__/pg-error.test.ts +14 -0
  15. package/src/db/__tests__/system-db-view-export.test.ts +107 -0
  16. package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
  17. package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
  18. package/src/db/index.ts +1 -1
  19. package/src/db/pg-error.ts +13 -0
  20. package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
  21. package/src/db/queries/event-consumer.ts +57 -3
  22. package/src/db/queries/event-store.ts +69 -0
  23. package/src/db/tenant-db.ts +133 -18
  24. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
  25. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
  26. package/src/engine/__tests__/boot-validator.test.ts +1 -1
  27. package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
  28. package/src/engine/extension-names.ts +55 -25
  29. package/src/engine/extensions/storage-provider.ts +14 -41
  30. package/src/engine/extensions/tenant-data.ts +4 -0
  31. package/src/engine/extensions/tenant-resource.ts +40 -0
  32. package/src/engine/extensions/user-data.ts +8 -7
  33. package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
  34. package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
  35. package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
  36. package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
  37. package/src/engine/feature-ast/entity-field-types.ts +41 -0
  38. package/src/engine/feature-ast/extractors/handlers.ts +217 -84
  39. package/src/engine/feature-ast/extractors/hooks.ts +72 -15
  40. package/src/engine/feature-ast/extractors/round2.ts +21 -0
  41. package/src/engine/feature-ast/extractors/shared.ts +9 -0
  42. package/src/engine/feature-ast/index.ts +11 -1
  43. package/src/engine/feature-ast/patch.ts +338 -5
  44. package/src/engine/feature-ast/patcher.ts +2 -2
  45. package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
  46. package/src/engine/feature-ast/patterns.ts +22 -15
  47. package/src/engine/feature-ast/render.ts +1 -0
  48. package/src/engine/feature-ui-extensions.ts +8 -7
  49. package/src/engine/index.ts +21 -5
  50. package/src/engine/types/extension-options-map.ts +1 -0
  51. package/src/engine/types/index.ts +6 -0
  52. package/src/event-store/__tests__/event-attribution.integration.test.ts +53 -3
  53. package/src/event-store/admin-api.ts +5 -0
  54. package/src/event-store/event-store.ts +16 -7
  55. package/src/jobs/__tests__/job-public-intake-origin.integration.test.ts +536 -0
  56. package/src/jobs/__tests__/job-retention.integration.test.ts +316 -0
  57. package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
  58. package/src/jobs/__tests__/jobs.integration.test.ts +3 -3
  59. package/src/jobs/job-runner.ts +211 -19
  60. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
  61. package/src/pipeline/__tests__/dispatcher-utils.test.ts +8 -0
  62. package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
  63. package/src/pipeline/__tests__/event-dispatcher-commit-order.integration.test.ts +278 -0
  64. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +1 -0
  65. package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.test.ts +6 -6
  66. package/src/pipeline/__tests__/event-dispatcher-per-consumer-turns.integration.test.ts +126 -0
  67. package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
  68. package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
  69. package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +261 -2
  70. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
  71. package/src/pipeline/dispatch-batch.ts +102 -29
  72. package/src/pipeline/dispatch-stream.ts +7 -3
  73. package/src/pipeline/dispatcher-utils.ts +21 -2
  74. package/src/pipeline/dispatcher.ts +71 -6
  75. package/src/pipeline/event-consumer-state.ts +26 -0
  76. package/src/pipeline/event-dispatcher-admin.ts +32 -5
  77. package/src/pipeline/event-dispatcher-delivery.ts +109 -57
  78. package/src/pipeline/event-dispatcher.ts +167 -50
  79. package/src/pipeline/idempotency.ts +16 -0
  80. package/src/pipeline/pending-gap-ranges.ts +72 -0
  81. package/src/pipeline/system-hooks.ts +8 -1
  82. package/src/pipeline/system-identity-switch.ts +22 -4
  83. package/src/pipeline/write-origin.ts +31 -10
  84. package/src/stack/test-stack.ts +1 -1
  85. package/src/testing/closed-connection-error.ts +62 -0
  86. package/src/testing/index.ts +1 -0
  87. package/src/bun-db/__tests__/select-many-retry.test.ts +0 -79
@@ -71,7 +71,7 @@ import type {
71
71
  AgentHandlerHints,
72
72
  ClaimKeyType,
73
73
  EscapeHatchDeclaration,
74
- RateLimitOption,
74
+ RateLimitDeclaration,
75
75
  } from "../types/handlers";
76
76
  import type { HookPhase } from "../types/hooks";
77
77
  import type { HttpRouteMethod } from "../types/http-route";
@@ -81,6 +81,7 @@ import type { RelationDefinition } from "../types/relations";
81
81
  import type { ScreenDefinition } from "../types/screen";
82
82
  import type { TreeActionDef } from "../types/tree-node";
83
83
  import type { WorkspaceDefinition } from "../types/workspace";
84
+ import type { RawRefSentinel } from "./extractors/shared";
84
85
  import type { SourceLocation } from "./source-location";
85
86
 
86
87
  // =============================================================================
@@ -367,6 +368,10 @@ export type ScreenPattern = {
367
368
  // overrides that derivation explicitly (force-show or force-hide);
368
369
  // `agent.risk` ("low" | "mid" | "high") classifies the action's blast
369
370
  // radius, defaulting to "mid" for a write handler.
371
+ //
372
+ // access/rateLimit/escapeHatch/agent additionally accept a RawRefSentinel
373
+ // for a non-literal value (imported/same-file const, or a sub-value like
374
+ // `personalData: PD`) that would otherwise lose the reference on render.
370
375
  export type WriteHandlerPattern = {
371
376
  readonly kind: "writeHandler";
372
377
  readonly source: SourceLocation;
@@ -384,12 +389,12 @@ export type WriteHandlerPattern = {
384
389
  // handlerBody: the closure body as source text. Always opaque — AI
385
390
  // generates raw TypeScript, no DSL interpretation.
386
391
  readonly handlerBody?: SourceLocation;
387
- readonly access?: AccessRule;
392
+ readonly access?: AccessRule | RawRefSentinel;
388
393
  readonly description?: string;
389
- readonly agent?: AgentHandlerHints;
390
- readonly rateLimit?: RateLimitOption;
394
+ readonly agent?: AgentHandlerHints | RawRefSentinel;
395
+ readonly rateLimit?: RateLimitDeclaration | RawRefSentinel;
391
396
  readonly unsafeSkipTransitionGuard?: boolean;
392
- readonly escapeHatch?: EscapeHatchDeclaration;
397
+ readonly escapeHatch?: EscapeHatchDeclaration | RawRefSentinel;
393
398
  };
394
399
 
395
400
  // `r.queryHandler(...)` — registers a read handler: name, Zod input schema,
@@ -408,25 +413,27 @@ export type QueryHandlerPattern = {
408
413
  readonly handlerName?: string;
409
414
  readonly schemaSource?: SourceLocation;
410
415
  readonly handlerBody?: SourceLocation;
411
- readonly access?: AccessRule;
416
+ readonly access?: AccessRule | RawRefSentinel;
412
417
  readonly description?: string;
413
- readonly agent?: AgentHandlerHints;
414
- readonly rateLimit?: RateLimitOption;
415
- readonly escapeHatch?: EscapeHatchDeclaration;
418
+ readonly agent?: AgentHandlerHints | RawRefSentinel;
419
+ readonly rateLimit?: RateLimitDeclaration | RawRefSentinel;
420
+ readonly escapeHatch?: EscapeHatchDeclaration | RawRefSentinel;
416
421
  };
417
422
 
418
423
  // `r.streamHandler(...)` — registers a streaming read handler: name, Zod
419
- // input schema, async-generator handler closure, plus optional `access` and
420
- // `rateLimit` rules. Same header/body split and opaque single-reference
421
- // case as QueryHandlerPattern.
424
+ // input schema, async-generator handler closure, plus optional `access`,
425
+ // `rateLimit`, and `escapeHatch` rules (StreamHandlerDef carries all three
426
+ // at runtime). Same header/body split and opaque single-reference case as
427
+ // QueryHandlerPattern.
422
428
  export type StreamHandlerPattern = {
423
429
  readonly kind: "streamHandler";
424
430
  readonly source: SourceLocation;
425
431
  readonly handlerName?: string;
426
432
  readonly schemaSource?: SourceLocation;
427
433
  readonly handlerBody?: SourceLocation;
428
- readonly access?: AccessRule;
429
- readonly rateLimit?: RateLimitOption;
434
+ readonly access?: AccessRule | RawRefSentinel;
435
+ readonly rateLimit?: RateLimitDeclaration | RawRefSentinel;
436
+ readonly escapeHatch?: EscapeHatchDeclaration | RawRefSentinel;
430
437
  };
431
438
 
432
439
  // `r.hook(type, target, fn, options?)` — attaches a lifecycle hook
@@ -447,7 +454,7 @@ export type HookPattern = {
447
454
  readonly target: string | readonly string[] | { readonly allOf: string };
448
455
  readonly fnBody: SourceLocation;
449
456
  readonly phase?: HookPhase;
450
- readonly escapeHatch?: EscapeHatchDeclaration;
457
+ readonly escapeHatch?: EscapeHatchDeclaration | RawRefSentinel;
451
458
  };
452
459
 
453
460
  // `r.job(name, options, handler)` — registers a background job, qualified
@@ -436,6 +436,7 @@ function renderStreamHandler(p: StreamHandlerPattern): string {
436
436
  lines.push(` handler: ${reindentBody(p.handlerBody?.raw ?? "", PATTERN_INDENT)},`);
437
437
  if (p.access !== undefined) lines.push(` access: ${renderValue(p.access)},`);
438
438
  if (p.rateLimit !== undefined) lines.push(` rateLimit: ${renderValue(p.rateLimit)},`);
439
+ if (p.escapeHatch !== undefined) lines.push(` escapeHatch: ${renderValue(p.escapeHatch)},`);
439
440
  lines.push("});");
440
441
  return lines.join("\n");
441
442
  }
@@ -229,9 +229,9 @@ export function buildUiExtensionsMethods<TName extends string>(
229
229
  useExtension(
230
230
  extensionNameOrDefinition:
231
231
  | string
232
- | ({ readonly name: string; readonly entity: NameOrRef } & Record<string, unknown>),
232
+ | ({ readonly name: string; readonly entity: NameOrRef } & object),
233
233
  entityRef?: NameOrRef,
234
- options?: Record<string, unknown>,
234
+ options?: object,
235
235
  ): void {
236
236
  const [extensionName, resolvedEntityRef, resolvedOptions] =
237
237
  typeof extensionNameOrDefinition === "string"
@@ -241,11 +241,12 @@ export function buildUiExtensionsMethods<TName extends string>(
241
241
  return [name, entity, rest] as const;
242
242
  })();
243
243
  const resolvedEntityName = resolveName(resolvedEntityRef);
244
+ // @cast-boundary engine-bridge — typed per-extension options → erased registration bag
245
+ const optionsBag = resolvedOptions as Record<string, unknown> | undefined;
244
246
  // fw#2914 — cross-cutting escapeHatch convention for hook-context db
245
- // access (mirrors r.hook's validation above). Not part of a typed
246
- // per-extension options shape: useExtension's bag stays generic, but
247
- // this one key is validated for every extension the same way.
248
- const escapeHatch = resolvedOptions?.["escapeHatch"];
247
+ // access (mirrors r.hook's validation above). Validated the same way
248
+ // for every extension regardless of its typed hook shape.
249
+ const escapeHatch = optionsBag?.["escapeHatch"];
249
250
  if (escapeHatch !== undefined) {
250
251
  const reason =
251
252
  typeof escapeHatch === "object" && escapeHatch !== null
@@ -261,7 +262,7 @@ export function buildUiExtensionsMethods<TName extends string>(
261
262
  state.extensionUsages.push({
262
263
  extensionName,
263
264
  entityName: resolvedEntityName,
264
- options: resolvedOptions,
265
+ options: optionsBag,
265
266
  });
266
267
  },
267
268
  extensionSelector(extensionName: string, key: { readonly name: string } | string): void {
@@ -98,7 +98,7 @@ export {
98
98
  export { declareEscapeHatch } from "./escape-hatch-declaration";
99
99
  export type { EmitCtx } from "./event-helpers";
100
100
  export { emitEvent, typedPayload } from "./event-helpers";
101
- export type { KumikoExtensionName } from "./extension-names";
101
+ export type { KumikoExtensionName, TenantResourceExtensionName } from "./extension-names";
102
102
  export {
103
103
  EXT_DERIVATIVE_OVERLAY_RESOLVER,
104
104
  EXT_DERIVATIVE_PUBLIC_PREDICATE,
@@ -124,11 +124,18 @@ export type {
124
124
  StorageProviderExtensionHooks,
125
125
  StorageProviderHookCtx,
126
126
  } from "./extensions/storage-provider";
127
- export type {
128
- TenantDataDestroyHook,
129
- TenantDataExtensionHooks,
130
- TenantDataHookCtx,
127
+ export {
128
+ isTenantDataExtensionHooks,
129
+ type TenantDataDestroyHook,
130
+ type TenantDataExtensionHooks,
131
+ type TenantDataHookCtx,
131
132
  } from "./extensions/tenant-data";
133
+ export {
134
+ isTenantResourceExtensionHooks,
135
+ type TenantResourceDestroyHook,
136
+ type TenantResourceExtensionHooks,
137
+ type TenantResourceHookCtx,
138
+ } from "./extensions/tenant-resource";
132
139
  export type {
133
140
  TenantUserModel,
134
141
  UserDataDeleteHook,
@@ -136,6 +143,7 @@ export type {
136
143
  UserDataExportHook,
137
144
  UserDataExportSnippet,
138
145
  UserDataExtensionHooks,
146
+ UserDataExtensionOptions,
139
147
  UserDataHookCtx,
140
148
  UserDataStorageProvider,
141
149
  } from "./extensions/user-data";
@@ -178,10 +186,13 @@ export type {
178
186
  FormFieldLabel,
179
187
  FormFieldSpec,
180
188
  FormInputType,
189
+ HandlerHeaderUpdate,
181
190
  ParseError,
182
191
  ParseResult,
183
192
  PatternCategory,
184
193
  PatternChange,
194
+ PatternChangeIssue,
195
+ PatternChangesParseResult,
185
196
  PatternFormSchema,
186
197
  PatternId,
187
198
  RenderFeatureFileInput,
@@ -195,11 +206,13 @@ export {
195
206
  groupByCategory,
196
207
  PATTERN_LIBRARY,
197
208
  parseFeatureFile,
209
+ parsePatternChanges,
198
210
  parseSourceFile,
199
211
  removePattern,
200
212
  renderFeatureFile,
201
213
  renderPattern,
202
214
  replacePattern,
215
+ updatePattern,
203
216
  VERSION_HEADER,
204
217
  } from "./feature-ast";
205
218
  export {
@@ -392,6 +405,8 @@ export type {
392
405
  EscapeHatchTarget,
393
406
  EscapeHatchUseEvent,
394
407
  EventDef,
408
+ ExtensionOptionsArgs,
409
+ ExtensionOptionsFor,
395
410
  FeatureDefinition,
396
411
  FeatureRegistrar,
397
412
  FieldAccess,
@@ -419,6 +434,7 @@ export type {
419
434
  JsonbFieldDef,
420
435
  KumikoEntityTypeMap,
421
436
  KumikoEventTypeMap,
437
+ KumikoExtensionOptionsMap,
422
438
  KumikoHandlerPayloadMap,
423
439
  KumikoHandlerResultMap,
424
440
  LifecycleHookType,
@@ -0,0 +1 @@
1
+ export type * from "@cosmicdrift/kumiko-types/extension-options-map";
@@ -72,6 +72,12 @@ export type {
72
72
  KumikoHandlerPayloadMap,
73
73
  KumikoHandlerResultMap,
74
74
  } from "@cosmicdrift/kumiko-types/event-type-map";
75
+ // Cross-Feature Compile-Time-Type-Map for r.useExtension options — mirrors KumikoEventTypeMap above.
76
+ export type {
77
+ ExtensionOptionsArgs,
78
+ ExtensionOptionsFor,
79
+ KumikoExtensionOptionsMap,
80
+ } from "@cosmicdrift/kumiko-types/extension-options-map";
75
81
  export type {
76
82
  BootCheckContext,
77
83
  BootCheckFn,
@@ -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