@cosmicdrift/kumiko-framework 0.249.0 → 0.250.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 (53) 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/entity-handlers.ts +4 -0
  19. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +1 -0
  20. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -1
  21. package/src/engine/feature-ast/__tests__/parse.test.ts +27 -1
  22. package/src/engine/feature-ast/__tests__/patch.test.ts +1 -0
  23. package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -0
  24. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +56 -3
  25. package/src/engine/feature-ast/extractors/events.ts +24 -0
  26. package/src/engine/feature-ast/patcher.ts +6 -1
  27. package/src/engine/feature-ast/patterns.ts +6 -0
  28. package/src/engine/feature-ast/render.ts +3 -4
  29. package/src/engine/feature-config-events-jobs.ts +33 -9
  30. package/src/engine/pattern-library/__tests__/library.test.ts +1 -0
  31. package/src/engine/registry-facade.ts +10 -1
  32. package/src/engine/registry-state.ts +8 -0
  33. package/src/engine/registry-validate.ts +54 -3
  34. package/src/engine/types/index.ts +1 -0
  35. package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +4 -1
  36. package/src/entrypoint/__tests__/split-deploy.integration.test.ts +10 -3
  37. package/src/event-store/__tests__/upcaster.integration.test.ts +8 -1
  38. package/src/jobs/__tests__/job-write-as.integration.test.ts +206 -0
  39. package/src/jobs/job-runner.ts +12 -4
  40. package/src/pipeline/__tests__/archive-stream.integration.test.ts +3 -1
  41. package/src/pipeline/__tests__/causation-chain.integration.test.ts +3 -1
  42. package/src/pipeline/__tests__/domain-events-projections.integration.test.ts +3 -1
  43. package/src/pipeline/__tests__/event-define-event-strict.integration.test.ts +6 -2
  44. package/src/pipeline/__tests__/fetch-for-writing.integration.test.ts +6 -2
  45. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +3 -1
  46. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -0
  47. package/src/pipeline/__tests__/msp-multi-hop.integration.test.ts +5 -3
  48. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +3 -0
  49. package/src/pipeline/__tests__/multi-stream-projection.integration.test.ts +2 -0
  50. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +3 -1
  51. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +260 -0
  52. package/src/pipeline/__tests__/try-append-event.integration.test.ts +3 -1
  53. package/src/pipeline/system-hooks.ts +94 -25
@@ -300,6 +300,69 @@ async function resolveReferenceMatches(
300
300
  return { ownColumn: descriptor.ownColumn, ids: rows.map((r) => r.id) };
301
301
  }
302
302
 
303
+ // Projected alongside the row when a reference sort is active, so the keyset
304
+ // cursor carries the label that was ordered by instead of the FK's UUID.
305
+ const SORT_LABEL_ALIAS = "__kumiko_sort_label";
306
+
307
+ // fw#2741 — resolve a `sortable: true` reference field's label inline for
308
+ // ORDER BY. A correlated scalar subquery, not a join: the result set keeps one
309
+ // row per entity row, the read model stays untouched, and the lookup rides the
310
+ // target's primary key. Tenant-scoped exactly like the search path — a foreign
311
+ // tenant's label must never influence this tenant's ordering.
312
+ // undefined means "cannot order by this label", which drops the sort to plain
313
+ // id order; falling back to the UUID column would look deliberate to the user
314
+ // while being arbitrary, which the issue calls worse than no sorting.
315
+ function buildReferenceSortExpr(
316
+ outerTableName: string,
317
+ ownColumnSql: string,
318
+ descriptor: { readonly targetEntityName: string; readonly labelField: string },
319
+ resolveEntity: (entityName: string) => EntityDefinition | undefined,
320
+ db: TenantDb,
321
+ ): ((params: unknown[]) => string) | undefined {
322
+ const targetEntity = resolveEntity(descriptor.targetEntityName);
323
+ if (targetEntity === undefined) {
324
+ // skip: unknown target entity — registry inconsistency the boot-validator
325
+ // should have caught; drop the sort rather than 500 the request
326
+ return undefined;
327
+ }
328
+ if (targetEntity.fields[descriptor.labelField] === undefined) {
329
+ // skip: labelField isn't a real (declared) column on the target entity
330
+ return undefined;
331
+ }
332
+ if (
333
+ collectEncryptedFieldNames(targetEntity).has(descriptor.labelField) ||
334
+ collectPiiSubjectFields(targetEntity).includes(descriptor.labelField)
335
+ ) {
336
+ // skip: encrypted/PII labelField — ciphertext orders by its bytes, which is
337
+ // arbitrary to the reader and would also rank rows by their stored secret
338
+ return undefined;
339
+ }
340
+
341
+ const targetTable = buildEntityTable(descriptor.targetEntityName, targetEntity);
342
+ const targetTableName = String(
343
+ (targetTable as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL],
344
+ );
345
+ const labelCol = physicalColumnName(targetTable, descriptor.labelField);
346
+ const tenantScoped = targetTable["tenantId"] !== undefined && db.mode === "tenant";
347
+ const tenantCol = tenantScoped ? physicalColumnName(targetTable, "tenantId") : "";
348
+ const tenantId = db.mode === "tenant" ? db.tenantId : undefined;
349
+
350
+ // No ::text cast (unlike the ILIKE search path): a non-text label must keep
351
+ // its native collation/numeric order, and the keyset param infers its type
352
+ // from the comparison anyway.
353
+ return (params: unknown[]): string => {
354
+ let tenantClause = "";
355
+ if (tenantScoped) {
356
+ params.push(tenantId, SYSTEM_TENANT_ID);
357
+ tenantClause = ` AND t."${tenantCol}" IN ($${params.length - 1}, $${params.length})`;
358
+ }
359
+ return (
360
+ `(SELECT t."${labelCol}" FROM "${targetTableName}" t ` +
361
+ `WHERE t."id" = "${outerTableName}".${ownColumnSql}${tenantClause})`
362
+ );
363
+ };
364
+ }
365
+
303
366
  export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor, "list" | "detail"> {
304
367
  const {
305
368
  table,
@@ -431,12 +494,35 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
431
494
  whereSql.push(`(${orParts.join(" OR ")})`);
432
495
  }
433
496
 
434
- const sortField =
497
+ const sortCandidate =
435
498
  payload.sort && table[payload.sort] && fieldReadClause(payload.sort).kind === "pass"
436
499
  ? payload.sort
437
500
  : undefined;
438
501
  const sortDescending = payload.sortDirection === "desc";
439
502
 
503
+ // fw#2741 — a reference column stores a UUID, so it is never sorted by
504
+ // directly: either the field opted into `sortable` and we order by the
505
+ // target row's label, or the sort is dropped to plain id order.
506
+ let sortField: string | undefined;
507
+ let referenceSortExpr: ((params: unknown[]) => string) | undefined;
508
+ if (sortCandidate !== undefined && entity.fields[sortCandidate]?.type === "reference") {
509
+ const referenceSort = runtimeOptions?.referenceSort;
510
+ const descriptor = referenceSort?.fields.find((f) => f.fieldName === sortCandidate);
511
+ referenceSortExpr =
512
+ referenceSort && descriptor
513
+ ? buildReferenceSortExpr(
514
+ tableName,
515
+ colSql(sortCandidate),
516
+ descriptor,
517
+ referenceSort.resolveEntity,
518
+ db,
519
+ )
520
+ : undefined;
521
+ if (referenceSortExpr !== undefined) sortField = sortCandidate;
522
+ } else {
523
+ sortField = sortCandidate;
524
+ }
525
+
440
526
  // Tenant-Filter (replicates TenantDb's readWhere semantics).
441
527
  if (table["tenantId"] !== undefined && db.mode === "tenant") {
442
528
  params.push(db.tenantId, SYSTEM_TENANT_ID);
@@ -453,7 +539,7 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
453
539
  } else {
454
540
  whereSql.push(
455
541
  keysetBoundarySql(
456
- colSql(sortField),
542
+ referenceSortExpr !== undefined ? referenceSortExpr(params) : colSql(sortField),
457
543
  colSql("id"),
458
544
  { id: cursor.id, sortValue: cursor.sortValue },
459
545
  sortDescending,
@@ -501,17 +587,40 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
501
587
  if (payload.filter !== undefined) applyFilter(payload.filter);
502
588
  if (payload.filters !== undefined) for (const f of payload.filters) applyFilter(f);
503
589
 
590
+ // The COUNT below reuses the WHERE text only, so it must bind exactly the
591
+ // params that text references — Postgres rejects a statement supplied
592
+ // with more parameters than it uses, and ORDER BY may add its own.
593
+ const whereParams = params.slice();
594
+
595
+ const sortSql =
596
+ sortField === undefined
597
+ ? undefined
598
+ : referenceSortExpr !== undefined
599
+ ? referenceSortExpr(params)
600
+ : colSql(sortField);
504
601
  const orderByClause =
505
- sortField !== undefined
506
- ? ` ORDER BY ${colSql(sortField)} ${sortDescending ? "DESC" : "ASC"}, ${colSql("id")} ASC`
602
+ sortSql !== undefined
603
+ ? ` ORDER BY ${sortSql} ${sortDescending ? "DESC" : "ASC"}, ${colSql("id")} ASC`
507
604
  : ` ORDER BY ${colSql("id")} ASC`;
508
605
  const useOffset = !payload.cursor && offset > 0;
509
606
  const offsetClause = useOffset ? ` OFFSET ${offset}` : "";
510
607
 
511
608
  const whereClauseSqlText = whereSql.length > 0 ? ` WHERE ${whereSql.join(" AND ")}` : "";
512
- const listSql = `SELECT * FROM "${tableName}"${whereClauseSqlText}${orderByClause} LIMIT ${limit}${offsetClause}`;
609
+ // Project the resolved label too, so the keyset cursor below carries the
610
+ // value that was ordered by rather than the FK column's UUID.
611
+ const selectList =
612
+ referenceSortExpr !== undefined && sortSql !== undefined
613
+ ? `*, ${sortSql} AS "${SORT_LABEL_ALIAS}"`
614
+ : "*";
615
+ const listSql = `SELECT ${selectList} FROM "${tableName}"${whereClauseSqlText}${orderByClause} LIMIT ${limit}${offsetClause}`;
513
616
 
514
617
  const rawRows = await executeRawQueryRead<Record<string, unknown>>(db.raw, listSql, params);
618
+ const lastSortLabel = rawRows[rawRows.length - 1]?.[SORT_LABEL_ALIAS];
619
+ if (referenceSortExpr !== undefined) {
620
+ // The alias is a sort artifact, not a column of this entity — strip it
621
+ // before coercion so it never reaches the caller or the entity cache.
622
+ for (const row of rawRows) delete row[SORT_LABEL_ALIAS];
623
+ }
515
624
  // Per-row read-side rehydrate + snake→camel coercion for driver-agnostic field names.
516
625
  // Coerce BEFORE rehydrate/decrypt: the raw SELECT * rows carry snake_case
517
626
  // column names, while compound-type lookups (rehydrateMoney et al.) and the
@@ -542,7 +651,11 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
542
651
  if (rows.length === limit && lastRow && lastRaw) {
543
652
  const cursorId = lastRow["id"] as string; // @cast-boundary engine-payload
544
653
  const sortText =
545
- sortField === undefined ? undefined : toCursorSortText(lastRaw[physicalCol(sortField)]);
654
+ sortField === undefined
655
+ ? undefined
656
+ : toCursorSortText(
657
+ referenceSortExpr !== undefined ? lastSortLabel : lastRaw[physicalCol(sortField)],
658
+ );
546
659
  nextCursor =
547
660
  sortText === undefined ? encodeCursor(cursorId) : encodeKeysetCursor(sortText, cursorId);
548
661
  }
@@ -559,7 +672,11 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
559
672
  total = filterIds.length;
560
673
  } else {
561
674
  const countSql = `SELECT COUNT(*)::int AS count FROM "${tableName}"${whereClauseSqlText}`;
562
- const countRows = await executeRawQueryRead<{ count: number }>(db.raw, countSql, params);
675
+ const countRows = await executeRawQueryRead<{ count: number }>(
676
+ db.raw,
677
+ countSql,
678
+ whereParams,
679
+ );
563
680
  total = countRows[0]?.count ?? 0;
564
681
  }
565
682
  }
@@ -572,6 +572,16 @@ export function buildEntityTable<E extends EntityDefinition>(
572
572
  // Pass through to the IndexBuilderWithCols.where()-API.
573
573
  if (def.where !== undefined) {
574
574
  chain = chain.where(def.where as SqlExpression);
575
+ } else if (def.unique === true && entity.softDelete === true) {
576
+ // Soft-deleted rows keep their value, so a full unique index would
577
+ // permanently block reuse; kept in lock-step with entity-table-meta.ts (framework#2593).
578
+ const softDeleteWhere: SqlExpression = {
579
+ kind: "sql-expr",
580
+ text: `"is_deleted" = false`,
581
+ params: [],
582
+ [SQL_EXPR_BRAND]: true,
583
+ };
584
+ chain = chain.where(softDeleteWhere);
575
585
  }
576
586
  indexes[indexName] = chain;
577
587
  // Partielles bidx-Pendant für unique-Indices über lookupable-Spalten
@@ -150,7 +150,9 @@ describe("defineFeature", () => {
150
150
  test("r.defineEvent returns typed EventDef and registers on feature", () => {
151
151
  let eventRef: { name: string } | undefined;
152
152
  const feature = defineFeature("orders", (r) => {
153
- eventRef = r.defineEvent("order:created", z.object({ orderId: z.number() }));
153
+ eventRef = r.defineEvent("order:created", z.object({ orderId: z.number() }), {
154
+ piiFields: "none",
155
+ });
154
156
  });
155
157
 
156
158
  // E.3: defineEvent returns the fully-qualified name so callers can
@@ -185,7 +187,7 @@ describe("defineFeature", () => {
185
187
 
186
188
  test("registry prefixes event names with feature name", () => {
187
189
  const feature = defineFeature("orders", (r) => {
188
- r.defineEvent("order:created", z.object({ orderId: z.number() }));
190
+ r.defineEvent("order:created", z.object({ orderId: z.number() }), { piiFields: "none" });
189
191
  });
190
192
  const registry = createRegistry([feature]);
191
193
  expect(registry.getEvent("orders:event:order:created")).toBeDefined();
@@ -698,6 +700,134 @@ describe("createRegistry", () => {
698
700
 
699
701
  expect(() => createRegistry([feature])).toThrow(/multiple/);
700
702
  });
703
+
704
+ test("returns sortable reference fields for entity, without leaking them into getSortableFields (fw#2741)", () => {
705
+ const feature = defineFeature("crm", (r) => {
706
+ r.entity(
707
+ "customer",
708
+ createEntity({ table: "Customers", fields: { name: createTextField() } }),
709
+ );
710
+ r.entity(
711
+ "order",
712
+ createEntity({
713
+ table: "Orders",
714
+ fields: {
715
+ note: createTextField({ sortable: true }),
716
+ customerId: {
717
+ type: "reference",
718
+ entity: "customer",
719
+ labelField: "name",
720
+ sortable: true,
721
+ },
722
+ },
723
+ }),
724
+ );
725
+ });
726
+
727
+ const registry = createRegistry([feature]);
728
+ // getSortableFields lists plain columns the read path can ORDER BY
729
+ // directly — a reference sorts through its target's label instead.
730
+ expect(registry.getSortableFields("order")).toEqual(["note"]);
731
+ expect(registry.getSortableReferences("order")).toEqual([
732
+ { fieldName: "customerId", targetEntityName: "customer", labelField: "name" },
733
+ ]);
734
+ expect(registry.getSortableReferences("nonexistent")).toEqual([]);
735
+ });
736
+
737
+ test("resolves a cross-feature sortable reference to its bare entity name (fw#2741)", () => {
738
+ const users = defineFeature("users", (r) => {
739
+ r.entity("user", createEntity({ table: "Users", fields: { email: createTextField() } }));
740
+ });
741
+ const crm = defineFeature("crm", (r) => {
742
+ r.entity(
743
+ "ticket",
744
+ createEntity({
745
+ table: "Tickets",
746
+ fields: {
747
+ ownerId: {
748
+ type: "reference",
749
+ entity: "users:user",
750
+ labelField: "email",
751
+ sortable: true,
752
+ },
753
+ },
754
+ }),
755
+ );
756
+ });
757
+
758
+ const registry = createRegistry([users, crm]);
759
+ expect(registry.getSortableReferences("ticket")).toEqual([
760
+ { fieldName: "ownerId", targetEntityName: "user", labelField: "email" },
761
+ ]);
762
+ });
763
+
764
+ test("throws at boot when a sortable reference field has no explicit labelField (fw#2741)", () => {
765
+ const feature = defineFeature("crm", (r) => {
766
+ r.entity(
767
+ "customer",
768
+ createEntity({ table: "Customers", fields: { name: createTextField() } }),
769
+ );
770
+ r.entity(
771
+ "order",
772
+ createEntity({
773
+ table: "Orders",
774
+ fields: {
775
+ customerId: { type: "reference", entity: "customer", sortable: true },
776
+ },
777
+ }),
778
+ );
779
+ });
780
+
781
+ expect(() => createRegistry([feature])).toThrow(/labelField/);
782
+ });
783
+
784
+ test("throws at boot when a sortable reference field uses labelField: 'id' (fw#2741)", () => {
785
+ const feature = defineFeature("crm", (r) => {
786
+ r.entity(
787
+ "customer",
788
+ createEntity({ table: "Customers", fields: { name: createTextField() } }),
789
+ );
790
+ r.entity(
791
+ "order",
792
+ createEntity({
793
+ table: "Orders",
794
+ fields: {
795
+ customerId: {
796
+ type: "reference",
797
+ entity: "customer",
798
+ labelField: "id",
799
+ sortable: true,
800
+ },
801
+ },
802
+ }),
803
+ );
804
+ });
805
+
806
+ expect(() => createRegistry([feature])).toThrow(/labelField/);
807
+ });
808
+
809
+ test("throws at boot when sortable is combined with multiple on a reference field (fw#2741)", () => {
810
+ const feature = defineFeature("crm", (r) => {
811
+ r.entity("tag", createEntity({ table: "Tags", fields: { name: createTextField() } }));
812
+ r.entity(
813
+ "post",
814
+ createEntity({
815
+ table: "Posts",
816
+ fields: {
817
+ tagIds: {
818
+ type: "reference",
819
+ entity: "tag",
820
+ labelField: "name",
821
+ sortable: true,
822
+ multiple: true,
823
+ },
824
+ },
825
+ }),
826
+ );
827
+ });
828
+
829
+ expect(() => createRegistry([feature])).toThrow(/multiple/);
830
+ });
701
831
  });
702
832
 
703
833
  // --- Access ---
@@ -9,6 +9,7 @@ const upcastCtx = {} as EventUpcastCtx;
9
9
  function compile(spec: DeclarativeEventMigration) {
10
10
  const feature = defineFeature("billing", (r) => {
11
11
  r.defineEvent("invoicePaid", z.unknown(), {
12
+ piiFields: "none",
12
13
  version: 2,
13
14
  migrations: [{ fromVersion: 1, toVersion: 2, transform: spec }],
14
15
  });
@@ -56,6 +57,7 @@ describe("declarative eventMigration", () => {
56
57
  const fn = (payload: unknown) => payload;
57
58
  const feature = defineFeature("billing", (r) => {
58
59
  r.defineEvent("invoicePaid", z.unknown(), {
60
+ piiFields: "none",
59
61
  version: 2,
60
62
  migrations: [{ fromVersion: 1, toVersion: 2, transform: fn }],
61
63
  });
@@ -21,7 +21,9 @@ describe("r.extendEntityProjection — registration", () => {
21
21
  test("merges apply keys + extraSources into the implicit projection", () => {
22
22
  const feature = defineFeature("test", (r) => {
23
23
  r.entity("unit", exampleEntity());
24
- const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() }));
24
+ const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() }), {
25
+ piiFields: "none",
26
+ });
25
27
  r.extendEntityProjection("unit", {
26
28
  sources: ["field-definition"],
27
29
  apply: { [setEvent.name]: noopApply },
@@ -39,7 +41,9 @@ describe("r.extendEntityProjection — registration", () => {
39
41
  test("extension source equal to the entity name is not duplicated into extraSources", () => {
40
42
  const feature = defineFeature("test", (r) => {
41
43
  r.entity("unit", exampleEntity());
42
- const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() }));
44
+ const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() }), {
45
+ piiFields: "none",
46
+ });
43
47
  r.extendEntityProjection("unit", {
44
48
  sources: ["unit"],
45
49
  apply: { [setEvent.name]: noopApply },
@@ -66,7 +70,9 @@ describe("r.extendEntityProjection — registration", () => {
66
70
 
67
71
  test("registration order is free: extension before r.entity in the same feature", () => {
68
72
  const feature = defineFeature("test", (r) => {
69
- const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() }));
73
+ const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() }), {
74
+ piiFields: "none",
75
+ });
70
76
  r.extendEntityProjection("unit", { apply: { [setEvent.name]: noopApply } });
71
77
  r.entity("unit", exampleEntity());
72
78
  });
@@ -89,7 +95,9 @@ describe("r.extendEntityProjection — registry-build validation", () => {
89
95
  test("unknown entity fails at registry build", () => {
90
96
  const feature = defineFeature("test", (r) => {
91
97
  r.entity("unit", exampleEntity());
92
- const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() }));
98
+ const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() }), {
99
+ piiFields: "none",
100
+ });
93
101
  r.extendEntityProjection("typo-entity", { apply: { [setEvent.name]: noopApply } });
94
102
  });
95
103
  expect(() => createRegistry([feature])).toThrow(/no r\.entity/);
@@ -106,7 +114,9 @@ describe("r.extendEntityProjection — registry-build validation", () => {
106
114
  test("apply-key collision between two extensions fails at registry build", () => {
107
115
  const feature = defineFeature("test", (r) => {
108
116
  r.entity("unit", exampleEntity());
109
- const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() }));
117
+ const setEvent = r.defineEvent("custom-field-set", z.object({ fieldKey: z.string() }), {
118
+ piiFields: "none",
119
+ });
110
120
  r.extendEntityProjection("unit", { apply: { [setEvent.name]: noopApply } });
111
121
  r.extendEntityProjection("unit", { apply: { [setEvent.name]: noopApply } });
112
122
  });
@@ -454,7 +454,7 @@ const bulkLogHandler = defineWriteHandler({
454
454
  const demoPipelineFeature = defineFeature("demoPipeline", (r) => {
455
455
  r.requires.projection("pipeline_demo_log");
456
456
  r.entity("widget", widgetEntity);
457
- r.defineEvent("annotated", z.object({ note: z.string() }));
457
+ r.defineEvent("annotated", z.object({ note: z.string() }), { piiFields: "none" });
458
458
  r.writeHandler(echoHandler);
459
459
  r.writeHandler(explodeHandler);
460
460
  r.writeHandler(compoundHandler);
@@ -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.