@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
@@ -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);
@@ -362,6 +362,190 @@ describe("r.screen() — registration", () => {
362
362
  expect(() => validateBoot(features)).not.toThrow();
363
363
  });
364
364
 
365
+ describe("relatedList search + facets (fw#2740)", () => {
366
+ test("rejects searchable: true when the bound query's schema has no search parameter", () => {
367
+ const features = [
368
+ defineFeature("app", (r) => {
369
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
370
+ access: { openToAll: true },
371
+ });
372
+ r.queryHandler("foo:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
373
+ access: { openToAll: true },
374
+ });
375
+ r.screen({
376
+ id: "x",
377
+ type: "projectionDetail",
378
+ query: "app:query:foo:detail",
379
+ layout: {
380
+ sections: [
381
+ {
382
+ kind: "relatedList",
383
+ title: "s",
384
+ query: "app:query:foo:list",
385
+ columns: ["name"],
386
+ searchable: true,
387
+ },
388
+ ],
389
+ },
390
+ });
391
+ }),
392
+ ];
393
+ expect(() => validateBoot(features)).toThrow(/searchable: true.*"search"/);
394
+ });
395
+
396
+ test("accepts searchable: true when the bound query's schema accepts search", () => {
397
+ const features = [
398
+ defineFeature("app", (r) => {
399
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
400
+ access: { openToAll: true },
401
+ });
402
+ r.queryHandler(
403
+ "foo:list",
404
+ z.object({ search: z.string().optional() }),
405
+ async () => ({ rows: [], nextCursor: null }),
406
+ { access: { openToAll: true } },
407
+ );
408
+ r.screen({
409
+ id: "x",
410
+ type: "projectionDetail",
411
+ query: "app:query:foo:detail",
412
+ layout: {
413
+ sections: [
414
+ {
415
+ kind: "relatedList",
416
+ title: "s",
417
+ query: "app:query:foo:list",
418
+ columns: ["name"],
419
+ searchable: true,
420
+ },
421
+ ],
422
+ },
423
+ });
424
+ }),
425
+ ];
426
+ expect(() => validateBoot(features)).not.toThrow();
427
+ });
428
+
429
+ test("rejects facets when the bound query's schema has no filters parameter", () => {
430
+ const features = [
431
+ defineFeature("app", (r) => {
432
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
433
+ access: { openToAll: true },
434
+ });
435
+ r.queryHandler("foo:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
436
+ access: { openToAll: true },
437
+ });
438
+ r.screen({
439
+ id: "x",
440
+ type: "projectionDetail",
441
+ query: "app:query:foo:detail",
442
+ layout: {
443
+ sections: [
444
+ {
445
+ kind: "relatedList",
446
+ title: "s",
447
+ query: "app:query:foo:list",
448
+ columns: ["name", "status"],
449
+ facets: [
450
+ {
451
+ field: "status",
452
+ type: "select",
453
+ label: "Status",
454
+ options: [{ value: "active", label: "Active" }],
455
+ },
456
+ ],
457
+ },
458
+ ],
459
+ },
460
+ });
461
+ }),
462
+ ];
463
+ expect(() => validateBoot(features)).toThrow(/declares facets but query.*"filters"/);
464
+ });
465
+
466
+ test("rejects a facet whose field is not a declared column", () => {
467
+ const features = [
468
+ defineFeature("app", (r) => {
469
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
470
+ access: { openToAll: true },
471
+ });
472
+ r.queryHandler(
473
+ "foo:list",
474
+ z.object({ filters: z.array(z.object({})).optional() }),
475
+ async () => ({ rows: [], nextCursor: null }),
476
+ { access: { openToAll: true } },
477
+ );
478
+ r.screen({
479
+ id: "x",
480
+ type: "projectionDetail",
481
+ query: "app:query:foo:detail",
482
+ layout: {
483
+ sections: [
484
+ {
485
+ kind: "relatedList",
486
+ title: "s",
487
+ query: "app:query:foo:list",
488
+ columns: ["name"],
489
+ facets: [
490
+ {
491
+ field: "status",
492
+ type: "select",
493
+ label: "Status",
494
+ options: [{ value: "active", label: "Active" }],
495
+ },
496
+ ],
497
+ },
498
+ ],
499
+ },
500
+ });
501
+ }),
502
+ ];
503
+ expect(() => validateBoot(features)).toThrow(
504
+ /relatedList section "s".*facet references field "status" which is not a declared column/,
505
+ );
506
+ });
507
+
508
+ test("accepts valid facets when the bound query's schema accepts filters", () => {
509
+ const features = [
510
+ defineFeature("app", (r) => {
511
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
512
+ access: { openToAll: true },
513
+ });
514
+ r.queryHandler(
515
+ "foo:list",
516
+ z.object({ filters: z.array(z.object({})).optional() }),
517
+ async () => ({ rows: [], nextCursor: null }),
518
+ { access: { openToAll: true } },
519
+ );
520
+ r.screen({
521
+ id: "x",
522
+ type: "projectionDetail",
523
+ query: "app:query:foo:detail",
524
+ layout: {
525
+ sections: [
526
+ {
527
+ kind: "relatedList",
528
+ title: "s",
529
+ query: "app:query:foo:list",
530
+ columns: ["name", "status"],
531
+ facets: [
532
+ {
533
+ field: "status",
534
+ type: "select",
535
+ label: "Status",
536
+ options: [{ value: "active", label: "Active" }],
537
+ },
538
+ ],
539
+ },
540
+ ],
541
+ },
542
+ });
543
+ }),
544
+ ];
545
+ expect(() => validateBoot(features)).not.toThrow();
546
+ });
547
+ });
548
+
365
549
  test("validateBoot rejects a relatedList section on entityEdit (fw#2166)", () => {
366
550
  const features = [
367
551
  defineFeature("shop", (r) => {
@@ -46,7 +46,10 @@ import {
46
46
  } from "./nav";
47
47
  import { collectClaimKeys, validateOwnershipRules } from "./ownership";
48
48
  import { validatePiiAndRetention } from "./pii-retention";
49
- import { validateProjectionListScreens } from "./projection-list-screens";
49
+ import {
50
+ validateProjectionListScreens,
51
+ validateRelatedListSectionQueries,
52
+ } from "./projection-list-screens";
50
53
  import { validateQueryOutputColumns } from "./query-output-columns";
51
54
  import { validateQueryRefs } from "./query-refs";
52
55
  import {
@@ -224,6 +227,7 @@ export function validateBoot(
224
227
  // assume every `query` string already resolves to a registered handler.
225
228
  validateQueryOutputColumns(features);
226
229
  validateProjectionListScreens(features);
230
+ validateRelatedListSectionQueries(features);
227
231
  validateExtensionPreSaveWiring(features);
228
232
  validateGdprStoragePersistence(features);
229
233
  validateFeatureBootChecks(features);