@cosmicdrift/kumiko-framework 0.330.2 → 0.331.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.
@@ -38,6 +38,9 @@ export function validateActionWiring(feature) {
38
38
  for (const action of screen.toolbarActions ?? []) {
39
39
  validateActionNoFunctions(feature.name, screen.id, "toolbarAction", action);
40
40
  }
41
+ if (screen.type === "entityList" && screen.expandableRow !== undefined) {
42
+ validateColumnsNoFunctions(feature.name, screen.id, "entityList", screen.expandableRow.columns);
43
+ }
41
44
  }
42
45
  }
43
46
  // EditFieldSpec's visible/readOnly/required are the same FieldCondition DSL
@@ -4,6 +4,7 @@ export declare function validateConfigKeyBounds(feature: FeatureDefinition): voi
4
4
  export declare function validateConfigKeyComputed(feature: FeatureDefinition): void;
5
5
  export declare function validateConfigKeyRequired(feature: FeatureDefinition): void;
6
6
  export declare function validateConfigKeyAllowPerRequest(feature: FeatureDefinition): void;
7
+ export declare function validateConfigKeyOptionsQuery(feature: FeatureDefinition): void;
7
8
  export declare function validateConfigKeyBacking(feature: FeatureDefinition): void;
8
9
  export declare function validateConfigKeyPiiEncrypted(feature: FeatureDefinition): void;
9
10
  export declare function validateConfigReads(features: readonly FeatureDefinition[], allConfigKeys: ReadonlySet<string>): void;
@@ -101,6 +101,30 @@ export function validateConfigKeyAllowPerRequest(feature) {
101
101
  }
102
102
  }
103
103
  }
104
+ // --- Config key optionsQuery compatibility ---
105
+ export function validateConfigKeyOptionsQuery(feature) {
106
+ for (const [keyName, keyDef] of Object.entries(feature.configKeys)) {
107
+ const prefix = `[Feature ${feature.name}] Config key "${keyName}"`;
108
+ if (keyDef.optionsQuery === undefined) {
109
+ if (keyDef.optionsQueryPayload !== undefined) {
110
+ throw new Error(`${prefix} has optionsQueryPayload without optionsQuery`);
111
+ }
112
+ continue;
113
+ }
114
+ if (keyDef.optionsQuery.length === 0) {
115
+ throw new Error(`${prefix} has an empty optionsQuery`);
116
+ }
117
+ if (keyDef.type !== "select") {
118
+ throw new Error(`${prefix} has optionsQuery but type="${keyDef.type}" — optionsQuery is only valid on select keys`);
119
+ }
120
+ if (keyDef.options !== undefined && keyDef.options.length > 0) {
121
+ throw new Error(`${prefix} declares both options and optionsQuery — pick one`);
122
+ }
123
+ if (keyDef.allowPerRequest) {
124
+ throw new Error(`${prefix} has optionsQuery AND allowPerRequest=true — a per-request value would bypass the option set unchecked`);
125
+ }
126
+ }
127
+ }
104
128
  // --- Config key storage backing × scope matrix ---
105
129
  export function validateConfigKeyBacking(feature) {
106
130
  for (const [keyName, keyDef] of Object.entries(feature.configKeys)) {
@@ -16,6 +16,7 @@ export declare function validateEncryptedFields(feature: FeatureDefinition): boo
16
16
  export declare function validateFileFields(feature: FeatureDefinition): boolean;
17
17
  export declare function validateReferenceFields(feature: FeatureDefinition, featureMap: ReadonlyMap<string, FeatureDefinition>, queryHandlers: ReadonlyMap<string, QueryHandlerDef>): void;
18
18
  export declare function validateEmbeddedFields(feature: FeatureDefinition, featureMap: ReadonlyMap<string, FeatureDefinition>, queryHandlers: ReadonlyMap<string, QueryHandlerDef>): void;
19
+ export declare function validateEntitySelectOptionsQuery(feature: FeatureDefinition): void;
19
20
  export declare function validateMultiSelectFields(feature: FeatureDefinition): void;
20
21
  export declare function validateImageVariants(feature: FeatureDefinition): void;
21
22
  export declare function validateTransitions(feature: FeatureDefinition): void;
@@ -593,6 +593,26 @@ function validateCheckboxDisplayOptions(field, fieldName, entityName) {
593
593
  throw new Error(`MultiSelect field "${fieldName}" on entity "${entityName}" sets maxRows, which is only valid with display: "checkboxes".`);
594
594
  }
595
595
  }
596
+ export function validateEntitySelectOptionsQuery(feature) {
597
+ for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
598
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
599
+ assertNoSelectOptionsQuery(field, `${entityName}.${fieldName}`, feature.name);
600
+ if (field.type !== "embedded")
601
+ continue;
602
+ for (const [subName, subField] of Object.entries(field.schema ?? {})) {
603
+ assertNoSelectOptionsQuery(subField, `${entityName}.${fieldName}.${subName}`, feature.name);
604
+ }
605
+ }
606
+ }
607
+ }
608
+ function assertNoSelectOptionsQuery(field, path, featureName) {
609
+ // @cast-boundary schema-walk — embedded sub-field types don't declare optionsQuery, an untyped author may still set it
610
+ const { optionsQuery, optionsQueryPayload } = field;
611
+ const declaresOptionsQuery = optionsQuery !== undefined || optionsQueryPayload !== undefined;
612
+ if (field.type === "select" && declaresOptionsQuery) {
613
+ throw new Error(`[Feature ${featureName}] Entity select field "${path}" declares optionsQuery — not supported on entity fields because the stored value would not be validated against the query result. Use static options, or a reference field.`);
614
+ }
615
+ }
596
616
  export function validateMultiSelectFields(feature) {
597
617
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
598
618
  for (const [fieldName, field] of Object.entries(entity.fields)) {
@@ -7,9 +7,9 @@ import { warnOnUniqueAccessRoles } from "./access-roles.js";
7
7
  import { validateActionWiring, validateFieldWiring } from "./action-wiring.js";
8
8
  import { validateApiExposureMatching, validateExtensionUsages } from "./api-ext.js";
9
9
  import { validateFeatureBootChecks } from "./boot-check.js";
10
- import { validateCircularDeps, validateConfigKeyAllowPerRequest, validateConfigKeyBacking, validateConfigKeyBounds, validateConfigKeyComputed, validateConfigKeyPiiEncrypted, validateConfigKeyRequired, validateConfigReads, warnOnToggleableDependencies, } from "./config-deps.js";
10
+ import { validateCircularDeps, validateConfigKeyAllowPerRequest, validateConfigKeyBacking, validateConfigKeyBounds, validateConfigKeyComputed, validateConfigKeyOptionsQuery, validateConfigKeyPiiEncrypted, validateConfigKeyRequired, validateConfigReads, warnOnToggleableDependencies, } from "./config-deps.js";
11
11
  import { collectDetailForScreens } from "./detail-screens.js";
12
- import { validateDerivedFieldCollisions, validateEmbeddedFields, validateEncryptedFields, validateEntityIndexes, validateExtendSchemaCollisions, validateExtensionPreSaveWiring, validateFileFields, validateHandlerAccess, validateImageVariants, validateLocatedTimestamps, validateMultiSelectFields, validateMultiStreamProjections, validateReferenceFields, validateTransitions, } from "./entity-handler.js";
12
+ import { validateDerivedFieldCollisions, validateEmbeddedFields, validateEncryptedFields, validateEntityIndexes, validateEntitySelectOptionsQuery, validateExtendSchemaCollisions, validateExtensionPreSaveWiring, validateFileFields, validateHandlerAccess, validateImageVariants, validateLocatedTimestamps, validateMultiSelectFields, validateMultiStreamProjections, validateReferenceFields, validateTransitions, } from "./entity-handler.js";
13
13
  import { validateEntityListScreens } from "./entity-list-screens.js";
14
14
  import { validateGdprStoragePersistence } from "./gdpr-storage.js";
15
15
  import { validateGlobalTenancyEntities } from "./global-tenancy.js";
@@ -122,6 +122,7 @@ export function validateBoot(rawFeatures, options) {
122
122
  validatePiiAndRetention(feature);
123
123
  validateRecordOwnedSubjects(feature);
124
124
  validateApiExposureMatching(feature, allExposedApis, featureMap);
125
+ validateEntitySelectOptionsQuery(feature);
125
126
  validateEmbeddedFields(feature, featureMap, queryHandlerQns);
126
127
  validateMultiSelectFields(feature);
127
128
  validateImageVariants(feature);
@@ -139,6 +140,7 @@ export function validateBoot(rawFeatures, options) {
139
140
  validateConfigKeyComputed(feature);
140
141
  validateConfigKeyAllowPerRequest(feature);
141
142
  validateConfigKeyBacking(feature);
143
+ validateConfigKeyOptionsQuery(feature);
142
144
  validateConfigKeyPiiEncrypted(feature);
143
145
  validateOwnershipRules(feature, allClaimKeys, knownRoles);
144
146
  validateParentRefs(feature, featureMap);
@@ -200,19 +200,24 @@ function validateRelatedListParentFilter(prefix, section, schema, handler, featu
200
200
  export function validateRelatedListSectionQueries(features) {
201
201
  const queryHandlers = buildQueryHandlerMap(features);
202
202
  const featureMap = new Map(features.map((f) => [f.name, f]));
203
+ const validateSectionQuery = (prefix, section) => {
204
+ const handler = queryHandlers.get(section.query);
205
+ const schema = handler?.schema;
206
+ validateRelatedListSearchable(prefix, section, schema);
207
+ validateRelatedListFacets(prefix, section, schema);
208
+ validateRelatedListParentFilter(prefix, section, schema, handler, featureMap);
209
+ };
203
210
  for (const feature of features) {
204
211
  for (const screen of Object.values(feature.screens)) {
212
+ if (screen.type === "entityList" && screen.expandableRow !== undefined) {
213
+ validateSectionQuery(`[Feature ${feature.name}] Screen "${screen.id}" (entityList) expandableRow "${screen.expandableRow.title}"`, screen.expandableRow);
214
+ }
205
215
  if (!isEditLayoutScreen(screen))
206
216
  continue;
207
217
  for (const section of screen.layout.sections) {
208
218
  if (section.kind !== "relatedList")
209
219
  continue;
210
- const prefix = `[Feature ${feature.name}] Screen "${screen.id}" (${screen.type}) relatedList section "${section.title}"`;
211
- const handler = queryHandlers.get(section.query);
212
- const schema = handler?.schema;
213
- validateRelatedListSearchable(prefix, section, schema);
214
- validateRelatedListFacets(prefix, section, schema);
215
- validateRelatedListParentFilter(prefix, section, schema, handler, featureMap);
220
+ validateSectionQuery(`[Feature ${feature.name}] Screen "${screen.id}" (${screen.type}) relatedList section "${section.title}"`, section);
216
221
  }
217
222
  }
218
223
  }
@@ -139,6 +139,14 @@ function checkScreenOutputColumns(queryHandlers, featureName, screenId, screen)
139
139
  if (screen.type === "projectionList") {
140
140
  checkProjectionListOutputColumns(queryHandlers, featureName, screenId, screen);
141
141
  }
142
+ else if (screen.type === "entityList") {
143
+ if (screen.expandableRow !== undefined) {
144
+ const rowShape = getZodRowShape(queryHandlers.get(screen.expandableRow.query)?.outputSchema);
145
+ for (const column of screen.expandableRow.columns) {
146
+ checkColumnField(rowShape, column, (field) => `[Feature ${featureName}] Screen "${screenId}" (entityList) expandableRow "${screen.expandableRow?.title}" column "${field}" is not present in query "${screen.expandableRow?.query}"'s outputSchema — check for a typo, or add a "label" to mark it a virtual/computed column.`);
147
+ }
148
+ }
149
+ }
142
150
  else if (screen.type === "projectionDetail") {
143
151
  checkProjectionDetailOutputFields(queryHandlers, featureName, screenId, screen);
144
152
  checkEditLayoutOutputColumns(queryHandlers, featureName, screenId, "projectionDetail", screen.layout);
@@ -44,10 +44,22 @@ function checkDashboardQueryRefs(queryHandlers, featureName, screenId, screen) {
44
44
  checkQueryRef(queryHandlers, screen.filter.optionsQuery, () => `[Feature ${featureName}] Screen "${screenId}" (dashboard) filter "${screen.filter?.id}"`);
45
45
  }
46
46
  }
47
+ function checkFormFieldQueryRefs(queryHandlers, featureName, screenId, screenType, fields) {
48
+ for (const [fieldName, field] of Object.entries(fields)) {
49
+ if (field.type !== "select" || field.optionsQuery === undefined)
50
+ continue;
51
+ checkQueryRef(queryHandlers, field.optionsQuery, () => `[Feature ${featureName}] Screen "${screenId}" (${screenType}) select field "${fieldName}" optionsQuery`);
52
+ }
53
+ }
47
54
  function checkScreenQueryRefs(queryHandlers, featureName, screenId, screen) {
48
55
  if (screen.type === "projectionList") {
49
56
  checkQueryRef(queryHandlers, screen.query, () => `[Feature ${featureName}] Screen "${screenId}" (projectionList)`);
50
57
  }
58
+ else if (screen.type === "entityList") {
59
+ if (screen.expandableRow !== undefined) {
60
+ checkQueryRef(queryHandlers, screen.expandableRow.query, () => `[Feature ${featureName}] Screen "${screenId}" (entityList) expandableRow "${screen.expandableRow?.title}"`);
61
+ }
62
+ }
51
63
  else if (screen.type === "projectionDetail") {
52
64
  checkQueryRef(queryHandlers, screen.query, () => `[Feature ${featureName}] Screen "${screenId}" (projectionDetail)`);
53
65
  checkEditLayoutQueryRefs(queryHandlers, featureName, screenId, "projectionDetail", screen.layout);
@@ -57,6 +69,12 @@ function checkScreenQueryRefs(queryHandlers, featureName, screenId, screen) {
57
69
  screen.type === "configEdit" ||
58
70
  screen.type === "secretMint") {
59
71
  checkEditLayoutQueryRefs(queryHandlers, featureName, screenId, screen.type, screen.layout);
72
+ if (screen.type !== "entityEdit") {
73
+ checkFormFieldQueryRefs(queryHandlers, featureName, screenId, screen.type, screen.fields);
74
+ }
75
+ if (screen.type === "secretMint" && screen.confirm !== undefined) {
76
+ checkFormFieldQueryRefs(queryHandlers, featureName, screenId, "secretMint confirm", screen.confirm.fields);
77
+ }
60
78
  }
61
79
  else if (screen.type === "dashboard") {
62
80
  checkDashboardQueryRefs(queryHandlers, featureName, screenId, screen);
@@ -65,6 +83,11 @@ function checkScreenQueryRefs(queryHandlers, featureName, screenId, screen) {
65
83
  export function validateQueryRefs(features) {
66
84
  const queryHandlers = buildQueryHandlerMap(features);
67
85
  for (const feature of features) {
86
+ for (const [keyName, keyDef] of Object.entries(feature.configKeys)) {
87
+ if (keyDef.optionsQuery === undefined)
88
+ continue;
89
+ checkQueryRef(queryHandlers, keyDef.optionsQuery, () => `[Feature ${feature.name}] Config key "${keyName}" optionsQuery`);
90
+ }
68
91
  for (const [screenId, screen] of Object.entries(feature.screens)) {
69
92
  checkScreenQueryRefs(queryHandlers, feature.name, screenId, screen);
70
93
  }
@@ -471,9 +471,28 @@ function validateFormFieldsMap(featureName, screenId, context, fields, allowEmpt
471
471
  if (ftype === "money") {
472
472
  validateFormMoneyCurrency(featureName, screenId, context, fname, fdef);
473
473
  }
474
+ validateFormSelectOptions(featureName, screenId, context, fname, fdef);
474
475
  }
475
476
  return fieldNames;
476
477
  }
478
+ // Static options XOR optionsQuery on inline-form select fields. The QN
479
+ // existence check lives in query-refs.ts with the other query refs.
480
+ function validateFormSelectOptions(featureName, screenId, context, fieldName, fdef) {
481
+ if (fdef.type === "select") {
482
+ const where = `[Feature ${featureName}] Screen "${screenId}" (${context}) select field "${fieldName}"`;
483
+ if (fdef.optionsQuery !== undefined) {
484
+ if (fdef.optionsQuery.length === 0) {
485
+ throw new Error(`${where} has an empty optionsQuery`);
486
+ }
487
+ if (fdef.options.length > 0) {
488
+ throw new Error(`${where} declares both options and optionsQuery — pick one`);
489
+ }
490
+ }
491
+ else if (fdef.optionsQueryPayload !== undefined) {
492
+ throw new Error(`${where} has optionsQueryPayload without optionsQuery`);
493
+ }
494
+ }
495
+ }
477
496
  // Fail-closed currency-source gate (fw#2839), the runtime half of the
478
497
  // narrowed `FormFieldDefinition` — an untyped JS consumer has no compiler to
479
498
  // stop it. An inline form screen has no entity, so a money field there can't
@@ -681,6 +700,98 @@ function validateScreenHasNavArea(feature, screenId, screen, featureMap) {
681
700
  `a standalone r.nav() pointing at it, or set dormant: true if it's intentionally reachable ` +
682
701
  `only via a direct link/redirect or a consuming app's own r.nav().`);
683
702
  }
703
+ // Shared by projectionDetail relatedList sections and entityList.expandableRow:
704
+ // both render a relatedList whose rows come from an arbitrary query.
705
+ function validateRelatedListSection(args) {
706
+ const { featureName, featureMap, where, section } = args;
707
+ if (!section.query || typeof section.query !== "string") {
708
+ throw new Error(`[Feature ${featureName}] ${where} has empty or non-string query.`);
709
+ }
710
+ if (section.entity !== undefined) {
711
+ assertRefTargetRegistered(`[Feature ${featureName}] ${where}`, "entity", section.entity, featureName, featureMap);
712
+ }
713
+ for (const col of section.columns) {
714
+ const normalizedCol = normalizeListColumn(col);
715
+ if (normalizedCol.refEntity !== undefined) {
716
+ assertRefTargetRegistered(`[Feature ${featureName}] ${where}`, `column "${normalizedCol.field}" (refEntity)`, normalizedCol.refEntity, featureName, featureMap);
717
+ }
718
+ }
719
+ if (section.rowClick !== undefined) {
720
+ const targetEntity = section.rowClick.entity;
721
+ const hasDetailScreen = [...featureMap.values()].some((f) => Object.values(f.screens).some((s) => s.detailFor === targetEntity));
722
+ if (!hasDetailScreen) {
723
+ throw new Error(`[Feature ${featureName}] ${where} ` +
724
+ `rowClick targets entity "${targetEntity}", but no screen declares ` +
725
+ `detailFor: "${targetEntity}". Add detailFor: "${targetEntity}" to the screen that shows it.`);
726
+ }
727
+ }
728
+ if (section.rowActions !== undefined)
729
+ validateRelatedListRowActions(args, section.rowActions);
730
+ if (section.defaultSort !== undefined)
731
+ validateRelatedListDefaultSort(args, section.defaultSort);
732
+ if (section.facets !== undefined) {
733
+ validateListFacets(`[Feature ${featureName}] ${where}`, section.facets, section.columns, featureName, featureMap);
734
+ }
735
+ if (section.toolbarActions !== undefined) {
736
+ validateRelatedListToolbarActions(args, section.toolbarActions);
737
+ }
738
+ }
739
+ function validateRelatedListRowActions(args, rowActions) {
740
+ const { featureName, screenId, screenType, where, section, screens, allWriteHandlerQns, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens, } = args;
741
+ for (const action of rowActions) {
742
+ if (action.kind === "navigate") {
743
+ const target = resolveRowActionNavigateTarget(featureName, screenId, screenType, "rowAction", action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
744
+ validateRowActionNavigateParams(featureName, screenId, screenType, undefined, action, target);
745
+ }
746
+ else if (action.kind === "drawer") {
747
+ validateDrawerTargetAction(featureName, screenId, screenType, "rowAction", action, screens);
748
+ }
749
+ else if (!allWriteHandlerQns.has(action.handler)) {
750
+ throw new Error(`[Feature ${featureName}] ${where} ` +
751
+ `rowAction "${action.id}" handler "${action.handler}" ` +
752
+ `is not a registered write-handler. Check the QN spelling (expected ` +
753
+ `"<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`);
754
+ }
755
+ }
756
+ // section.rowClick (legacy, navigate-only) and a rowActions entry
757
+ // marked rowClick:true both claim the row-body click — same
758
+ // at-most-one constraint as entityList/projectionList's
759
+ // validateAtMostOneRowClick, just spanning two fields instead of one.
760
+ const rowClickActionCount = rowActions.filter((a) => a.kind === "navigate" && a.rowClick === true).length;
761
+ const legacyRowClickCount = section.rowClick !== undefined ? 1 : 0;
762
+ if (rowClickActionCount + legacyRowClickCount > 1) {
763
+ throw new Error(`[Feature ${featureName}] ${where} ` +
764
+ `has both a rowClick and ${rowClickActionCount} rowActions marked ` +
765
+ `rowClick:true — at most one may fire on a row-body click.`);
766
+ }
767
+ }
768
+ function validateRelatedListDefaultSort(args, defaultSort) {
769
+ const { featureName, where, section } = args;
770
+ const sortField = defaultSort.field;
771
+ const col = section.columns.find((c) => normalizeListColumn(c).field === sortField);
772
+ if (col === undefined) {
773
+ throw new Error(`[Feature ${featureName}] ${where} ` +
774
+ `defaultSort.field "${sortField}" is not a listed column.`);
775
+ }
776
+ if (normalizeListColumn(col).sortable !== true) {
777
+ throw new Error(`[Feature ${featureName}] ${where} ` +
778
+ `defaultSort.field "${sortField}" is not sortable. Set sortable: true on ` +
779
+ `the column or pick another field.`);
780
+ }
781
+ }
782
+ // Only drawer-kind and navigate actions that set tab are validated here;
783
+ // plain navigate (no tab) and writeHandler toolbarActions have no boot check yet.
784
+ function validateRelatedListToolbarActions(args, toolbarActions) {
785
+ const { featureName, screenId, screenType, screens, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens, } = args;
786
+ for (const action of toolbarActions) {
787
+ if (action.kind === "drawer") {
788
+ validateDrawerTargetAction(featureName, screenId, screenType, "toolbarAction", action, screens);
789
+ }
790
+ if (action.kind === "navigate" && action.tab !== undefined) {
791
+ resolveRowActionNavigateTarget(featureName, screenId, screenType, "toolbarAction", action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
792
+ }
793
+ }
794
+ }
684
795
  export function validateScreens(feature, featureMap, allWriteHandlerQns, allScreenQns, allConfigKeyQns, screensByShortId, detailForScreens) {
685
796
  // navigate-Targets (rowAction/toolbarAction) dürfen cross-feature zeigen —
686
797
  // der Runtime-Router (create-app) löst eine bare screenId app-weit über ALLE
@@ -876,87 +987,25 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
876
987
  continue;
877
988
  }
878
989
  if (section.kind === "relatedList") {
879
- if (!section.query || typeof section.query !== "string") {
880
- throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
881
- `(relatedList) has empty or non-string query.`);
882
- }
883
- for (const col of section.columns) {
884
- const normalizedCol = normalizeListColumn(col);
885
- if (normalizedCol.refEntity !== undefined) {
886
- assertRefTargetRegistered(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" (relatedList)`, `column "${normalizedCol.field}" (refEntity)`, normalizedCol.refEntity, feature.name, featureMap);
887
- }
888
- }
889
990
  if (screen.layout.mode === "wizard") {
890
991
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
891
992
  `is kind "relatedList" in a wizard layout — a read-only list is not supported in a ` +
892
993
  `stepped form. Remove mode: "wizard" or drop the relatedList section.`);
893
994
  }
894
- if (section.rowClick !== undefined) {
895
- const targetEntity = section.rowClick.entity;
896
- const hasDetailScreen = [...featureMap.values()].some((f) => Object.values(f.screens).some((s) => s.detailFor === targetEntity));
897
- if (!hasDetailScreen) {
898
- throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
899
- `(relatedList) rowClick targets entity "${targetEntity}", but no screen declares ` +
900
- `detailFor: "${targetEntity}". Add detailFor: "${targetEntity}" to the screen that shows it.`);
901
- }
902
- }
903
- if (section.rowActions !== undefined) {
904
- for (const action of section.rowActions) {
905
- if (action.kind === "navigate") {
906
- const target = resolveRowActionNavigateTarget(feature.name, screenId, "projectionDetail", "rowAction", action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
907
- validateRowActionNavigateParams(feature.name, screenId, "projectionDetail", undefined, action, target);
908
- }
909
- else if (action.kind === "drawer") {
910
- validateDrawerTargetAction(feature.name, screenId, "projectionDetail", "rowAction", action, feature.screens);
911
- }
912
- else if (!allWriteHandlerQns.has(action.handler)) {
913
- throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section ` +
914
- `"${section.title}" (relatedList) rowAction "${action.id}" handler "${action.handler}" ` +
915
- `is not a registered write-handler. Check the QN spelling (expected ` +
916
- `"<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`);
917
- }
918
- }
919
- // section.rowClick (legacy, navigate-only) and a rowActions entry
920
- // marked rowClick:true both claim the row-body click — same
921
- // at-most-one constraint as entityList/projectionList's
922
- // validateAtMostOneRowClick, just spanning two fields instead of one.
923
- const rowClickActionCount = section.rowActions.filter((a) => a.kind === "navigate" && a.rowClick === true).length;
924
- const legacyRowClickCount = section.rowClick !== undefined ? 1 : 0;
925
- if (rowClickActionCount + legacyRowClickCount > 1) {
926
- throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
927
- `(relatedList) has both a rowClick and ${rowClickActionCount} rowActions marked ` +
928
- `rowClick:true — at most one may fire on a row-body click.`);
929
- }
930
- }
931
- if (section.defaultSort !== undefined) {
932
- const sortField = section.defaultSort.field;
933
- const col = section.columns.find((c) => normalizeListColumn(c).field === sortField);
934
- if (col === undefined) {
935
- throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
936
- `(relatedList) defaultSort.field "${sortField}" is not a listed column.`);
937
- }
938
- if (normalizeListColumn(col).sortable !== true) {
939
- throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
940
- `(relatedList) defaultSort.field "${sortField}" is not sortable. Set sortable: true on ` +
941
- `the column or pick another field.`);
942
- }
943
- }
944
- if (section.facets !== undefined) {
945
- validateListFacets(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) relatedList section "${section.title}"`, section.facets, section.columns, feature.name, featureMap);
946
- }
947
- // Only drawer-kind and navigate actions that set tab are validated
948
- // here — plain navigate (no tab) and writeHandler toolbarActions
949
- // have no boot check yet, same gap as above.
950
- if (section.toolbarActions !== undefined) {
951
- for (const action of section.toolbarActions) {
952
- if (action.kind === "drawer") {
953
- validateDrawerTargetAction(feature.name, screenId, "projectionDetail", "toolbarAction", action, feature.screens);
954
- }
955
- if (action.kind === "navigate" && action.tab !== undefined) {
956
- resolveRowActionNavigateTarget(feature.name, screenId, "projectionDetail", "toolbarAction", action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
957
- }
958
- }
959
- }
995
+ validateRelatedListSection({
996
+ featureName: feature.name,
997
+ featureMap,
998
+ screenId,
999
+ screenType: "projectionDetail",
1000
+ where: `Screen "${screenId}" (projectionDetail) section "${section.title}" (relatedList)`,
1001
+ section,
1002
+ screens: feature.screens,
1003
+ allWriteHandlerQns,
1004
+ allScreenQns,
1005
+ navTargetShortIds,
1006
+ screensByShortId,
1007
+ detailForScreens,
1008
+ });
960
1009
  continue;
961
1010
  }
962
1011
  if (isWriteFormEditSection(section)) {
@@ -1048,6 +1097,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1048
1097
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) field "${fname}" has no ` +
1049
1098
  `\`type\` set. Each field must declare a type (e.g. "text", "number", "select").`);
1050
1099
  }
1100
+ validateFormSelectOptions(feature.name, screenId, "configEdit", fname, fdef);
1051
1101
  }
1052
1102
  if (screen.layout.sections.length === 0) {
1053
1103
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) has an empty sections list — ` +
@@ -1340,6 +1390,27 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1340
1390
  validateActionFieldRefs(feature.name, screenId, "toolbarAction", action.id, action, fieldNames, rowMeta);
1341
1391
  }
1342
1392
  }
1393
+ if (screen.expandableRow !== undefined) {
1394
+ const expandableRow = screen.expandableRow;
1395
+ validateRelatedListSection({
1396
+ featureName: feature.name,
1397
+ featureMap,
1398
+ screenId,
1399
+ screenType: "entityList expandableRow",
1400
+ where: `Screen "${screenId}" (entityList) expandableRow "${expandableRow.title}"`,
1401
+ section: expandableRow,
1402
+ screens: feature.screens,
1403
+ allWriteHandlerQns,
1404
+ allScreenQns,
1405
+ navTargetShortIds,
1406
+ screensByShortId,
1407
+ detailForScreens,
1408
+ });
1409
+ // Toolbar actions of the expansion evaluate against the host row.
1410
+ for (const action of expandableRow.toolbarActions ?? []) {
1411
+ validateActionFieldRefs(feature.name, screenId, "toolbarAction", action.id, action, fieldNames, rowMeta);
1412
+ }
1413
+ }
1343
1414
  }
1344
1415
  else {
1345
1416
  // Same rationale as the columns check: an entityEdit layout with zero
@@ -28,7 +28,17 @@ function projectionDetailNavigateActions(screen) {
28
28
  function navigateActionsOf(screen) {
29
29
  switch (screen.type) {
30
30
  case "entityList":
31
- return scopeActions(screen.rowActions, screen.entity);
31
+ return [
32
+ ...scopeActions(screen.rowActions, screen.entity),
33
+ ...(screen.expandableRow === undefined
34
+ ? []
35
+ : [
36
+ ...scopeActions(screen.expandableRow.rowActions, undefined),
37
+ ...scopeActions(screen.expandableRow.toolbarActions, undefined, {
38
+ pick: [relatedListParentParamField(screen.expandableRow)],
39
+ }),
40
+ ]),
41
+ ];
32
42
  case "projectionList":
33
43
  return scopeActions(screen.rowActions, undefined);
34
44
  case "entityEdit":
@@ -394,6 +394,9 @@ function projectField(fieldDef) {
394
394
  out["entity"] = def["entity"];
395
395
  if (typeof def["labelField"] === "string")
396
396
  out["labelField"] = def["labelField"];
397
+ // fw#2780: author-supplied picker source — without it the client falls back to the list handler.
398
+ if (typeof def["optionsQuery"] === "string")
399
+ out["optionsQuery"] = def["optionsQuery"];
397
400
  if (typeof def["multiple"] === "boolean")
398
401
  out["multiple"] = def["multiple"];
399
402
  // MultiSelect: display picks checkboxes vs. combobox in the renderer,
@@ -504,6 +504,15 @@ function deriveField(def, selectorPluginIds) {
504
504
  case "boolean":
505
505
  return createBooleanField();
506
506
  case "select":
507
+ if (def.optionsQuery !== undefined) {
508
+ return createSelectField({
509
+ options: [],
510
+ optionsQuery: def.optionsQuery,
511
+ ...(def.optionsQueryPayload !== undefined && {
512
+ optionsQueryPayload: def.optionsQueryPayload,
513
+ }),
514
+ });
515
+ }
507
516
  return def.options !== undefined && def.options.length > 0
508
517
  ? createSelectField({ options: def.options })
509
518
  : createTextField({ personal: false, reason: "system_metadata" });
@@ -18,6 +18,8 @@ type ConfigKeyOptions<T extends ConfigKeyType> = {
18
18
  encrypted?: boolean;
19
19
  piiEncrypted?: T extends "text" ? boolean : never;
20
20
  options?: readonly string[];
21
+ optionsQuery?: T extends "select" ? string : never;
22
+ optionsQueryPayload?: T extends "select" ? Readonly<Record<string, string | number | boolean>> : never;
21
23
  bounds?: T extends "number" ? ConfigBounds : never;
22
24
  pattern?: T extends "text" ? {
23
25
  regex: string;
@@ -35,6 +35,14 @@ const SCOPE_DEFAULTS = {
35
35
  user: { write: access.all, read: access.all },
36
36
  };
37
37
  // --- Factory ---
38
+ function optionsQueryProps(opts) {
39
+ return {
40
+ ...(opts.optionsQuery !== undefined && { optionsQuery: opts.optionsQuery }),
41
+ ...(opts.optionsQueryPayload !== undefined && {
42
+ optionsQueryPayload: opts.optionsQueryPayload,
43
+ }),
44
+ };
45
+ }
38
46
  function createConfigKey(scope, type, opts = {}) {
39
47
  const defaults = SCOPE_DEFAULTS[scope];
40
48
  return {
@@ -48,6 +56,7 @@ function createConfigKey(scope, type, opts = {}) {
48
56
  ...(opts.encrypted ? { encrypted: true } : {}),
49
57
  ...(opts.piiEncrypted ? { piiEncrypted: true } : {}),
50
58
  ...(opts.options ? { options: opts.options } : {}),
59
+ ...optionsQueryProps(opts),
51
60
  bounds: opts.bounds, // @cast-boundary schema-walk
52
61
  ...(opts.pattern ? { pattern: opts.pattern } : {}),
53
62
  computed: opts.computed,
@@ -71,7 +71,7 @@ export { describeWorkflowStepError } from "./steps/describe-workflow-step-error.
71
71
  export { ANONYMOUS_ROLE, ANONYMOUS_USER_ID, createAnonymousUser, createSystemUser, SYSTEM_ROLE, SYSTEM_USER_ID, } from "./system-user.js";
72
72
  export { type EffectiveFeaturesResolver, findTierResolverUsage, isTierResolverPlugin, TENANT_TIER_RESOLVER_EXT, type TierResolverPlugin, type TrialGate, } from "./tier-resolver-extension.js";
73
73
  export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers.js";
74
- export type { AccessRule, ActionFormScreenDefinition, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BelongsToRelation, BigIntFieldDef, BooleanFieldDef, CamelToKebab, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, ConcurrencyMode, ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigCascade, ConfigCascadeLevel, ConfigDefinition, ConfigEditScreenDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigScope, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValue, ConfigValueSource, ConfigValueWithSource, ContentCollectionDefinition, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DateFieldDef, DecimalFieldDef, DeleteContext, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EntityDefinition, EntityEditScreenDefinition, EntityId, EntityListScreenDefinition, EntityRef, EntityRelations, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, ExtensionOptionsArgs, ExtensionOptionsFor, FeatureDefinition, FeatureRegistrar, FieldAccess, FieldCondition, FieldDefinition, FieldFormatRegistry, FieldIconKey, FieldRenderer, FileFieldDef, FilesFieldDef, Findability, FormatSpec, FormWidth, HandlerContext, HasManyRelation, HookMap, ImageFieldDef, ImagesFieldDef, JobBackoff, JobBackoffStrategy, JobContext, JobDefinition, JobHandlerFn, JobTrigger, JobTriggerEvent, JsonbFieldDef, KumikoEntityTypeMap, KumikoEventTypeMap, KumikoExtensionOptionsMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, LifecycleHookType, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, LongTextFindability, ManyToManyRelation, MemberReader, MetricNavigate, MetricSpec, MspErrorMode, MspErrorPolicy, MultiSelectFieldDef, MultiStreamApplyFn, MultiStreamProjectionDefinition, NameOrRef, NavDefinition, NavIconKey, NotificationDataFn, NotificationDefinition, NotificationRecipientFn, NotificationTemplateFn, NotifyFactory, NotifyFn, NotifyOptions, NotifyPriority, NumberFieldDef, OnDeleteStrategy, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, PlatformComponent, PluralForms, PostDeleteHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, ProjectionDefinition, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, ProjectionTable, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RecordHeaderSpec, ReferenceDataDef, Registry, RelationDefinition, ResolvedPiiFlags, RetentionDef, RoleAccessPersonalData, RoleAccessRule, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, SaveContext, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretKeyHandle, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, SelectFieldDef, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, Subscribe, TargetRef, TenantId, TextFieldDef, ToolbarAction, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, UnitKey, UnsafeAppendEventFn, ValidationError, ValidationHookFn, WorkspaceDefinition, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "./types/index.js";
74
+ export type { AccessRule, ActionFormScreenDefinition, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BelongsToRelation, BigIntFieldDef, BooleanFieldDef, CamelToKebab, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, ConcurrencyMode, ConfigAccessor, ConfigAccessorFactory, ConfigBacking, ConfigCascade, ConfigCascadeLevel, ConfigDefinition, ConfigEditScreenDefinition, ConfigKeyAccess, ConfigKeyDefinition, ConfigKeyHandle, ConfigKeyType, ConfigMask, ConfigResolver, ConfigScope, ConfigSecretsReader, ConfigSeedDef, ConfigStoredRow, ConfigStoredRowWithSource, ConfigValue, ConfigValueSource, ConfigValueWithSource, ContentCollectionDefinition, CreateSeedOptions, CreateTenantSeedOptions, CreateUserSeedOptions, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DateFieldDef, DecimalFieldDef, DeleteContext, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EntityDefinition, EntityEditScreenDefinition, EntityId, EntityListExpandableRow, EntityListScreenDefinition, EntityRef, EntityRelations, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, ExtensionOptionsArgs, ExtensionOptionsFor, FeatureDefinition, FeatureRegistrar, FieldAccess, FieldCondition, FieldDefinition, FieldFormatRegistry, FieldIconKey, FieldRenderer, FileFieldDef, FilesFieldDef, Findability, FormatSpec, FormWidth, HandlerContext, HasManyRelation, HookMap, ImageFieldDef, ImagesFieldDef, JobBackoff, JobBackoffStrategy, JobContext, JobDefinition, JobHandlerFn, JobTrigger, JobTriggerEvent, JsonbFieldDef, KumikoEntityTypeMap, KumikoEventTypeMap, KumikoExtensionOptionsMap, KumikoHandlerPayloadMap, KumikoHandlerResultMap, LifecycleHookType, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, LongTextFindability, ManyToManyRelation, MemberReader, MetricNavigate, MetricSpec, MspErrorMode, MspErrorPolicy, MultiSelectFieldDef, MultiStreamApplyFn, MultiStreamProjectionDefinition, NameOrRef, NavDefinition, NavIconKey, NotificationDataFn, NotificationDefinition, NotificationRecipientFn, NotificationTemplateFn, NotifyFactory, NotifyFn, NotifyOptions, NotifyPriority, NumberFieldDef, OnDeleteStrategy, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, PlatformComponent, PluralForms, PostDeleteHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, ProjectionDefinition, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, ProjectionTable, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RecordHeaderSpec, ReferenceDataDef, Registry, RelationDefinition, ResolvedPiiFlags, RetentionDef, RoleAccessPersonalData, RoleAccessRule, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, SaveContext, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretKeyHandle, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, SelectFieldDef, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, Subscribe, TargetRef, TenantId, TextFieldDef, ToolbarAction, TranslationEntry, TranslationKeys, TranslationsDef, TranslationValue, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, UnitKey, UnsafeAppendEventFn, ValidationError, ValidationHookFn, WorkspaceDefinition, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "./types/index.js";
75
75
  export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, HookPhases, isAgentVisibleScreen, isOpenToAllGranted, resolveAgentExposure, } from "./types/index.js";
76
76
  export type { AwaitedEventType, PipelineBuildCtx, PipelineCtx, PipelineDef, StepBuilder, StepDef, StepFailureStrategy, StepInstance, StepKind, StepNamespace, StepResolver, } from "./types/step.js";
77
77
  export { runValidation } from "./validation.js";
@@ -55,6 +55,7 @@
55
55
  // - WorkspaceSchema.definition.nav — the raw explicit nav-QN list,
56
56
  // verbatim in JSON, distinct from the computed/filtered navMembers
57
57
  // - entityList.createScreen (same-feature target, dropped when not kept)
58
+ // - entityList.expandableRow (a relatedList: same projection as a relatedList section)
58
59
  // - listScreenId (projectionDetail/entityEdit/actionForm/secretMint/custom —
59
60
  // the breadcrumb "back to list" target). It's serialized, so a visible
60
61
  // screen would otherwise disclose a denied screen's short id. Resolved
@@ -433,12 +434,20 @@ function projectListScreen(original, feature, indices, keptScreenQns) {
433
434
  const { rowActions, toolbarActions, ...rest } = screen;
434
435
  const nextRowActions = projectRowActionArray(rowActions, feature, indices, keptScreenQns);
435
436
  const nextToolbarActions = projectToolbarActionArray(toolbarActions, feature, indices, keptScreenQns);
436
- if (nextRowActions === rowActions && nextToolbarActions === toolbarActions)
437
+ const expandableRow = screen.type === "entityList" ? screen.expandableRow : undefined;
438
+ const nextExpandableRow = expandableRow === undefined
439
+ ? undefined
440
+ : projectRelatedListSection(expandableRow, feature, indices, keptScreenQns);
441
+ if (nextRowActions === rowActions &&
442
+ nextToolbarActions === toolbarActions &&
443
+ nextExpandableRow === expandableRow) {
437
444
  return screen;
445
+ }
438
446
  return {
439
447
  ...rest,
440
448
  ...(nextRowActions !== undefined && { rowActions: nextRowActions }),
441
449
  ...(nextToolbarActions !== undefined && { toolbarActions: nextToolbarActions }),
450
+ ...(nextExpandableRow !== undefined && { expandableRow: nextExpandableRow }),
442
451
  };
443
452
  }
444
453
  function projectProjectionDetailScreen(screen, feature, indices, keptScreenQns) {
@@ -17,7 +17,7 @@ export type { ContentCollectionDefinition, NavDefinition, NavIconKey, } from "@c
17
17
  export type { FromRule, FromRuleKind, OwnershipClause, OwnershipMap, OwnershipRef, OwnershipRule, SqlFragment, WhereRule, WhereRuleContext, } from "@cosmicdrift/kumiko-types/ownership";
18
18
  export type { EntityProjectionExtension, MspErrorMode, MspErrorPolicy, MultiStreamApplyFn, MultiStreamProjectionDefinition, ProjectionDefinition, ProjectionTable, SingleStreamApplyFn, } from "@cosmicdrift/kumiko-types/projection";
19
19
  export type { BelongsToRelation, EntityRelations, HasManyRelation, ManyToManyRelation, RelationDefinition, } from "@cosmicdrift/kumiko-types/relations";
20
- export type { ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EntityEditScreenDefinition, EntityListScreenDefinition, FieldCondition, FieldFormatRegistry, FieldRenderer, FormatSpec, FormWidth, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSpec, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, UnitKey, } from "@cosmicdrift/kumiko-types/screen";
20
+ export type { ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartPanel, DashboardCustomPanel, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EntityEditScreenDefinition, EntityListExpandableRow, EntityListScreenDefinition, FieldCondition, FieldFormatRegistry, FieldRenderer, FormatSpec, FormWidth, ListColumnSpec, ListFacetSpec, ListPaginationMode, ListSortDir, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSpec, RowAction, RowActionNavigate, RowActionNavigateBase, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenNavSugar, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, UnitKey, } from "@cosmicdrift/kumiko-types/screen";
21
21
  export { metricField } from "@cosmicdrift/kumiko-types/screen";
22
22
  export type { TargetRef } from "@cosmicdrift/kumiko-types/target-ref";
23
23
  export type { Subscribe, TreeAction, TreeActionDef, TreeActionsHandle, TreeChildrenSubscribe, TreeNode, TreeNodeState, } from "@cosmicdrift/kumiko-types/tree-node";
@@ -107,6 +107,30 @@ function pushToolbarActionKeys(out, action) {
107
107
  // toolbar button AND the Drawer title, see ToolbarDrawerHost) is
108
108
  // already pushed above.
109
109
  }
110
+ function pushRelatedListSectionKeys(out, section) {
111
+ pushKey(out, section.title);
112
+ pushKey(out, section.description);
113
+ pushKey(out, section.itemNoun);
114
+ for (const col of section.columns) {
115
+ const normalized = normalizeListColumn(col);
116
+ if (normalized.label !== undefined)
117
+ pushKey(out, normalized.label);
118
+ }
119
+ for (const action of section.rowActions ?? [])
120
+ pushRowActionKeys(out, action);
121
+ }
122
+ function pushExpandableRowKeys(out, section) {
123
+ pushRelatedListSectionKeys(out, section);
124
+ for (const action of section.actions ?? [])
125
+ pushRowActionKeys(out, action);
126
+ for (const action of section.toolbarActions ?? [])
127
+ pushToolbarActionKeys(out, action);
128
+ pushKey(out, section.emptyState?.title);
129
+ pushKey(out, section.emptyState?.description);
130
+ if (section.emptyState?.action !== undefined) {
131
+ pushRowActionKeys(out, section.emptyState.action);
132
+ }
133
+ }
110
134
  export function requiredKeysFromScreen(featureName, screen, options = {}) {
111
135
  const { treatDotFormAsKey = false } = options;
112
136
  const out = new Set();
@@ -129,6 +153,8 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
129
153
  pushRowActionKeys(out, action);
130
154
  for (const action of list.toolbarActions ?? [])
131
155
  pushToolbarActionKeys(out, action);
156
+ if (list.expandableRow !== undefined)
157
+ pushExpandableRowKeys(out, list.expandableRow);
132
158
  break;
133
159
  }
134
160
  case "projectionList": {
@@ -301,16 +327,7 @@ export function requiredKeysFromScreen(featureName, screen, options = {}) {
301
327
  continue;
302
328
  }
303
329
  if (section.kind === "relatedList") {
304
- pushKey(out, section.title);
305
- pushKey(out, section.description);
306
- pushKey(out, section.itemNoun);
307
- for (const col of section.columns) {
308
- const normalized = normalizeListColumn(col);
309
- if (normalized.label !== undefined)
310
- pushKey(out, normalized.label);
311
- }
312
- for (const action of section.rowActions ?? [])
313
- pushRowActionKeys(out, action);
330
+ pushRelatedListSectionKeys(out, section);
314
331
  continue;
315
332
  }
316
333
  if (isWriteFormEditSection(section)) {
@@ -9,7 +9,7 @@ export type { BooleanFieldDef, DateFieldDef, EmbeddedDerivedCellDef, EntityDefin
9
9
  export type { AccessRule, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, RoleAccessPersonalData, RoleAccessRule, } from "../engine/types/handlers.js";
10
10
  export { isOpenToAllGranted, isUiAccessGranted } from "../engine/types/handlers.js";
11
11
  export type { IconKey, NavDefinition, NavIconKey } from "../engine/types/nav.js";
12
- export type { ActionFormRedirect, ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartKind, DashboardChartPanel, DashboardChartTone, DashboardCustomPanel, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelEmptyState, DashboardPanelQueryOptions, DashboardPanelSpan, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScopeDefinition, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardTimeRangeDefinition, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EntityEditScreenDefinition, EntityListScreenDefinition, FieldCondition, FieldIconKey, FieldRenderer, FormWidth, ListBadgeTone, ListColumnDisplay, ListColumnSpec, ListFacetSpec, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RelatedListToolbarAction, RowAction, RowActionDrawer, RowActionNavigate, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, } from "../engine/types/screen.js";
12
+ export type { ActionFormRedirect, ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartKind, DashboardChartPanel, DashboardChartTone, DashboardCustomPanel, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelEmptyState, DashboardPanelQueryOptions, DashboardPanelSpan, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScopeDefinition, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardTimeRangeDefinition, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EntityEditScreenDefinition, EntityListExpandableRow, EntityListScreenDefinition, FieldCondition, FieldIconKey, FieldRenderer, FormWidth, ListBadgeTone, ListColumnDisplay, ListColumnSpec, ListFacetSpec, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RelatedListToolbarAction, RowAction, RowActionDrawer, RowActionNavigate, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, } from "../engine/types/screen.js";
13
13
  export { metricField } from "../engine/types/screen.js";
14
14
  export type { TargetRef } from "../engine/types/target-ref.js";
15
15
  export type { TreeAction, TreeNode, TreeNodeState } from "../engine/types/tree-node.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.330.2",
3
+ "version": "0.331.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -206,8 +206,8 @@
206
206
  "./package.json": "./package.json"
207
207
  },
208
208
  "dependencies": {
209
- "@cosmicdrift/kumiko-http": "0.330.2",
210
- "@cosmicdrift/kumiko-types": "0.330.2",
209
+ "@cosmicdrift/kumiko-http": "0.331.0",
210
+ "@cosmicdrift/kumiko-types": "0.331.0",
211
211
  "bullmq": "^5.76.7",
212
212
  "bun-types": "^1.3.13",
213
213
  "hono": "^4.13.1",
@@ -223,7 +223,7 @@
223
223
  "zod": "^4.4.3"
224
224
  },
225
225
  "devDependencies": {
226
- "@cosmicdrift/kumiko-dispatcher-live": "0.330.2",
226
+ "@cosmicdrift/kumiko-dispatcher-live": "0.331.0",
227
227
  "bun-types": "^1.3.13",
228
228
  "pino-pretty": "^13.1.3"
229
229
  },
package/src/changes.json CHANGED
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "version": "0.331.0",
4
+ "type": "fix",
5
+ "title": "A reference field's optionsQuery now reaches the client schema, so the picker uses the declared handler",
6
+ "detail": "`ReferenceFieldDef.optionsQuery` was dropped by the client-schema projection, so the picker ignored the declared handler and fell back to the entity's list handler. The projection now keeps it.",
7
+ "migration": "No code change needed."
8
+ },
9
+ {
10
+ "version": "0.331.0",
11
+ "type": "improvement",
12
+ "title": "Select fields in configEdit and actionForm screens and select config keys can load their options from a query (optionsQuery)",
13
+ "detail": "A `select` config key and a `select` field on configEdit and actionForm screens accept `optionsQuery` (a query QN returning `{ rows: { value, label }[] }`) plus an optional static `optionsQueryPayload`. The Settings-Hub derives the field from the config key, the renderer mounts the query and shows the returned labels as they are. The boot validator rejects dead QNs, static `options` together with `optionsQuery`, `allowPerRequest` keys, and `optionsQuery` on entity fields. The write side does not check the value against the query result.",
14
+ "migration": "No code change needed. Existing select fields and config keys keep their static options."
15
+ },
2
16
  {
3
17
  "version": "0.330.2",
4
18
  "type": "fix",