@cosmicdrift/kumiko-framework 0.238.0 → 0.240.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.
@@ -435,6 +435,461 @@ describe("r.screen() — registration", () => {
435
435
  );
436
436
  });
437
437
 
438
+ test("validateBoot rejects a relatedList rowAction with an unregistered write-handler (fw editable-detail-screens)", () => {
439
+ const features = [
440
+ defineFeature("app", (r) => {
441
+ r.screen({
442
+ id: "x",
443
+ type: "projectionDetail",
444
+ query: "app:query:foo:detail",
445
+ layout: {
446
+ sections: [
447
+ {
448
+ kind: "relatedList",
449
+ title: "s",
450
+ query: "app:query:foo:list",
451
+ columns: ["name"],
452
+ rowActions: [{ id: "sync", label: "actions.sync", handler: "app:write:missing" }],
453
+ },
454
+ ],
455
+ },
456
+ });
457
+ }),
458
+ ];
459
+ expect(() => validateBoot(features)).toThrow(
460
+ /relatedList\).*rowAction "sync" handler "app:write:missing".*not a registered write-handler/,
461
+ );
462
+ });
463
+
464
+ test("validateBoot accepts a relatedList rowAction bound to a registered write-handler (fw editable-detail-screens)", () => {
465
+ const features = [
466
+ defineFeature("app", (r) => {
467
+ r.writeHandler(
468
+ "sync",
469
+ z.object({}),
470
+ async () => ({ isSuccess: true as const, data: null }),
471
+ {
472
+ access: { roles: ["Admin"] },
473
+ },
474
+ );
475
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
476
+ access: { openToAll: true },
477
+ });
478
+ r.queryHandler("foo:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
479
+ access: { openToAll: true },
480
+ });
481
+ r.screen({
482
+ id: "x",
483
+ type: "projectionDetail",
484
+ query: "app:query:foo:detail",
485
+ layout: {
486
+ sections: [
487
+ {
488
+ kind: "relatedList",
489
+ title: "s",
490
+ query: "app:query:foo:list",
491
+ columns: ["name"],
492
+ rowActions: [
493
+ {
494
+ id: "sync",
495
+ label: "actions.sync",
496
+ handler: "app:write:sync",
497
+ payload: { pick: ["name"] },
498
+ },
499
+ ],
500
+ },
501
+ ],
502
+ },
503
+ });
504
+ }),
505
+ ];
506
+ expect(() => validateBoot(features)).not.toThrow();
507
+ });
508
+
509
+ test("validateBoot rejects a relatedList section with both rowClick and a rowActions rowClick:true entry (fw editable-detail-screens)", () => {
510
+ const features = [
511
+ defineFeature("billing", (r) => {
512
+ r.entity(
513
+ "invoice",
514
+ createEntity({ table: "invoices", fields: { name: createTextField() } }),
515
+ );
516
+ r.screen({
517
+ id: "invoice-detail",
518
+ type: "custom",
519
+ renderer: { react: { __component: "stub" } },
520
+ detailFor: "invoice",
521
+ });
522
+ }),
523
+ defineFeature("app", (r) => {
524
+ r.screen({
525
+ id: "x",
526
+ type: "projectionDetail",
527
+ query: "app:query:foo:detail",
528
+ layout: {
529
+ sections: [
530
+ {
531
+ kind: "relatedList",
532
+ title: "s",
533
+ query: "app:query:foo:list",
534
+ columns: ["name"],
535
+ rowClick: { entity: "invoice" },
536
+ rowActions: [
537
+ {
538
+ kind: "navigate",
539
+ id: "open",
540
+ label: "actions.open",
541
+ entity: "invoice",
542
+ entityId: "id",
543
+ rowClick: true,
544
+ },
545
+ ],
546
+ },
547
+ ],
548
+ },
549
+ });
550
+ }),
551
+ ];
552
+ expect(() => validateBoot(features)).toThrow(
553
+ /relatedList\).*has both a rowClick and 1 rowActions marked rowClick:true/,
554
+ );
555
+ });
556
+
557
+ test("validateBoot rejects a projectionDetail writeForm section with an empty fieldDefs map (fw editable-detail-screens)", () => {
558
+ const features = [
559
+ defineFeature("app", (r) => {
560
+ r.screen({
561
+ id: "x",
562
+ type: "projectionDetail",
563
+ query: "app:query:foo:detail",
564
+ layout: {
565
+ sections: [
566
+ {
567
+ kind: "writeForm",
568
+ title: "s",
569
+ fieldDefs: {},
570
+ fields: ["name"],
571
+ handler: "app:write:save",
572
+ },
573
+ ],
574
+ },
575
+ });
576
+ }),
577
+ ];
578
+ expect(() => validateBoot(features)).toThrow(/\(writeForm\) has an empty fieldDefs map/);
579
+ });
580
+
581
+ test("validateBoot rejects a projectionDetail writeForm section with zero fields (fw editable-detail-screens)", () => {
582
+ const features = [
583
+ defineFeature("app", (r) => {
584
+ r.screen({
585
+ id: "x",
586
+ type: "projectionDetail",
587
+ query: "app:query:foo:detail",
588
+ layout: {
589
+ sections: [
590
+ {
591
+ kind: "writeForm",
592
+ title: "s",
593
+ fieldDefs: { name: createTextField() },
594
+ fields: [],
595
+ handler: "app:write:save",
596
+ },
597
+ ],
598
+ },
599
+ });
600
+ }),
601
+ ];
602
+ expect(() => validateBoot(features)).toThrow(/\(writeForm\) has zero fields/);
603
+ });
604
+
605
+ test("validateBoot rejects a projectionDetail writeForm field with no entry in fieldDefs (fw editable-detail-screens)", () => {
606
+ const features = [
607
+ defineFeature("app", (r) => {
608
+ r.screen({
609
+ id: "x",
610
+ type: "projectionDetail",
611
+ query: "app:query:foo:detail",
612
+ layout: {
613
+ sections: [
614
+ {
615
+ kind: "writeForm",
616
+ title: "s",
617
+ fieldDefs: { name: createTextField() },
618
+ fields: ["sku"],
619
+ handler: "app:write:save",
620
+ },
621
+ ],
622
+ },
623
+ });
624
+ }),
625
+ ];
626
+ expect(() => validateBoot(features)).toThrow(
627
+ /\(writeForm\) field "sku" has no entry in fieldDefs/,
628
+ );
629
+ });
630
+
631
+ test("validateBoot rejects a projectionDetail writeForm section in a wizard layout (fw editable-detail-screens)", () => {
632
+ const features = [
633
+ defineFeature("app", (r) => {
634
+ r.screen({
635
+ id: "x",
636
+ type: "projectionDetail",
637
+ query: "app:query:foo:detail",
638
+ layout: {
639
+ mode: "wizard",
640
+ sections: [
641
+ {
642
+ kind: "writeForm",
643
+ title: "s",
644
+ fieldDefs: { name: createTextField() },
645
+ fields: ["name"],
646
+ handler: "app:write:save",
647
+ },
648
+ ],
649
+ },
650
+ });
651
+ }),
652
+ ];
653
+ expect(() => validateBoot(features)).toThrow(/writeForm.*wizard layout/);
654
+ });
655
+
656
+ test("validateBoot rejects a projectionDetail writeForm section with an unregistered write-handler (fw editable-detail-screens)", () => {
657
+ const features = [
658
+ defineFeature("app", (r) => {
659
+ r.screen({
660
+ id: "x",
661
+ type: "projectionDetail",
662
+ query: "app:query:foo:detail",
663
+ layout: {
664
+ sections: [
665
+ {
666
+ kind: "writeForm",
667
+ title: "s",
668
+ fieldDefs: { name: createTextField() },
669
+ fields: ["name"],
670
+ handler: "app:write:missing",
671
+ },
672
+ ],
673
+ },
674
+ });
675
+ }),
676
+ ];
677
+ expect(() => validateBoot(features)).toThrow(
678
+ /\(writeForm\) handler "app:write:missing".*not a registered write-handler/,
679
+ );
680
+ });
681
+
682
+ test("validateBoot accepts a valid projectionDetail writeForm section (fw editable-detail-screens)", () => {
683
+ const features = [
684
+ defineFeature("app", (r) => {
685
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
686
+ access: { openToAll: true },
687
+ });
688
+ r.writeHandler(
689
+ "save",
690
+ z.object({}),
691
+ async () => ({ isSuccess: true as const, data: null }),
692
+ {
693
+ access: { roles: ["Admin"] },
694
+ },
695
+ );
696
+ r.screen({
697
+ id: "x",
698
+ type: "projectionDetail",
699
+ query: "app:query:foo:detail",
700
+ layout: {
701
+ sections: [
702
+ {
703
+ kind: "writeForm",
704
+ title: "s",
705
+ fieldDefs: { name: createTextField() },
706
+ fields: ["name"],
707
+ handler: "app:write:save",
708
+ submitLabel: "actions.save",
709
+ },
710
+ ],
711
+ },
712
+ });
713
+ }),
714
+ ];
715
+ expect(() => validateBoot(features)).not.toThrow();
716
+ });
717
+
718
+ // withBootValidatorFixture auto-fills every key requiredKeysFromFeature
719
+ // reports, so every other boot test in this file passes regardless of
720
+ // whether required-surface-keys.ts's writeForm branch names the right key
721
+ // — it would pass even for a wrong namespace. Bypass the fixture (raw
722
+ // validateBootRaw, no stubTranslations) to pin the actual key contract.
723
+ test("validateBoot requires a translation for a writeForm field's own label, namespaced under the write-form pseudo-entity (fw editable-detail-screens)", () => {
724
+ const features = [
725
+ defineFeature("app", (r) => {
726
+ // Screens always require their own "screen:<id>.title" key — supplied
727
+ // here so the throw below isolates the one key this test is about.
728
+ r.translations({ keys: { "screen:x.title": { en: "X" } } });
729
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
730
+ access: { openToAll: true },
731
+ });
732
+ r.writeHandler(
733
+ "save",
734
+ z.object({}),
735
+ async () => ({ isSuccess: true as const, data: null }),
736
+ { access: { roles: ["Admin"] } },
737
+ );
738
+ r.screen({
739
+ id: "x",
740
+ type: "projectionDetail",
741
+ query: "app:query:foo:detail",
742
+ layout: {
743
+ sections: [
744
+ {
745
+ kind: "writeForm",
746
+ title: "s",
747
+ fieldDefs: { name: createTextField() },
748
+ fields: ["name"],
749
+ handler: "app:write:save",
750
+ },
751
+ ],
752
+ },
753
+ });
754
+ }),
755
+ ];
756
+
757
+ expect(() => validateBootRaw(features)).toThrow(
758
+ '[i18n] Feature "app": required translation key missing: "app:entity:__write-form-section__:field:name"',
759
+ );
760
+ });
761
+
762
+ test("translating the writeForm field's pseudo-entity key lets boot pass", () => {
763
+ const features = [
764
+ defineFeature("app", (r) => {
765
+ r.translations({
766
+ keys: {
767
+ "screen:x.title": { en: "X" },
768
+ "app:entity:__write-form-section__:field:name": { en: "Name" },
769
+ },
770
+ });
771
+ r.queryHandler("foo:detail", z.object({}), async () => ({}), {
772
+ access: { openToAll: true },
773
+ });
774
+ r.writeHandler(
775
+ "save",
776
+ z.object({}),
777
+ async () => ({ isSuccess: true as const, data: null }),
778
+ { access: { roles: ["Admin"] } },
779
+ );
780
+ r.screen({
781
+ id: "x",
782
+ type: "projectionDetail",
783
+ query: "app:query:foo:detail",
784
+ layout: {
785
+ sections: [
786
+ {
787
+ kind: "writeForm",
788
+ title: "s",
789
+ fieldDefs: { name: createTextField() },
790
+ fields: ["name"],
791
+ handler: "app:write:save",
792
+ },
793
+ ],
794
+ },
795
+ });
796
+ }),
797
+ ];
798
+
799
+ expect(() => validateBootRaw(features)).not.toThrow();
800
+ });
801
+
802
+ test("validateBoot rejects a writeForm section on entityEdit (fw editable-detail-screens)", () => {
803
+ const features = [
804
+ defineFeature("shop", (r) => {
805
+ r.entity("product", productEntity());
806
+ r.screen({
807
+ id: "product-edit",
808
+ type: "entityEdit",
809
+ entity: "product",
810
+ layout: {
811
+ sections: [
812
+ {
813
+ kind: "writeForm",
814
+ title: "s",
815
+ fieldDefs: { name: createTextField() },
816
+ fields: ["name"],
817
+ handler: "shop:write:save",
818
+ },
819
+ ],
820
+ },
821
+ });
822
+ }),
823
+ ];
824
+ expect(() => validateBoot(features)).toThrow(
825
+ /\(entityEdit\) writeForm section "s".*projectionDetail-only/,
826
+ );
827
+ });
828
+
829
+ test("validateBoot rejects a writeForm section on configEdit (fw editable-detail-screens)", () => {
830
+ const features = [
831
+ defineFeature("shop", (r) => {
832
+ r.config({
833
+ keys: { "site-name": createTenantConfig("text", { default: "" }) },
834
+ });
835
+ r.screen({
836
+ id: "settings",
837
+ type: "configEdit",
838
+ scope: "tenant",
839
+ configKeys: { siteName: "shop:config:site-name" },
840
+ fields: { siteName: { type: "text" } } as never,
841
+ layout: {
842
+ sections: [
843
+ {
844
+ kind: "writeForm",
845
+ title: "s",
846
+ fieldDefs: { name: createTextField() },
847
+ fields: ["name"],
848
+ handler: "shop:write:save",
849
+ },
850
+ ],
851
+ },
852
+ });
853
+ }),
854
+ ];
855
+ expect(() => validateBoot(features)).toThrow(
856
+ /\(configEdit\) writeForm section "s".*projectionDetail-only/,
857
+ );
858
+ });
859
+
860
+ test("validateBoot rejects a writeForm section on actionForm (fw editable-detail-screens)", () => {
861
+ const features = [
862
+ defineFeature("shop", (r) => {
863
+ r.writeHandler(
864
+ "restock",
865
+ z.object({}),
866
+ async () => ({ isSuccess: true as const, data: null }),
867
+ { access: { roles: ["Admin"] } },
868
+ );
869
+ r.screen({
870
+ id: "restock",
871
+ type: "actionForm",
872
+ handler: "shop:write:restock",
873
+ fields: { qty: { type: "number" } } as never,
874
+ layout: {
875
+ sections: [
876
+ {
877
+ kind: "writeForm",
878
+ title: "s",
879
+ fieldDefs: { name: createTextField() },
880
+ fields: ["name"],
881
+ handler: "shop:write:save",
882
+ },
883
+ ],
884
+ },
885
+ });
886
+ }),
887
+ ];
888
+ expect(() => validateBoot(features)).toThrow(
889
+ /\(actionForm\) writeForm section "s".*projectionDetail-only/,
890
+ );
891
+ });
892
+
438
893
  test("stores an entityEdit screen with sections + conditional fields", () => {
439
894
  const feature = defineFeature("shop", (r) => {
440
895
  r.entity("product", productEntity());
@@ -9,7 +9,12 @@ import { rowMetaFieldNames } from "../../db/table-builder";
9
9
  import { LIST_ROW_META_COLUMNS } from "../../ui-types/list-row-meta";
10
10
  import { isKebabSegment, isValidQn, qualifyEntityName } from "../qualified-name";
11
11
  import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
12
- import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../screen-helpers";
12
+ import {
13
+ isExtensionEditSection,
14
+ isWriteFormEditSection,
15
+ normalizeEditField,
16
+ normalizeListColumn,
17
+ } from "../screen-helpers";
13
18
  import type { EntityDefinition, FeatureDefinition } from "../types";
14
19
  import type {
15
20
  DashboardCustomPanel,
@@ -732,6 +737,93 @@ export function validateScreens(
732
737
  );
733
738
  }
734
739
  }
740
+ if (section.rowActions !== undefined) {
741
+ for (const action of section.rowActions) {
742
+ if (action.kind === "navigate") {
743
+ const target = resolveRowActionNavigateTarget(
744
+ feature.name,
745
+ screenId,
746
+ "projectionDetail",
747
+ "rowAction",
748
+ action,
749
+ allScreenQns,
750
+ navTargetShortIds,
751
+ screensByShortId,
752
+ detailForScreens,
753
+ );
754
+ validateRowActionNavigateParams(
755
+ feature.name,
756
+ screenId,
757
+ "projectionDetail",
758
+ undefined,
759
+ action,
760
+ target,
761
+ );
762
+ } else if (!allWriteHandlerQns.has(action.handler)) {
763
+ throw new Error(
764
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section ` +
765
+ `"${section.title}" (relatedList) rowAction "${action.id}" handler "${action.handler}" ` +
766
+ `is not a registered write-handler. Check the QN spelling (expected ` +
767
+ `"<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`,
768
+ );
769
+ }
770
+ }
771
+ // section.rowClick (legacy, navigate-only) and a rowActions entry
772
+ // marked rowClick:true both claim the row-body click — same
773
+ // at-most-one constraint as entityList/projectionList's
774
+ // validateAtMostOneRowClick, just spanning two fields instead of one.
775
+ const rowClickActionCount = section.rowActions.filter(
776
+ (a) => a.kind === "navigate" && a.rowClick === true,
777
+ ).length;
778
+ const legacyRowClickCount = section.rowClick !== undefined ? 1 : 0;
779
+ if (rowClickActionCount + legacyRowClickCount > 1) {
780
+ throw new Error(
781
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
782
+ `(relatedList) has both a rowClick and ${rowClickActionCount} rowActions marked ` +
783
+ `rowClick:true — at most one may fire on a row-body click.`,
784
+ );
785
+ }
786
+ }
787
+ continue;
788
+ }
789
+ if (isWriteFormEditSection(section)) {
790
+ if (Object.keys(section.fieldDefs).length === 0) {
791
+ throw new Error(
792
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
793
+ `(writeForm) has an empty fieldDefs map — declare at least one field type.`,
794
+ );
795
+ }
796
+ if (section.fields.length === 0) {
797
+ throw new Error(
798
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
799
+ `(writeForm) has zero fields — drop the section or add fields to it.`,
800
+ );
801
+ }
802
+ for (const f of section.fields) {
803
+ const fieldName = normalizeEditField(f).field;
804
+ if (section.fieldDefs[fieldName] === undefined) {
805
+ throw new Error(
806
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
807
+ `(writeForm) field "${fieldName}" has no entry in fieldDefs — every rendered field needs ` +
808
+ `a type declared there.`,
809
+ );
810
+ }
811
+ }
812
+ if (screen.layout.mode === "wizard") {
813
+ throw new Error(
814
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
815
+ `is kind "writeForm" in a wizard layout — a self-persisting form is not supported in a ` +
816
+ `stepped form. Remove mode: "wizard" or drop the writeForm section.`,
817
+ );
818
+ }
819
+ if (!allWriteHandlerQns.has(section.handler)) {
820
+ throw new Error(
821
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
822
+ `(writeForm) handler "${section.handler}" is not a registered write-handler. Check the QN ` +
823
+ `spelling (expected "<feature>:write:<short>") and that the handler is declared via ` +
824
+ `r.writeHandler(...).`,
825
+ );
826
+ }
735
827
  continue;
736
828
  }
737
829
  if (section.fields.length === 0) {
@@ -837,6 +929,12 @@ export function validateScreens(
837
929
  `primitive (fw#2166).`,
838
930
  );
839
931
  }
932
+ if (isWriteFormEditSection(section)) {
933
+ throw new Error(
934
+ `[Feature ${feature.name}] Screen "${screenId}" (configEdit) writeForm section ` +
935
+ `"${section.title}" is not supported — writeForm is a projectionDetail-only primitive.`,
936
+ );
937
+ }
840
938
  if (section.fields.length === 0) {
841
939
  throw new Error(
842
940
  `[Feature ${feature.name}] Screen "${screenId}" (configEdit) has a section "${section.title}" ` +
@@ -952,6 +1050,12 @@ export function validateScreens(
952
1050
  `primitive (fw#2166).`,
953
1051
  );
954
1052
  }
1053
+ if (isWriteFormEditSection(section)) {
1054
+ throw new Error(
1055
+ `[Feature ${feature.name}] Screen "${screenId}" (actionForm) writeForm section ` +
1056
+ `"${section.title}" is not supported — writeForm is a projectionDetail-only primitive.`,
1057
+ );
1058
+ }
955
1059
  if (section.fields.length === 0) {
956
1060
  throw new Error(
957
1061
  `[Feature ${feature.name}] Screen "${screenId}" (actionForm) has a section "${section.title}" ` +
@@ -1308,6 +1412,12 @@ export function validateScreens(
1308
1412
  `primitive (fw#2166).`,
1309
1413
  );
1310
1414
  }
1415
+ if (isWriteFormEditSection(section)) {
1416
+ throw new Error(
1417
+ `[Feature ${feature.name}] Screen "${screenId}" (entityEdit) writeForm section ` +
1418
+ `"${section.title}" is not supported — writeForm is a projectionDetail-only primitive.`,
1419
+ );
1420
+ }
1311
1421
  if (section.fields.length === 0) {
1312
1422
  throw new Error(
1313
1423
  `[Feature ${feature.name}] Screen "${screenId}" (entityEdit) has a section "${section.title}" ` +
@@ -339,6 +339,13 @@ export function defineEntityQueryHandler(
339
339
  const result = await executor.list(listPayload, query.user, db, {
340
340
  ...(ctx.searchAdapter !== undefined && { searchAdapter: ctx.searchAdapter }),
341
341
  ...(ctx.includeDeleted === true && { includeDeleted: true }),
342
+ // fw#2660 — registry access only exists at request time, so the
343
+ // searchable-reference cache + entity resolver travel through the
344
+ // same runtime-override extension point as searchAdapter above.
345
+ referenceSearch: {
346
+ fields: ctx.registry.getSearchableReferences(entityName),
347
+ resolveEntity: (name) => ctx.registry.getEntity(name),
348
+ },
342
349
  });
343
350
  const enrichedRows = hasRefFields
344
351
  ? await enrichWithReferences(
@@ -242,6 +242,7 @@ export { buildInsertSchema, buildUpdateSchema, fieldToZod } from "./schema-build
242
242
  export {
243
243
  isExtensionEditSection,
244
244
  isFieldsEditSection,
245
+ isWriteFormEditSection,
245
246
  normalizeEditField,
246
247
  normalizeListColumn,
247
248
  } from "./screen-helpers";
@@ -1,4 +1,4 @@
1
- import type { IncomingRelation, RegistryState } from "./registry-state";
1
+ import type { IncomingRelation, RegistryState, SearchableReferenceField } from "./registry-state";
2
2
  import { filterByPhase, filterOwned } from "./registry-state";
3
3
  import type {
4
4
  AuthClaimsHookDef,
@@ -97,8 +97,8 @@ export function buildRegistryFacade(state: RegistryState): Registry {
97
97
  return (state.relationMap.get(entityName) ?? {}) as EntityRelations; // @cast-boundary schema-walk
98
98
  },
99
99
 
100
- getSearchIncludes(entityName: string): ReadonlyMap<string, readonly string[]> {
101
- return state.searchIncludesCache.get(entityName) ?? new Map();
100
+ getSearchableReferences(entityName: string): readonly SearchableReferenceField[] {
101
+ return state.searchableReferencesCache.get(entityName) ?? [];
102
102
  },
103
103
 
104
104
  getIncomingRelations(entityName: string): readonly IncomingRelation[] {
@@ -49,6 +49,17 @@ export type IncomingRelation = {
49
49
  relation: RelationDefinition;
50
50
  };
51
51
 
52
+ // One entry per `ReferenceFieldDef` with `searchable: true` (fw#2660) —
53
+ // resolved once at boot so the read-path never re-parses `field.entity`
54
+ // per request. `labelField` is guaranteed defined here: buildSearchableSortableCaches
55
+ // throws at boot for a searchable reference missing an explicit labelField
56
+ // (the "id" default is a UUID column, ILIKE against it would 500 at request time).
57
+ export type SearchableReferenceField = {
58
+ readonly fieldName: string;
59
+ readonly targetEntityName: string;
60
+ readonly labelField: string;
61
+ };
62
+
52
63
  const IMPLICIT_PROJECTION_SUFFIX = "-entity" as const;
53
64
 
54
65
  // Pro r.entity-Registration eine ImplicitProjection mit auto-generierten
@@ -223,7 +234,7 @@ export type RegistryState = {
223
234
  treeActionsMap: Map<string, Readonly<Record<string, TreeActionDef>>>;
224
235
  searchableFieldsCache: Map<string, readonly string[]>;
225
236
  sortableFieldsCache: Map<string, readonly string[]>;
226
- searchIncludesCache: Map<string, ReadonlyMap<string, readonly string[]>>;
237
+ searchableReferencesCache: Map<string, readonly SearchableReferenceField[]>;
227
238
  incomingRelationsCache: Map<string, IncomingRelation[]>;
228
239
  hasRateLimitedHandlerCached: boolean;
229
240
  };
@@ -286,7 +297,7 @@ export function createInitialState(): RegistryState {
286
297
  treeActionsMap: new Map(),
287
298
  searchableFieldsCache: new Map(),
288
299
  sortableFieldsCache: new Map(),
289
- searchIncludesCache: new Map(),
300
+ searchableReferencesCache: new Map(),
290
301
  incomingRelationsCache: new Map(),
291
302
  hasRateLimitedHandlerCached: false,
292
303
  };