@cosmicdrift/kumiko-framework 0.248.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 (56) 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-helpers.test.ts +52 -0
  19. package/src/engine/boot-validator/action-wiring.ts +6 -5
  20. package/src/engine/entity-handlers.ts +4 -0
  21. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +1 -0
  22. package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -1
  23. package/src/engine/feature-ast/__tests__/parse.test.ts +27 -1
  24. package/src/engine/feature-ast/__tests__/patch.test.ts +1 -0
  25. package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -0
  26. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +56 -3
  27. package/src/engine/feature-ast/extractors/events.ts +24 -0
  28. package/src/engine/feature-ast/patcher.ts +6 -1
  29. package/src/engine/feature-ast/patterns.ts +6 -0
  30. package/src/engine/feature-ast/render.ts +3 -4
  31. package/src/engine/feature-config-events-jobs.ts +33 -9
  32. package/src/engine/pattern-library/__tests__/library.test.ts +1 -0
  33. package/src/engine/registry-facade.ts +10 -1
  34. package/src/engine/registry-state.ts +8 -0
  35. package/src/engine/registry-validate.ts +54 -3
  36. package/src/engine/screen-helpers.ts +2 -0
  37. package/src/engine/types/index.ts +1 -0
  38. package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +4 -1
  39. package/src/entrypoint/__tests__/split-deploy.integration.test.ts +10 -3
  40. package/src/event-store/__tests__/upcaster.integration.test.ts +8 -1
  41. package/src/jobs/__tests__/job-write-as.integration.test.ts +206 -0
  42. package/src/jobs/job-runner.ts +12 -4
  43. package/src/pipeline/__tests__/archive-stream.integration.test.ts +3 -1
  44. package/src/pipeline/__tests__/causation-chain.integration.test.ts +3 -1
  45. package/src/pipeline/__tests__/domain-events-projections.integration.test.ts +3 -1
  46. package/src/pipeline/__tests__/event-define-event-strict.integration.test.ts +6 -2
  47. package/src/pipeline/__tests__/fetch-for-writing.integration.test.ts +6 -2
  48. package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +3 -1
  49. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -0
  50. package/src/pipeline/__tests__/msp-multi-hop.integration.test.ts +5 -3
  51. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +3 -0
  52. package/src/pipeline/__tests__/multi-stream-projection.integration.test.ts +2 -0
  53. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +3 -1
  54. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +260 -0
  55. package/src/pipeline/__tests__/try-append-event.integration.test.ts +3 -1
  56. 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);
@@ -0,0 +1,52 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { evalFieldCondition } from "../screen-helpers";
3
+ import type { FieldCondition } from "../types/screen";
4
+
5
+ describe("evalFieldCondition()", () => {
6
+ test("boolean forms pass through unchanged", () => {
7
+ expect(evalFieldCondition(true, {})).toBe(true);
8
+ expect(evalFieldCondition(false, {})).toBe(false);
9
+ });
10
+
11
+ test("eq matches when the field equals the given value", () => {
12
+ const cond: FieldCondition = { field: "status", eq: "active" };
13
+ expect(evalFieldCondition(cond, { status: "active" })).toBe(true);
14
+ expect(evalFieldCondition(cond, { status: "archived" })).toBe(false);
15
+ });
16
+
17
+ test("ne matches when the field differs from the given value", () => {
18
+ const cond: FieldCondition = { field: "status", ne: "archived" };
19
+ expect(evalFieldCondition(cond, { status: "active" })).toBe(true);
20
+ expect(evalFieldCondition(cond, { status: "archived" })).toBe(false);
21
+ });
22
+
23
+ test("in matches when the field is one of the given values", () => {
24
+ const cond: FieldCondition = { field: "status", in: ["draft", "active"] };
25
+ expect(evalFieldCondition(cond, { status: "active" })).toBe(true);
26
+ expect(evalFieldCondition(cond, { status: "archived" })).toBe(false);
27
+ });
28
+
29
+ test("notIn matches when the field is none of the given values", () => {
30
+ const cond: FieldCondition = { field: "status", notIn: ["draft", "archived"] };
31
+ expect(evalFieldCondition(cond, { status: "active" })).toBe(true);
32
+ expect(evalFieldCondition(cond, { status: "draft" })).toBe(false);
33
+ });
34
+
35
+ test("empty in is always false", () => {
36
+ const cond: FieldCondition = { field: "status", in: [] };
37
+ expect(evalFieldCondition(cond, { status: "active" })).toBe(false);
38
+ expect(evalFieldCondition(cond, {})).toBe(false);
39
+ });
40
+
41
+ test("empty notIn is always true", () => {
42
+ const cond: FieldCondition = { field: "status", notIn: [] };
43
+ expect(evalFieldCondition(cond, { status: "active" })).toBe(true);
44
+ expect(evalFieldCondition(cond, {})).toBe(true);
45
+ });
46
+
47
+ test("a missing field is undefined — in false, notIn true", () => {
48
+ const values: Record<string, unknown> = {};
49
+ expect(evalFieldCondition({ field: "status", in: ["active"] }, values)).toBe(false);
50
+ expect(evalFieldCondition({ field: "status", notIn: ["active"] }, values)).toBe(true);
51
+ });
52
+ });
@@ -21,9 +21,10 @@ function throwIfFunction(value: unknown, message: string): void {
21
21
  // rowActions/toolbarActions carry row-context extractors (payload/params),
22
22
  // static visibility conditions and navigate entityId — all declarative-DSL-
23
23
  // only fields (RowFieldExtractor's `{ pick }`/`{ map }`, FieldCondition's
24
- // `{ field, eq }`/`{ field, ne }`, plain strings). A function literal here
25
- // does not type-check against these structural DSL types — it only reaches
26
- // here via a leaked `any`/`as any`, which this runtime check backstops.
24
+ // `{ field, eq }`/`{ field, ne }`/`{ field, in }`/`{ field, notIn }`, plain
25
+ // strings). A function literal here does not type-check against these
26
+ // structural DSL types — it only reaches here via a leaked `any`/`as any`,
27
+ // which this runtime check backstops.
27
28
  const ACTION_FUNCTION_FIELDS = ["payload", "params", "entityId", "visible"] as const;
28
29
 
29
30
  function validateActionNoFunctions(
@@ -38,7 +39,7 @@ function validateActionNoFunctions(
38
39
  record[field],
39
40
  `[Feature ${featureName}] Screen "${screenId}" ${actionKind} "${action.id}" ${field} ` +
40
41
  `is a function — ${FUNCTION_DROPPED_HINT} Use the declarative DSL ({ pick }, { map }, ` +
41
- `"fieldName", { field, eq }) instead.`,
42
+ `"fieldName", { field, eq }/{ field, in }) instead.`,
42
43
  );
43
44
  }
44
45
  }
@@ -78,7 +79,7 @@ function validateEditFieldNoFunctions(
78
79
  record[key],
79
80
  `[Feature ${featureName}] Screen "${screenId}" (${screenType}) field "${normalized.field}" ` +
80
81
  `${key} is a function — ${FUNCTION_DROPPED_HINT} Use a FieldCondition ` +
81
- `(boolean or { field, eq }/{ field, ne }) instead.`,
82
+ `(boolean or { field, eq }/{ field, ne }/{ field, in }/{ field, notIn }) instead.`,
82
83
  );
83
84
  }
84
85
  throwIfFunction(
@@ -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
  });