@cosmicdrift/kumiko-framework 0.330.0 → 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);
@@ -188,16 +188,6 @@ export function validatePiiAndRetention(feature) {
188
188
  // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
189
189
  console.warn(`[kumiko:boot] [Feature ${feature.name}] Entity "${entityName}" retention.strategy="blockDelete" but no field has an anonymize-function. User-Forget cannot anonymize — Forget will return error. Add { anonymize: () => null } or () => "[ANONYMIZED]" to PII fields.`);
190
190
  }
191
- else if (!entityHasAnonymizableSubjectField) {
192
- const hasSubjectRef = Object.values(fieldsByName).some((f) => {
193
- const a = f; // @cast-boundary schema-walk
194
- return Boolean(a.subjectRef);
195
- });
196
- if (hasSubjectRef) {
197
- // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
198
- console.warn(`[kumiko:boot] [Feature ${feature.name}] Entity "${entityName}" retention.strategy="blockDelete" with subjectRef-only PII relies on the EXT_USER_DATA delete hook for Art.17 — make sure it is not a no-op.`);
199
- }
200
- }
201
191
  }
202
192
  }
203
193
  }
@@ -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
  }
@@ -67,6 +67,10 @@ function checkProjectionDetailOutputFields(queryHandlers, featureName, screenId,
67
67
  if (screen.header.status !== undefined)
68
68
  checkHeaderField("status", screen.header.status);
69
69
  }
70
+ if (screen.recordTitleField !== undefined) {
71
+ const field = screen.recordTitleField;
72
+ checkFieldExists(recordShape, field, () => `${prefix} recordTitleField references field "${field}" which is not present in query "${screen.query}"'s outputSchema.`);
73
+ }
70
74
  for (const metric of screen.metrics ?? []) {
71
75
  const field = metricField(metric);
72
76
  checkFieldExists(recordShape, field, () => `${prefix} metrics references field "${field}" which is not present in query "${screen.query}"'s outputSchema.`);
@@ -135,6 +139,14 @@ function checkScreenOutputColumns(queryHandlers, featureName, screenId, screen)
135
139
  if (screen.type === "projectionList") {
136
140
  checkProjectionListOutputColumns(queryHandlers, featureName, screenId, screen);
137
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
+ }
138
150
  else if (screen.type === "projectionDetail") {
139
151
  checkProjectionDetailOutputFields(queryHandlers, featureName, screenId, screen);
140
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
@@ -780,6 +891,10 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
780
891
  `and detailFor — the auto-generated "Edit" action navigates using the path's row id, which a ` +
781
892
  `singleton screen never has. Remove detailFor, or declare an explicit "edit" action instead.`);
782
893
  }
894
+ if (screen.recordTitleField !== undefined && screen.header !== undefined) {
895
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) sets both header and ` +
896
+ `recordTitleField — header.title already names the record, so drop recordTitleField.`);
897
+ }
783
898
  if (screen.layout.sections.length === 0) {
784
899
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has an empty sections list — ` +
785
900
  `declare at least one section.`);
@@ -872,87 +987,25 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
872
987
  continue;
873
988
  }
874
989
  if (section.kind === "relatedList") {
875
- if (!section.query || typeof section.query !== "string") {
876
- throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
877
- `(relatedList) has empty or non-string query.`);
878
- }
879
- for (const col of section.columns) {
880
- const normalizedCol = normalizeListColumn(col);
881
- if (normalizedCol.refEntity !== undefined) {
882
- assertRefTargetRegistered(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" (relatedList)`, `column "${normalizedCol.field}" (refEntity)`, normalizedCol.refEntity, feature.name, featureMap);
883
- }
884
- }
885
990
  if (screen.layout.mode === "wizard") {
886
991
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
887
992
  `is kind "relatedList" in a wizard layout — a read-only list is not supported in a ` +
888
993
  `stepped form. Remove mode: "wizard" or drop the relatedList section.`);
889
994
  }
890
- if (section.rowClick !== undefined) {
891
- const targetEntity = section.rowClick.entity;
892
- const hasDetailScreen = [...featureMap.values()].some((f) => Object.values(f.screens).some((s) => s.detailFor === targetEntity));
893
- if (!hasDetailScreen) {
894
- throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
895
- `(relatedList) rowClick targets entity "${targetEntity}", but no screen declares ` +
896
- `detailFor: "${targetEntity}". Add detailFor: "${targetEntity}" to the screen that shows it.`);
897
- }
898
- }
899
- if (section.rowActions !== undefined) {
900
- for (const action of section.rowActions) {
901
- if (action.kind === "navigate") {
902
- const target = resolveRowActionNavigateTarget(feature.name, screenId, "projectionDetail", "rowAction", action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
903
- validateRowActionNavigateParams(feature.name, screenId, "projectionDetail", undefined, action, target);
904
- }
905
- else if (action.kind === "drawer") {
906
- validateDrawerTargetAction(feature.name, screenId, "projectionDetail", "rowAction", action, feature.screens);
907
- }
908
- else if (!allWriteHandlerQns.has(action.handler)) {
909
- throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section ` +
910
- `"${section.title}" (relatedList) rowAction "${action.id}" handler "${action.handler}" ` +
911
- `is not a registered write-handler. Check the QN spelling (expected ` +
912
- `"<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`);
913
- }
914
- }
915
- // section.rowClick (legacy, navigate-only) and a rowActions entry
916
- // marked rowClick:true both claim the row-body click — same
917
- // at-most-one constraint as entityList/projectionList's
918
- // validateAtMostOneRowClick, just spanning two fields instead of one.
919
- const rowClickActionCount = section.rowActions.filter((a) => a.kind === "navigate" && a.rowClick === true).length;
920
- const legacyRowClickCount = section.rowClick !== undefined ? 1 : 0;
921
- if (rowClickActionCount + legacyRowClickCount > 1) {
922
- throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
923
- `(relatedList) has both a rowClick and ${rowClickActionCount} rowActions marked ` +
924
- `rowClick:true — at most one may fire on a row-body click.`);
925
- }
926
- }
927
- if (section.defaultSort !== undefined) {
928
- const sortField = section.defaultSort.field;
929
- const col = section.columns.find((c) => normalizeListColumn(c).field === sortField);
930
- if (col === undefined) {
931
- throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
932
- `(relatedList) defaultSort.field "${sortField}" is not a listed column.`);
933
- }
934
- if (normalizeListColumn(col).sortable !== true) {
935
- throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
936
- `(relatedList) defaultSort.field "${sortField}" is not sortable. Set sortable: true on ` +
937
- `the column or pick another field.`);
938
- }
939
- }
940
- if (section.facets !== undefined) {
941
- validateListFacets(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) relatedList section "${section.title}"`, section.facets, section.columns, feature.name, featureMap);
942
- }
943
- // Only drawer-kind and navigate actions that set tab are validated
944
- // here — plain navigate (no tab) and writeHandler toolbarActions
945
- // have no boot check yet, same gap as above.
946
- if (section.toolbarActions !== undefined) {
947
- for (const action of section.toolbarActions) {
948
- if (action.kind === "drawer") {
949
- validateDrawerTargetAction(feature.name, screenId, "projectionDetail", "toolbarAction", action, feature.screens);
950
- }
951
- if (action.kind === "navigate" && action.tab !== undefined) {
952
- resolveRowActionNavigateTarget(feature.name, screenId, "projectionDetail", "toolbarAction", action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
953
- }
954
- }
955
- }
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
+ });
956
1009
  continue;
957
1010
  }
958
1011
  if (isWriteFormEditSection(section)) {
@@ -1044,6 +1097,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1044
1097
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) field "${fname}" has no ` +
1045
1098
  `\`type\` set. Each field must declare a type (e.g. "text", "number", "select").`);
1046
1099
  }
1100
+ validateFormSelectOptions(feature.name, screenId, "configEdit", fname, fdef);
1047
1101
  }
1048
1102
  if (screen.layout.sections.length === 0) {
1049
1103
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) has an empty sections list — ` +
@@ -1336,6 +1390,27 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1336
1390
  validateActionFieldRefs(feature.name, screenId, "toolbarAction", action.id, action, fieldNames, rowMeta);
1337
1391
  }
1338
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
+ }
1339
1414
  }
1340
1415
  else {
1341
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";
@@ -54,6 +54,8 @@
54
54
  // buildAppSchema
55
55
  // - WorkspaceSchema.definition.nav — the raw explicit nav-QN list,
56
56
  // verbatim in JSON, distinct from the computed/filtered navMembers
57
+ // - entityList.createScreen (same-feature target, dropped when not kept)
58
+ // - entityList.expandableRow (a relatedList: same projection as a relatedList section)
57
59
  // - listScreenId (projectionDetail/entityEdit/actionForm/secretMint/custom —
58
60
  // the breadcrumb "back to list" target). It's serialized, so a visible
59
61
  // screen would otherwise disclose a denied screen's short id. Resolved
@@ -419,16 +421,33 @@ function projectCancelTarget(featureName, cancelTarget, keptScreenQns) {
419
421
  return cancelTarget;
420
422
  return isScreenTargetKept(featureName, cancelTarget, keptScreenQns) ? cancelTarget : undefined;
421
423
  }
422
- function projectListScreen(screen, feature, indices, keptScreenQns) {
424
+ function dropUnreachableCreateScreen(screen, feature, keptScreenQns) {
425
+ if (screen.type !== "entityList" || screen.createScreen === undefined)
426
+ return screen;
427
+ if (isScreenTargetKept(feature.featureName, screen.createScreen, keptScreenQns))
428
+ return screen;
429
+ const { createScreen: _unreachable, ...rest } = screen;
430
+ return rest;
431
+ }
432
+ function projectListScreen(original, feature, indices, keptScreenQns) {
433
+ const screen = dropUnreachableCreateScreen(original, feature, keptScreenQns);
423
434
  const { rowActions, toolbarActions, ...rest } = screen;
424
435
  const nextRowActions = projectRowActionArray(rowActions, feature, indices, keptScreenQns);
425
436
  const nextToolbarActions = projectToolbarActionArray(toolbarActions, feature, indices, keptScreenQns);
426
- 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) {
427
444
  return screen;
445
+ }
428
446
  return {
429
447
  ...rest,
430
448
  ...(nextRowActions !== undefined && { rowActions: nextRowActions }),
431
449
  ...(nextToolbarActions !== undefined && { toolbarActions: nextToolbarActions }),
450
+ ...(nextExpandableRow !== undefined && { expandableRow: nextExpandableRow }),
432
451
  };
433
452
  }
434
453
  function projectProjectionDetailScreen(screen, feature, indices, keptScreenQns) {
@@ -23,7 +23,11 @@ export function resolveNavParentScreen(screens, detail, getId) {
23
23
  // rowActions, toolbarActions, and drawer-kind actions (which mount inline
24
24
  // and have no page of their own) all count as "reached from" this list.
25
25
  const navigatesToDetail = (actions) => (actions ?? []).some((a) => (a.kind === "navigate" || a.kind === "drawer") && a.screen === detailScreenId);
26
+ // The create button of an entityList navigates to its createScreen, so the
27
+ // list is that screen's parent just like for a rowAction target.
26
28
  const listFromRowAction = screens.find((s) => {
29
+ if (s.type === "entityList" && s.createScreen === detailScreenId)
30
+ return true;
27
31
  if (s.type !== "entityList" && s.type !== "projectionList")
28
32
  return false;
29
33
  return navigatesToDetail(s.rowActions) || navigatesToDetail(s.toolbarActions);
@@ -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.0",
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.0",
210
- "@cosmicdrift/kumiko-types": "0.330.0",
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.0",
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,24 @@
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
+ },
16
+ {
17
+ "version": "0.330.2",
18
+ "type": "fix",
19
+ "title": "createScreen role projection without a type assertion (no behaviour change)",
20
+ "detail": "Internal cleanup of the createScreen role projection added in the previous release; behaviour is unchanged."
21
+ },
2
22
  {
3
23
  "version": "0.330.0",
4
24
  "type": "improvement",