@cosmicdrift/kumiko-framework 0.249.0 → 0.251.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 (57) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/full-stack.integration.test.ts +3 -1
  3. package/src/crypto/__tests__/event-pii.test.ts +25 -3
  4. package/src/crypto/__tests__/kms-wiring.test.ts +15 -2
  5. package/src/crypto/__tests__/pg-kms-adapter.integration.test.ts +18 -0
  6. package/src/crypto/event-pii.ts +7 -1
  7. package/src/crypto/index.ts +1 -0
  8. package/src/crypto/kms-wiring.ts +23 -10
  9. package/src/db/__tests__/event-store-executor-reference-sort.integration.test.ts +258 -0
  10. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +33 -0
  11. package/src/db/entity-table-meta.ts +9 -2
  12. package/src/db/event-store-executor-read.ts +124 -7
  13. package/src/db/table-builder.ts +10 -0
  14. package/src/engine/__tests__/engine.test.ts +132 -2
  15. package/src/engine/__tests__/event-migration-declarative.test.ts +2 -0
  16. package/src/engine/__tests__/extend-entity-projection.test.ts +15 -5
  17. package/src/engine/__tests__/pipeline-handler.integration.test.ts +1 -1
  18. package/src/engine/__tests__/screen.test.ts +184 -0
  19. package/src/engine/boot-validator/index.ts +5 -1
  20. package/src/engine/boot-validator/projection-list-screens.ts +42 -0
  21. package/src/engine/boot-validator/screens.ts +45 -29
  22. package/src/engine/entity-handlers.ts +4 -0
  23. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +1 -0
  24. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -1
  25. package/src/engine/feature-ast/__tests__/parse.test.ts +27 -1
  26. package/src/engine/feature-ast/__tests__/patch.test.ts +1 -0
  27. package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -0
  28. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +56 -3
  29. package/src/engine/feature-ast/extractors/events.ts +24 -0
  30. package/src/engine/feature-ast/patcher.ts +6 -1
  31. package/src/engine/feature-ast/patterns.ts +6 -0
  32. package/src/engine/feature-ast/render.ts +3 -4
  33. package/src/engine/feature-config-events-jobs.ts +33 -9
  34. package/src/engine/pattern-library/__tests__/library.test.ts +1 -0
  35. package/src/engine/registry-facade.ts +10 -1
  36. package/src/engine/registry-state.ts +8 -0
  37. package/src/engine/registry-validate.ts +54 -3
  38. package/src/engine/types/index.ts +1 -0
  39. package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +4 -1
  40. package/src/entrypoint/__tests__/split-deploy.integration.test.ts +10 -3
  41. package/src/event-store/__tests__/upcaster.integration.test.ts +8 -1
  42. package/src/jobs/__tests__/job-write-as.integration.test.ts +206 -0
  43. package/src/jobs/job-runner.ts +12 -4
  44. package/src/pipeline/__tests__/archive-stream.integration.test.ts +3 -1
  45. package/src/pipeline/__tests__/causation-chain.integration.test.ts +3 -1
  46. package/src/pipeline/__tests__/domain-events-projections.integration.test.ts +3 -1
  47. package/src/pipeline/__tests__/event-define-event-strict.integration.test.ts +6 -2
  48. package/src/pipeline/__tests__/fetch-for-writing.integration.test.ts +6 -2
  49. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +3 -1
  50. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -0
  51. package/src/pipeline/__tests__/msp-multi-hop.integration.test.ts +5 -3
  52. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +3 -0
  53. package/src/pipeline/__tests__/multi-stream-projection.integration.test.ts +2 -0
  54. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +3 -1
  55. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +260 -0
  56. package/src/pipeline/__tests__/try-append-event.integration.test.ts +3 -1
  57. package/src/pipeline/system-hooks.ts +94 -25
@@ -121,3 +121,45 @@ export function validateProjectionListScreens(features: readonly FeatureDefiniti
121
121
  }
122
122
  }
123
123
  }
124
+
125
+ // relatedList sections declare search/facets the same way a projectionList
126
+ // screen does, and hit the same 422 footgun: definePagedQueryHandler doesn't
127
+ // auto-merge params into the handler's own Zod schema (fw#2165/#2224), so an
128
+ // undeclared param would fail every query instead of failing at boot.
129
+ // Reachable on projectionDetail only (screens.ts rejects relatedList
130
+ // elsewhere), but the layout walk stays uniform so lifting that restriction
131
+ // doesn't quietly skip these checks.
132
+ export function validateRelatedListSectionQueries(features: readonly FeatureDefinition[]): void {
133
+ const queryHandlers = buildQueryHandlerMap(features);
134
+ for (const feature of features) {
135
+ for (const screen of Object.values(feature.screens)) {
136
+ if (
137
+ screen.type !== "projectionDetail" &&
138
+ screen.type !== "entityEdit" &&
139
+ screen.type !== "actionForm" &&
140
+ screen.type !== "configEdit"
141
+ ) {
142
+ continue;
143
+ }
144
+ for (const section of screen.layout.sections) {
145
+ if (section.kind !== "relatedList") continue;
146
+ const prefix = `[Feature ${feature.name}] Screen "${screen.id}" (${screen.type}) relatedList section "${section.title}"`;
147
+ const schema = queryHandlers.get(section.query)?.schema;
148
+ if (section.searchable === true && !schemaAccepts(schema, "search")) {
149
+ throw new Error(
150
+ `${prefix}: searchable: true but query "${section.query}" has no "search" parameter in its Zod schema`,
151
+ );
152
+ }
153
+ if (
154
+ section.facets !== undefined &&
155
+ section.facets.length > 0 &&
156
+ !schemaAccepts(schema, "filters")
157
+ ) {
158
+ throw new Error(
159
+ `${prefix}: declares facets but query "${section.query}" has no "filters" parameter in its Zod schema — add filters: z.array(z.object({ field: z.string(), op: z.literal("in"), value: z.unknown() })).optional() (or reuse entityListSchema's shape) to the handler's schema.`,
160
+ );
161
+ }
162
+ }
163
+ }
164
+ }
165
+ }
@@ -25,6 +25,8 @@ import type {
25
25
  EditFieldSpec,
26
26
  EditLayout,
27
27
  FieldCondition,
28
+ ListColumnSpec,
29
+ ListFacetSpec,
28
30
  RowAction,
29
31
  RowActionNavigateBase,
30
32
  RowFieldExtractor,
@@ -559,37 +561,12 @@ export function validateScreens(
559
561
  `filter.value to be a readonly array.`,
560
562
  );
561
563
  }
562
- // Facets (fw#2224) — unlike filter, a field inventory IS available
563
- // here: the declared columns. A facet on a field with no column is
564
- // almost always a typo (the user never sees the field anywhere), so
565
- // this is hard-checked rather than just documented.
566
564
  if (screen.facets !== undefined) {
567
- const columnFieldNames = new Set(
568
- screen.columns.map((col) => normalizeListColumn(col).field),
565
+ validateListFacets(
566
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionList)`,
567
+ screen.facets,
568
+ screen.columns,
569
569
  );
570
- const seenFacetFields = new Set<string>();
571
- for (const facet of screen.facets) {
572
- if (seenFacetFields.has(facet.field)) {
573
- throw new Error(
574
- `[Feature ${feature.name}] Screen "${screenId}" (projectionList) declares facet ` +
575
- `"${facet.field}" more than once.`,
576
- );
577
- }
578
- seenFacetFields.add(facet.field);
579
- if (!columnFieldNames.has(facet.field)) {
580
- throw new Error(
581
- `[Feature ${feature.name}] Screen "${screenId}" (projectionList) facet references field ` +
582
- `"${facet.field}" which is not a declared column. Known columns: ` +
583
- `${[...columnFieldNames].sort().join(", ")}`,
584
- );
585
- }
586
- if (facet.type === "select" && facet.options.length === 0) {
587
- throw new Error(
588
- `[Feature ${feature.name}] Screen "${screenId}" (projectionList) facet "${facet.field}" ` +
589
- `(type "select") has an empty options list — declare at least one option.`,
590
- );
591
- }
592
- }
593
570
  }
594
571
  if (screen.rowActions !== undefined) {
595
572
  for (const action of screen.rowActions) {
@@ -863,6 +840,13 @@ export function validateScreens(
863
840
  );
864
841
  }
865
842
  }
843
+ if (section.facets !== undefined) {
844
+ validateListFacets(
845
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) relatedList section "${section.title}"`,
846
+ section.facets,
847
+ section.columns,
848
+ );
849
+ }
866
850
  continue;
867
851
  }
868
852
  if (isWriteFormEditSection(section)) {
@@ -1805,6 +1789,38 @@ export function validateColumnRendererForm(
1805
1789
  }
1806
1790
  }
1807
1791
 
1792
+ // Facets (fw#2224) — unlike filter, a field inventory IS available here: the
1793
+ // declared columns. A facet on a field with no column is almost always a
1794
+ // typo (the user never sees the field anywhere), so this is hard-checked
1795
+ // rather than just documented. Shared by projectionList and relatedList
1796
+ // (fw#2740) — `prefix` carries the caller's own screen/section message lead-in.
1797
+ function validateListFacets(
1798
+ prefix: string,
1799
+ facets: readonly ListFacetSpec[],
1800
+ columns: readonly ListColumnSpec[],
1801
+ ): void {
1802
+ const columnFieldNames = new Set(columns.map((col) => normalizeListColumn(col).field));
1803
+ const seenFacetFields = new Set<string>();
1804
+ for (const facet of facets) {
1805
+ if (seenFacetFields.has(facet.field)) {
1806
+ throw new Error(`${prefix} declares facet "${facet.field}" more than once.`);
1807
+ }
1808
+ seenFacetFields.add(facet.field);
1809
+ if (!columnFieldNames.has(facet.field)) {
1810
+ throw new Error(
1811
+ `${prefix} facet references field "${facet.field}" which is not a declared column. ` +
1812
+ `Known columns: ${[...columnFieldNames].sort().join(", ")}`,
1813
+ );
1814
+ }
1815
+ if (facet.type === "select" && facet.options.length === 0) {
1816
+ throw new Error(
1817
+ `${prefix} facet "${facet.field}" (type "select") has an empty options list — ` +
1818
+ `declare at least one option.`,
1819
+ );
1820
+ }
1821
+ }
1822
+ }
1823
+
1808
1824
  export function findEntityFeature(
1809
1825
  entityName: string,
1810
1826
  featureMap: ReadonlyMap<string, FeatureDefinition>,
@@ -384,6 +384,10 @@ export function defineEntityQueryHandler(
384
384
  fields: ctx.registry.getSearchableReferences(entityName),
385
385
  resolveEntity: (name) => ctx.registry.getEntity(name),
386
386
  },
387
+ referenceSort: {
388
+ fields: ctx.registry.getSortableReferences(entityName),
389
+ resolveEntity: (name) => ctx.registry.getEntity(name),
390
+ },
387
391
  });
388
392
  const enrichedRows = hasRefFields
389
393
  ? await enrichWithReferences(
@@ -56,6 +56,7 @@ defineFeature("todoList", (r) => {
56
56
  r.defineEvent({
57
57
  name: "taskCompleted",
58
58
  schema: z.object({ id: z.string() }),
59
+ piiFields: "none",
59
60
  version: 2,
60
61
  migrations: {
61
62
  "1": (old) => ({ ...old, done: true }),
@@ -42,7 +42,7 @@ defineFeature("todoList", (r) => {
42
42
  },
43
43
  });
44
44
 
45
- r.defineEvent("taskCompleted", z.object({ id: z.string() }), { version: 1 });
45
+ r.defineEvent("taskCompleted", z.object({ id: z.string() }), { piiFields: "none", version: 1 });
46
46
 
47
47
  r.writeHandler(
48
48
  "task:create",
@@ -2224,7 +2224,7 @@ describe("extractDefineEvent", () => {
2224
2224
  test("captures eventName + version", () => {
2225
2225
  const result = parseInline(`
2226
2226
  defineFeature("f", (r) => {
2227
- r.defineEvent("incidentOpened", z.object({ id: z.string() }), { version: 2 });
2227
+ r.defineEvent("incidentOpened", z.object({ id: z.string() }), { piiFields: "none", version: 2 });
2228
2228
  });
2229
2229
  `);
2230
2230
 
@@ -2241,6 +2241,7 @@ defineFeature("f", (r) => {
2241
2241
  r.defineEvent({
2242
2242
  name: "incidentOpened",
2243
2243
  schema: z.object({ id: z.string() }),
2244
+ piiFields: "none",
2244
2245
  version: 2,
2245
2246
  migrations: { "1": (payload) => payload },
2246
2247
  });
@@ -2285,6 +2286,17 @@ defineFeature("f", (r) => {
2285
2286
  expect(result.errors[0]?.methodName).toBe("defineEvent");
2286
2287
  });
2287
2288
 
2289
+ test("emits ParseError when object form is missing the piiFields property", () => {
2290
+ const result = parseInline(`
2291
+ defineFeature("f", (r) => {
2292
+ r.defineEvent({ name: "incidentOpened", schema: z.object({ id: z.string() }) });
2293
+ });
2294
+ `);
2295
+
2296
+ expect(result.errors[0]?.methodName).toBe("defineEvent");
2297
+ expect(result.errors[0]?.reason).toContain("piiFields");
2298
+ });
2299
+
2288
2300
  test("emits ParseError when the event name is not a string literal", () => {
2289
2301
  const result = parseInline(`
2290
2302
  defineFeature("f", (r) => {
@@ -2304,6 +2316,17 @@ defineFeature("f", (r) => {
2304
2316
 
2305
2317
  expect(result.errors[0]?.methodName).toBe("defineEvent");
2306
2318
  });
2319
+
2320
+ test("emits ParseError when the positional form is missing the piiFields option", () => {
2321
+ const result = parseInline(`
2322
+ defineFeature("f", (r) => {
2323
+ r.defineEvent("incidentOpened", z.object({ id: z.string() }), { version: 2 });
2324
+ });
2325
+ `);
2326
+
2327
+ expect(result.errors[0]?.methodName).toBe("defineEvent");
2328
+ expect(result.errors[0]?.reason).toContain("piiFields");
2329
+ });
2307
2330
  });
2308
2331
 
2309
2332
  describe("extractDefineEvent — migrations (formerly extractEventMigration)", () => {
@@ -2311,6 +2334,7 @@ describe("extractDefineEvent — migrations (formerly extractEventMigration)", (
2311
2334
  const result = parseInline(`
2312
2335
  defineFeature("f", (r) => {
2313
2336
  r.defineEvent("incidentOpened", z.object({ id: z.string() }), {
2337
+ piiFields: "none",
2314
2338
  version: 2,
2315
2339
  migrations: [
2316
2340
  { fromVersion: 1, toVersion: 2, transform: (payload) => ({ ...payload, severity: "low" }) },
@@ -2333,6 +2357,7 @@ defineFeature("f", (r) => {
2333
2357
  r.defineEvent({
2334
2358
  name: "incidentOpened",
2335
2359
  schema: z.object({ id: z.string() }),
2360
+ piiFields: "none",
2336
2361
  version: 2,
2337
2362
  migrations: {
2338
2363
  "1": (payload) => ({ ...payload, severity: "low" }),
@@ -2353,6 +2378,7 @@ defineFeature("f", (r) => {
2353
2378
  const result = parseInline(`
2354
2379
  defineFeature("f", (r) => {
2355
2380
  r.defineEvent("incidentOpened", z.object({ id: z.string() }), {
2381
+ piiFields: "none",
2356
2382
  migrations: [
2357
2383
  "skip-me",
2358
2384
  { fromVersion: "bad", transform: (payload) => payload },
@@ -307,6 +307,7 @@ describe("patch coverage for the remaining pattern-kinds", () => {
307
307
  createFeaturePatcher(sf).addDefineEvent({
308
308
  name: "itemCreated",
309
309
  schemaSource: "z.object({ id: z.string() })",
310
+ piiFieldsSource: '"none"',
310
311
  version: 2,
311
312
  migrations: { "1": "(old) => old" },
312
313
  });
@@ -178,6 +178,7 @@ describe("FeaturePatcher — typed add helpers for mixed (closure-bearing) patte
178
178
  p.addDefineEvent({
179
179
  name: "stepCompleted",
180
180
  schemaSource: "z.object({ id: z.string() })",
181
+ piiFieldsSource: '"none"',
181
182
  version: 2,
182
183
  migrations: { "1": '(old) => ({ id: old.id ?? "" })' },
183
184
  });
@@ -86,6 +86,7 @@ function parse(source: string): {
86
86
  // to match the new context.
87
87
  const BODY_LOC_KEYS = new Set([
88
88
  "schemaSource",
89
+ "piiFields",
89
90
  "handlerBody",
90
91
  "fnBody",
91
92
  "transformBody",
@@ -228,7 +229,7 @@ defineFeature("workflow", (r) => {
228
229
  console.log("step deleted");
229
230
  });
230
231
 
231
- r.defineEvent("stepCompleted", z.object({ id: z.string() }), { version: 1 });
232
+ r.defineEvent("stepCompleted", z.object({ id: z.string() }), { piiFields: "none", version: 1 });
232
233
 
233
234
  r.nav({ id: "steps", label: "Steps", screen: "workflow:screen:step-list" });
234
235
  });
@@ -458,8 +459,18 @@ describe("renderPattern — single-pattern shape", () => {
458
459
  end: { line: 1, column: 1 },
459
460
  raw: "z.object({})",
460
461
  },
462
+ piiFields: {
463
+ file: "x",
464
+ start: { line: 1, column: 1 },
465
+ end: { line: 1, column: 1 },
466
+ raw: '"none"',
467
+ },
461
468
  });
462
- expect(out).toBe("r.defineEvent(DOC_INGESTED_EVENT, z.object({}));");
469
+ expect(out).toBe(
470
+ ["r.defineEvent(DOC_INGESTED_EVENT, z.object({}), {", ' piiFields: "none",', "});"].join(
471
+ "\n",
472
+ ),
473
+ );
463
474
  });
464
475
  });
465
476
 
@@ -479,7 +490,7 @@ const DOC_INGESTED_EVENT = "docIngested" as const;
479
490
  defineFeature("user-data-rights", (r) => {
480
491
  r.extendsRegistrar(EXT_TENANT_DATA, { onRegister: () => {} });
481
492
  r.useExtension(EXT_TENANT_DATA, "document", { description: "org-scoped" });
482
- r.defineEvent(DOC_INGESTED_EVENT, z.object({ id: z.string() }));
493
+ r.defineEvent(DOC_INGESTED_EVENT, z.object({ id: z.string() }), { piiFields: "none" });
483
494
  });
484
495
  `;
485
496
 
@@ -625,6 +636,7 @@ import { z } from "zod";
625
636
 
626
637
  defineFeature("billing", (r) => {
627
638
  r.defineEvent("invoicePaid", z.object({ totalCents: z.number() }), {
639
+ piiFields: "none",
628
640
  version: 2,
629
641
  migrations: [
630
642
  {
@@ -656,6 +668,47 @@ describe("render → parse roundtrip — r.defineEvent({ migrations }) fold", ()
656
668
  });
657
669
  });
658
670
 
671
+ const DEFINE_EVENT_WITH_PII_FIELDS_FEATURE = `
672
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
673
+ import { z } from "zod";
674
+
675
+ defineFeature("mailer", (r) => {
676
+ r.defineEvent("attempt", z.object({ recipientId: z.string(), recipientAddress: z.string() }), {
677
+ piiFields: { recipientAddress: { subjectField: "recipientId" } },
678
+ });
679
+ });
680
+ `;
681
+
682
+ describe("render → parse roundtrip — r.defineEvent piiFields stance (fw#2558)", () => {
683
+ test('piiFields: "none" survives parse → render → parse unchanged', () => {
684
+ const initial = parse(DEFINE_EVENT_WITH_MIGRATIONS_FEATURE);
685
+ const rendered = renderFeatureFile({
686
+ featureName: initial.featureName ?? "",
687
+ patterns: initial.patterns,
688
+ });
689
+ expect(rendered).toContain('piiFields: "none",');
690
+ const reparsed = parse(rendered);
691
+ expect(reparsed.patterns.map(stripLocations)).toEqual(initial.patterns.map(stripLocations));
692
+ });
693
+
694
+ test("an object-literal piiFields stance survives parse → render → parse unchanged", () => {
695
+ const initial = parse(DEFINE_EVENT_WITH_PII_FIELDS_FEATURE);
696
+ const rendered = renderFeatureFile({
697
+ featureName: initial.featureName ?? "",
698
+ patterns: initial.patterns,
699
+ });
700
+ expect(rendered).toContain('piiFields: { recipientAddress: { subjectField: "recipientId" } }');
701
+ const reparsed = parse(rendered);
702
+ expect(reparsed.patterns.map(stripLocations)).toEqual(initial.patterns.map(stripLocations));
703
+
704
+ const eventPattern = reparsed.patterns.find((p) => p.kind === "defineEvent");
705
+ expect(eventPattern?.kind).toBe("defineEvent");
706
+ if (eventPattern?.kind === "defineEvent") {
707
+ expect(eventPattern.piiFields.raw).toContain("recipientAddress");
708
+ }
709
+ });
710
+ });
711
+
659
712
  // --- Real-compile check ---------------------------------------------------
660
713
  //
661
714
  // Everything above proves parse(render(patterns)) is structurally faithful.
@@ -131,6 +131,17 @@ export function extractDefineEvent(
131
131
  const v = readDataLiteralNode(versionInit);
132
132
  if (typeof v === "number") version = v;
133
133
  }
134
+ const piiFieldsInit = obj
135
+ .getProperty("piiFields")
136
+ ?.asKind(SyntaxKind.PropertyAssignment)
137
+ ?.getInitializer();
138
+ if (!piiFieldsInit) {
139
+ return fail(
140
+ "defineEvent",
141
+ sourceLocationFromNode(call, sourceFile),
142
+ 'requires an explicit `piiFields` stance (an object literal or "none")',
143
+ );
144
+ }
134
145
  const migrationsInit = obj
135
146
  .getProperty("migrations")
136
147
  ?.asKind(SyntaxKind.PropertyAssignment)
@@ -143,6 +154,7 @@ export function extractDefineEvent(
143
154
  source: sourceLocationFromNode(call, sourceFile),
144
155
  eventName: nameInit.getLiteralValue(),
145
156
  schemaSource: sourceLocationFromNode(schemaInit, sourceFile),
157
+ piiFields: sourceLocationFromNode(piiFieldsInit, sourceFile),
146
158
  ...(version !== undefined && { version }),
147
159
  ...(migrations !== undefined && { migrations }),
148
160
  });
@@ -168,6 +180,17 @@ export function extractDefineEvent(
168
180
  let migrations: Readonly<Record<string, SourceLocation>> | undefined;
169
181
  const optionsArg = args[2];
170
182
  const optionsObj = optionsArg?.asKind(SyntaxKind.ObjectLiteralExpression);
183
+ const piiFieldsInit = optionsObj
184
+ ?.getProperty("piiFields")
185
+ ?.asKind(SyntaxKind.PropertyAssignment)
186
+ ?.getInitializer();
187
+ if (!piiFieldsInit) {
188
+ return fail(
189
+ "defineEvent",
190
+ sourceLocationFromNode(call, sourceFile),
191
+ 'requires an explicit `piiFields` stance (an object literal or "none")',
192
+ );
193
+ }
171
194
  if (optionsObj) {
172
195
  const versionInit = optionsObj
173
196
  .getProperty("version")
@@ -191,6 +214,7 @@ export function extractDefineEvent(
191
214
  eventName: eventNameRef.value,
192
215
  ...(eventNameRef.raw !== undefined && { eventNameRaw: eventNameRef.raw }),
193
216
  schemaSource: sourceLocationFromNode(schemaArg, sourceFile),
217
+ piiFields: sourceLocationFromNode(piiFieldsInit, sourceFile),
194
218
  ...(version !== undefined && { version }),
195
219
  ...(migrations !== undefined && { migrations }),
196
220
  });
@@ -147,6 +147,10 @@ export type AddHttpRouteArgs = {
147
147
  export type AddDefineEventArgs = {
148
148
  readonly name: string;
149
149
  readonly schemaSource: string;
150
+ // Raw source of the `piiFields` option value (e.g. `'"none"'` or an object
151
+ // literal) — mandatory, mirroring r.defineEvent's own required stance
152
+ // (fw#2558).
153
+ readonly piiFieldsSource: string;
150
154
  readonly version?: number;
151
155
  // Keyed by fromVersion (as a string) → the transform source text for the
152
156
  // fromVersion -> fromVersion+1 step. Folded in from the former
@@ -496,7 +500,7 @@ export function createFeaturePatcher(sourceFile: SourceFile): FeaturePatcher {
496
500
  });
497
501
  },
498
502
 
499
- addDefineEvent({ name, schemaSource, version, migrations }) {
503
+ addDefineEvent({ name, schemaSource, piiFieldsSource, version, migrations }) {
500
504
  const migrationLocs = migrations
501
505
  ? Object.fromEntries(
502
506
  Object.entries(migrations).map(([fromVersion, source]) => [
@@ -510,6 +514,7 @@ export function createFeaturePatcher(sourceFile: SourceFile): FeaturePatcher {
510
514
  source: SYNTHETIC_LOC,
511
515
  eventName: name,
512
516
  schemaSource: rawLoc(schemaSource),
517
+ piiFields: rawLoc(piiFieldsSource),
513
518
  ...(version !== undefined && { version }),
514
519
  ...(migrationLocs !== undefined && { migrations: migrationLocs }),
515
520
  });
@@ -561,6 +561,12 @@ export type DefineEventPattern = {
561
561
  readonly eventNameRaw?: string;
562
562
  readonly schemaSource: SourceLocation;
563
563
  readonly version?: number;
564
+ // Raw source of the `piiFields` option value ("none", an object literal,
565
+ // or a constant reference) — mandatory because `r.defineEvent` itself
566
+ // requires an explicit PII stance (fw#2558); round-tripping it verbatim
567
+ // avoids the extractor ever having to invent a stance the author didn't
568
+ // author.
569
+ readonly piiFields: SourceLocation;
564
570
  // Map fromVersion (as string, e.g. "1") → SourceLocation of the transform
565
571
  // closure for the fromVersion → fromVersion+1 step.
566
572
  readonly migrations?: Readonly<Record<string, SourceLocation>>;
@@ -596,11 +596,10 @@ function renderMultiStreamProjection(p: MultiStreamProjectionPattern): string {
596
596
  function renderDefineEvent(p: DefineEventPattern): string {
597
597
  const nameLiteral = p.eventNameRaw ?? JSON.stringify(p.eventName);
598
598
  const migrationEntries = p.migrations !== undefined ? Object.entries(p.migrations) : [];
599
- const hasOptions = p.version !== undefined || migrationEntries.length > 0;
600
- if (!hasOptions) {
601
- return `r.defineEvent(${nameLiteral}, ${p.schemaSource.raw});`;
602
- }
599
+ // piiFields is a mandatory option (fw#2558) — the options object is
600
+ // always rendered, unlike the old version/migrations-only shortcut.
603
601
  const lines: string[] = [`r.defineEvent(${nameLiteral}, ${p.schemaSource.raw}, {`];
602
+ lines.push(` piiFields: ${p.piiFields.raw},`);
604
603
  if (p.version !== undefined) lines.push(` version: ${p.version},`);
605
604
  if (migrationEntries.length > 0) {
606
605
  lines.push(" migrations: [");
@@ -14,6 +14,7 @@ import type {
14
14
  DeclarativeEventMigration,
15
15
  EventDef,
16
16
  EventPiiFields,
17
+ EventPiiStance,
17
18
  EventUpcastFn,
18
19
  JobDefinition,
19
20
  JobHandlerFn,
@@ -217,9 +218,9 @@ export function buildConfigEventsJobsMethods<TName extends string>(
217
218
  defineEvent: <const TInner extends string, TPayload>(
218
219
  eventName: TInner,
219
220
  schema: ZodType<TPayload>,
220
- options?: {
221
+ options: {
221
222
  readonly version?: number;
222
- readonly piiFields?: EventPiiFields;
223
+ readonly piiFields: EventPiiStance;
223
224
  // Step-wise upcast chain for this event, folded in from the former
224
225
  // standalone r.eventMigration() call (#1082 step 8) — an event and
225
226
  // its schema evolution are one lifecycle, not two registrar
@@ -232,6 +233,33 @@ export function buildConfigEventsJobsMethods<TName extends string>(
232
233
  }[];
233
234
  },
234
235
  ): EventDef<TPayload, QualifiedEventName<TName, TInner>> => {
236
+ // Fail-closed PII stance gate (fw#2558) — runs before anything else so
237
+ // an untyped JS consumer (no compiler to stop it) still can't register
238
+ // an event without declaring whether its payload carries personal data.
239
+ const missingStanceError = () =>
240
+ new Error(
241
+ `[Feature ${name}] defineEvent("${eventName}") must declare an explicit PII stance. Pass { piiFields: { <payloadField>: { subjectField: "<userIdField>" } } } for payload fields holding personal data, or { piiFields: "none" } when the payload holds none.`,
242
+ );
243
+ if (options === undefined || options.piiFields === undefined) {
244
+ throw missingStanceError();
245
+ }
246
+ const piiFields = options.piiFields;
247
+ // @cast-boundary runtime-guard — an untyped JS caller can hand
248
+ // anything through `piiFields` (including null), so re-check the
249
+ // shape before trusting it as EventPiiStance: `typeof null === "object"`
250
+ // would otherwise pass through to Object.keys(null) below and throw a
251
+ // raw TypeError instead of this guidance message.
252
+ if (piiFields !== "none" && (typeof piiFields !== "object" || piiFields === null)) {
253
+ throw missingStanceError();
254
+ }
255
+ if (piiFields !== "none" && Object.keys(piiFields).length === 0) {
256
+ throw new Error(
257
+ `[Feature ${name}] defineEvent("${eventName}"): piiFields: {} is not a stance — use piiFields: "none" to declare the payload holds no personal data.`,
258
+ );
259
+ }
260
+ if (piiFields !== "none") {
261
+ validateEventPiiFields(eventName, schema, piiFields);
262
+ }
235
263
  // Return the fully-qualified event name so callers can pass it
236
264
  // straight to ctx.appendEvent without hand-building the
237
265
  // "<feature>:event:<name>" shape. Registry keeps events keyed by
@@ -243,16 +271,12 @@ export function buildConfigEventsJobsMethods<TName extends string>(
243
271
  // returned `name` carries the literal qualified shape that the
244
272
  // augmented `KumikoEventTypeMap` keys against.
245
273
  const qualified = qn(toKebab(name), "event", toKebab(eventName));
246
- const version = options?.version ?? 1;
274
+ const version = options.version ?? 1;
247
275
  if (!Number.isInteger(version) || version < 1) {
248
276
  throw new Error(
249
277
  `[Feature ${name}] defineEvent("${eventName}"): version must be a positive integer, got ${String(version)}`,
250
278
  );
251
279
  }
252
- const piiFields = options?.piiFields;
253
- if (piiFields) {
254
- validateEventPiiFields(eventName, schema, piiFields);
255
- }
256
280
  // @cast-boundary engine-bridge — runtime-string mirrors the
257
281
  // template-literal-type via QualifiedEventName + toKebab. Both
258
282
  // sides are tested (CamelToKebab type-tests + scan-events kebab
@@ -261,10 +285,10 @@ export function buildConfigEventsJobsMethods<TName extends string>(
261
285
  name: qualified as QualifiedEventName<TName, TInner>,
262
286
  schema,
263
287
  version,
264
- ...(piiFields !== undefined && { piiFields }),
288
+ piiFields,
265
289
  };
266
290
  state.events[eventName] = def;
267
- for (const m of options?.migrations ?? []) {
291
+ for (const m of options.migrations ?? []) {
268
292
  registerEventMigration(eventName, m.fromVersion, m.toVersion, m.transform);
269
293
  }
270
294
  return def;
@@ -333,6 +333,7 @@ function makePlaceholderPattern(kind: FeaturePatternKind): FeaturePattern {
333
333
  source: PLACEHOLDER_LOC,
334
334
  eventName: "x",
335
335
  schemaSource: PLACEHOLDER_BODY_LOC,
336
+ piiFields: PLACEHOLDER_BODY_LOC,
336
337
  };
337
338
  case "extendsRegistrar":
338
339
  return {
@@ -1,4 +1,9 @@
1
- import type { IncomingRelation, RegistryState, SearchableReferenceField } from "./registry-state";
1
+ import type {
2
+ IncomingRelation,
3
+ RegistryState,
4
+ SearchableReferenceField,
5
+ SortableReferenceField,
6
+ } from "./registry-state";
2
7
  import { filterByPhase, filterOwned } from "./registry-state";
3
8
  import type {
4
9
  AuthClaimsHookDef,
@@ -101,6 +106,10 @@ export function buildRegistryFacade(state: RegistryState): Registry {
101
106
  return state.searchableReferencesCache.get(entityName) ?? [];
102
107
  },
103
108
 
109
+ getSortableReferences(entityName: string): readonly SortableReferenceField[] {
110
+ return state.sortableReferencesCache.get(entityName) ?? [];
111
+ },
112
+
104
113
  getIncomingRelations(entityName: string): readonly IncomingRelation[] {
105
114
  return state.incomingRelationsCache.get(entityName) ?? [];
106
115
  },
@@ -60,6 +60,12 @@ export type SearchableReferenceField = {
60
60
  readonly labelField: string;
61
61
  };
62
62
 
63
+ // One entry per `ReferenceFieldDef` with `sortable: true` (fw#2741). Same
64
+ // resolved shape as the searchable variant and the same boot guarantee on
65
+ // `labelField` — ORDER BY on the UUID column would look meaningful and be
66
+ // arbitrary.
67
+ export type SortableReferenceField = SearchableReferenceField;
68
+
63
69
  const IMPLICIT_PROJECTION_SUFFIX = "-entity" as const;
64
70
 
65
71
  // Pro r.entity-Registration eine ImplicitProjection mit auto-generierten
@@ -235,6 +241,7 @@ export type RegistryState = {
235
241
  searchableFieldsCache: Map<string, readonly string[]>;
236
242
  sortableFieldsCache: Map<string, readonly string[]>;
237
243
  searchableReferencesCache: Map<string, readonly SearchableReferenceField[]>;
244
+ sortableReferencesCache: Map<string, readonly SortableReferenceField[]>;
238
245
  incomingRelationsCache: Map<string, IncomingRelation[]>;
239
246
  hasRateLimitedHandlerCached: boolean;
240
247
  };
@@ -298,6 +305,7 @@ export function createInitialState(): RegistryState {
298
305
  searchableFieldsCache: new Map(),
299
306
  sortableFieldsCache: new Map(),
300
307
  searchableReferencesCache: new Map(),
308
+ sortableReferencesCache: new Map(),
301
309
  incomingRelationsCache: new Map(),
302
310
  hasRateLimitedHandlerCached: false,
303
311
  };