@cosmicdrift/kumiko-framework 0.330.2 → 0.332.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/db/blind-index-cleanup.d.ts +1 -0
  2. package/dist/db/blind-index-cleanup.js +12 -0
  3. package/dist/db/index.d.ts +1 -1
  4. package/dist/db/index.js +1 -1
  5. package/dist/db/queries/event-consumer.d.ts +2 -1
  6. package/dist/db/queries/event-consumer.js +12 -2
  7. package/dist/engine/boot-validator/action-wiring.js +3 -0
  8. package/dist/engine/boot-validator/config-deps.d.ts +1 -0
  9. package/dist/engine/boot-validator/config-deps.js +24 -0
  10. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  11. package/dist/engine/boot-validator/entity-handler.js +20 -0
  12. package/dist/engine/boot-validator/index.js +6 -2
  13. package/dist/engine/boot-validator/projection-list-screens.js +11 -6
  14. package/dist/engine/boot-validator/query-output-columns.js +23 -2
  15. package/dist/engine/boot-validator/query-refs.d.ts +2 -1
  16. package/dist/engine/boot-validator/query-refs.js +24 -1
  17. package/dist/engine/boot-validator/screens.d.ts +3 -1
  18. package/dist/engine/boot-validator/screens.js +180 -78
  19. package/dist/engine/boot-validator/selector-owner-panels.d.ts +2 -0
  20. package/dist/engine/boot-validator/selector-owner-panels.js +25 -0
  21. package/dist/engine/boot-validator/url-prefill-fields.js +18 -1
  22. package/dist/engine/build-app-schema.js +3 -0
  23. package/dist/engine/build-config-feature-schema.js +40 -6
  24. package/dist/engine/changeset-changes.js +6 -0
  25. package/dist/engine/config-helpers.d.ts +2 -0
  26. package/dist/engine/config-helpers.js +9 -0
  27. package/dist/engine/entity-handlers.js +1 -1
  28. package/dist/engine/feature-ui-extensions.d.ts +4 -2
  29. package/dist/engine/feature-ui-extensions.js +40 -3
  30. package/dist/engine/index.d.ts +1 -1
  31. package/dist/engine/project-app-schema-for-roles.js +30 -2
  32. package/dist/engine/registry-facade.js +3 -0
  33. package/dist/engine/registry-ingest.js +2 -0
  34. package/dist/engine/registry-state.d.ts +2 -1
  35. package/dist/engine/registry-state.js +1 -0
  36. package/dist/engine/schema-builder.d.ts +3 -1
  37. package/dist/engine/schema-builder.js +5 -6
  38. package/dist/engine/types/index.d.ts +2 -2
  39. package/dist/errors/reasons.js +7 -6
  40. package/dist/i18n/required-surface-keys.js +27 -10
  41. package/dist/pipeline/dispatch-write.js +26 -2
  42. package/dist/pipeline/event-consumer-state.js +6 -0
  43. package/dist/pipeline/event-dispatcher-admin.js +36 -16
  44. package/dist/pipeline/event-dispatcher-delivery.d.ts +2 -0
  45. package/dist/pipeline/event-dispatcher-delivery.js +22 -2
  46. package/dist/pipeline/event-dispatcher.js +1 -0
  47. package/dist/ui-types/index.d.ts +1 -1
  48. package/package.json +4 -4
  49. package/src/changes.json +54 -0
@@ -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
@@ -831,6 +942,37 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
831
942
  }
832
943
  }
833
944
  }
945
+ if (Array.isArray(screen.header?.subtitle)) {
946
+ if (screen.header.subtitleHref !== undefined) {
947
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) sets header.subtitleHref ` +
948
+ `together with a multi-part header.subtitle — subtitleHref only works with the string form.`);
949
+ }
950
+ const seenPartFields = new Set();
951
+ for (const part of screen.header.subtitle) {
952
+ const partField = typeof part === "string" ? part : part.field;
953
+ if (seenPartFields.has(partField)) {
954
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) header.subtitle ` +
955
+ `lists field "${partField}" twice.`);
956
+ }
957
+ seenPartFields.add(partField);
958
+ const navigate = typeof part === "string" ? undefined : part.navigate;
959
+ if (navigate === undefined)
960
+ continue;
961
+ if (navigate.screen === undefined && navigate.entity === undefined) {
962
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) header.subtitle part ` +
963
+ `"${partField}" navigates with neither "screen" nor "entity" — a tab-only navigate has no link target.`);
964
+ }
965
+ resolveRowActionNavigateTarget(feature.name, screenId, "projectionDetail", "subtitle part", {
966
+ kind: "navigate",
967
+ id: partField,
968
+ label: partField,
969
+ ...(navigate.screen !== undefined ? { screen: navigate.screen } : {}),
970
+ ...(navigate.entity !== undefined ? { entity: navigate.entity } : {}),
971
+ ...(navigate.entityId !== undefined ? { entityId: navigate.entityId } : {}),
972
+ ...(navigate.tab !== undefined ? { tab: navigate.tab } : {}),
973
+ }, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
974
+ }
975
+ }
834
976
  for (const section of screen.layout.sections) {
835
977
  const sectionLabel = section.title ?? section.id ?? "(untitled)";
836
978
  if (section.actions !== undefined) {
@@ -876,87 +1018,25 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
876
1018
  continue;
877
1019
  }
878
1020
  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
1021
  if (screen.layout.mode === "wizard") {
890
1022
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
891
1023
  `is kind "relatedList" in a wizard layout — a read-only list is not supported in a ` +
892
1024
  `stepped form. Remove mode: "wizard" or drop the relatedList section.`);
893
1025
  }
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
- }
1026
+ validateRelatedListSection({
1027
+ featureName: feature.name,
1028
+ featureMap,
1029
+ screenId,
1030
+ screenType: "projectionDetail",
1031
+ where: `Screen "${screenId}" (projectionDetail) section "${section.title}" (relatedList)`,
1032
+ section,
1033
+ screens: feature.screens,
1034
+ allWriteHandlerQns,
1035
+ allScreenQns,
1036
+ navTargetShortIds,
1037
+ screensByShortId,
1038
+ detailForScreens,
1039
+ });
960
1040
  continue;
961
1041
  }
962
1042
  if (isWriteFormEditSection(section)) {
@@ -1048,6 +1128,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1048
1128
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) field "${fname}" has no ` +
1049
1129
  `\`type\` set. Each field must declare a type (e.g. "text", "number", "select").`);
1050
1130
  }
1131
+ validateFormSelectOptions(feature.name, screenId, "configEdit", fname, fdef);
1051
1132
  }
1052
1133
  if (screen.layout.sections.length === 0) {
1053
1134
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) has an empty sections list — ` +
@@ -1340,6 +1421,27 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1340
1421
  validateActionFieldRefs(feature.name, screenId, "toolbarAction", action.id, action, fieldNames, rowMeta);
1341
1422
  }
1342
1423
  }
1424
+ if (screen.expandableRow !== undefined) {
1425
+ const expandableRow = screen.expandableRow;
1426
+ validateRelatedListSection({
1427
+ featureName: feature.name,
1428
+ featureMap,
1429
+ screenId,
1430
+ screenType: "entityList expandableRow",
1431
+ where: `Screen "${screenId}" (entityList) expandableRow "${expandableRow.title}"`,
1432
+ section: expandableRow,
1433
+ screens: feature.screens,
1434
+ allWriteHandlerQns,
1435
+ allScreenQns,
1436
+ navTargetShortIds,
1437
+ screensByShortId,
1438
+ detailForScreens,
1439
+ });
1440
+ // Toolbar actions of the expansion evaluate against the host row.
1441
+ for (const action of expandableRow.toolbarActions ?? []) {
1442
+ validateActionFieldRefs(feature.name, screenId, "toolbarAction", action.id, action, fieldNames, rowMeta);
1443
+ }
1444
+ }
1343
1445
  }
1344
1446
  else {
1345
1447
  // Same rationale as the columns check: an entityEdit layout with zero
@@ -1491,7 +1593,7 @@ const EMBEDDABLE_SCREEN_TYPES = new Set([
1491
1593
  "configEdit",
1492
1594
  "secretsEdit",
1493
1595
  ]);
1494
- function validateDashboardScreenPanel(featureName, screenId, panel, featureMap) {
1596
+ export function validateDashboardScreenPanel(featureName, screenId, panel, featureMap) {
1495
1597
  const context = `[Feature ${featureName}] Screen "${screenId}" (dashboard) screen-panel "${panel.id}"`;
1496
1598
  const targetQn = resolveScreenTargetQn(featureName, panel.screen);
1497
1599
  const [targetFeatureName = "", , targetShortId = ""] = targetQn.split(":");
@@ -1523,7 +1625,7 @@ function validateDashboardStatGroupPanel(featureName, screenId, panel, addPanelI
1523
1625
  }
1524
1626
  }
1525
1627
  }
1526
- function validateDashboardCustomPanel(featureName, screenId, panel) {
1628
+ export function validateDashboardCustomPanel(featureName, screenId, panel) {
1527
1629
  if (panel.component.react === undefined && panel.component.native === undefined) {
1528
1630
  throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) custom-panel "${panel.id}" has no component — ` +
1529
1631
  `declare a react/native component marker.`);
@@ -0,0 +1,2 @@
1
+ import type { FeatureDefinition } from "../types/index.js";
2
+ export declare function validateExtensionSelectorPanels(features: readonly FeatureDefinition[]): void;
@@ -0,0 +1,25 @@
1
+ import { buildQueryHandlerMap } from "./projection-list-screens.js";
2
+ import { checkQueryRef } from "./query-refs.js";
3
+ import { validateDashboardCustomPanel, validateDashboardScreenPanel } from "./screens.js";
4
+ // Owner panels are merged into a dashboard that is built after boot, so the screen and
5
+ // query refs are checked here against the registered features instead.
6
+ export function validateExtensionSelectorPanels(features) {
7
+ const featureMap = new Map(features.map((feature) => [feature.name, feature]));
8
+ const queryHandlers = buildQueryHandlerMap(features);
9
+ for (const feature of features) {
10
+ for (const selector of feature.extensionSelectors ?? []) {
11
+ const where = `extensionSelector("${selector.extensionName}")`;
12
+ for (const panel of selector.panels ?? []) {
13
+ const context = `[Feature ${feature.name}] ${where} panel "${panel.id}"`;
14
+ if (panel.kind === "custom") {
15
+ validateDashboardCustomPanel(feature.name, where, panel);
16
+ continue;
17
+ }
18
+ validateDashboardScreenPanel(feature.name, where, panel, featureMap);
19
+ if (panel.visibleWhen !== undefined) {
20
+ checkQueryRef(queryHandlers, panel.visibleWhen.query, () => `${context} visibleWhen`);
21
+ }
22
+ }
23
+ }
24
+ }
25
+ }
@@ -11,6 +11,12 @@ function scopeActions(actions, sourceScreenEntity, implicitParams) {
11
11
  function relatedListParentParamField(section) {
12
12
  return section.parentFilter?.field ?? section.parentParam ?? "id";
13
13
  }
14
+ function subtitlePartNavigates(screen) {
15
+ const subtitle = screen.header?.subtitle;
16
+ if (typeof subtitle === "string" || subtitle === undefined)
17
+ return [];
18
+ return subtitle.flatMap((part) => typeof part === "string" || part.navigate === undefined ? [] : [part.navigate]);
19
+ }
14
20
  function projectionDetailNavigateActions(screen) {
15
21
  return [
16
22
  ...scopeActions(screen.actions, screen.detailFor),
@@ -23,12 +29,23 @@ function projectionDetailNavigateActions(screen) {
23
29
  ...(screen.metrics ?? []).flatMap((metric) => typeof metric === "string" || metric.navigate === undefined
24
30
  ? []
25
31
  : scopeActions([metric.navigate], undefined)),
32
+ ...subtitlePartNavigates(screen).flatMap((navigate) => scopeActions([navigate], undefined)),
26
33
  ];
27
34
  }
28
35
  function navigateActionsOf(screen) {
29
36
  switch (screen.type) {
30
37
  case "entityList":
31
- return scopeActions(screen.rowActions, screen.entity);
38
+ return [
39
+ ...scopeActions(screen.rowActions, screen.entity),
40
+ ...(screen.expandableRow === undefined
41
+ ? []
42
+ : [
43
+ ...scopeActions(screen.expandableRow.rowActions, undefined),
44
+ ...scopeActions(screen.expandableRow.toolbarActions, undefined, {
45
+ pick: [relatedListParentParamField(screen.expandableRow)],
46
+ }),
47
+ ]),
48
+ ];
32
49
  case "projectionList":
33
50
  return scopeActions(screen.rowActions, undefined);
34
51
  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,
@@ -56,6 +56,7 @@ const ELEVATED_ROLES = {
56
56
  const MACHINE_WRITE_ROLE = "system";
57
57
  const NO_SELECTOR_GATING = {
58
58
  pluginsByOwnerGroup: new Map(),
59
+ ownerPanelsByGroup: new Map(),
59
60
  pluginFeatures: new Set(),
60
61
  secretQns: new Set(),
61
62
  };
@@ -93,6 +94,16 @@ function collectVisibleSelectorOwners(registry, visible) {
93
94
  }
94
95
  return { ownerGroupByExtension, selectorFeatures };
95
96
  }
97
+ function collectOwnerPanelsByGroup(registry, ownerGroupByExtension) {
98
+ const panelsByGroup = new Map();
99
+ for (const [extension, ownerGroup] of [...ownerGroupByExtension].sort(([a], [b]) => a.localeCompare(b))) {
100
+ const panels = registry.getExtensionSelectorPanels(extension);
101
+ if (panels.length === 0)
102
+ continue;
103
+ panelsByGroup.set(ownerGroup, [...(panelsByGroup.get(ownerGroup) ?? []), ...panels]);
104
+ }
105
+ return panelsByGroup;
106
+ }
96
107
  function countSelectorUsagesByFeature(registry) {
97
108
  const usageCountByFeature = new Map();
98
109
  for (const [extensionName] of registry.getAllExtensionSelectors()) {
@@ -112,7 +123,9 @@ function planSelectorGating(registry, visible, declaredSecrets, secretsEnabled)
112
123
  if (ownerGroupByExtension.size === 0)
113
124
  return NO_SELECTOR_GATING;
114
125
  const usageCountByFeature = countSelectorUsagesByFeature(registry);
115
- const pluginsByOwnerGroup = new Map();
126
+ const ownerPanelsByGroup = collectOwnerPanelsByGroup(registry, ownerGroupByExtension);
127
+ // A selector with panels but no gated plugin still becomes a dashboard.
128
+ const pluginsByOwnerGroup = new Map([...ownerPanelsByGroup.keys()].map((ownerGroup) => [ownerGroup, []]));
116
129
  const pluginFeatures = new Set();
117
130
  const secretQns = new Set();
118
131
  for (const [extension, ownerGroup] of [...ownerGroupByExtension].sort(([a], [b]) => a.localeCompare(b))) {
@@ -135,7 +148,7 @@ function planSelectorGating(registry, visible, declaredSecrets, secretsEnabled)
135
148
  secretQns.add(s.qn);
136
149
  }
137
150
  }
138
- return { pluginsByOwnerGroup, pluginFeatures, secretQns };
151
+ return { pluginsByOwnerGroup, ownerPanelsByGroup, pluginFeatures, secretQns };
139
152
  }
140
153
  // A tenant/user-home key with an elevated write role (SystemAdmin on a
141
154
  // tenant key, see ELEVATED_ROLES) surfaces the SAME feature under two
@@ -173,7 +186,7 @@ function buildFeatureScreensAndNavs(scope, visible, hub, gating, secretsAccess)
173
186
  out.navs.push(buildFeatureNav(scope, feature, ordered, access, hub.declaredTranslationKeys));
174
187
  continue;
175
188
  }
176
- const ownerHub = buildSelectorOwnerDashboard(feature, ordered, access, plugins, hub, secretsAccess);
189
+ const ownerHub = buildSelectorOwnerDashboard(feature, ordered, access, plugins, gating.ownerPanelsByGroup.get(feature) ?? [], hub, secretsAccess);
177
190
  out.screens.push(...ownerHub.screens);
178
191
  out.navs.push(buildFeatureNav(scope, feature, ordered, ownerHub.access, hub.declaredTranslationKeys));
179
192
  Object.assign(out.translations, ownerHub.translations);
@@ -183,9 +196,20 @@ function buildFeatureScreensAndNavs(scope, visible, hub, gating, secretsAccess)
183
196
  function pluginTenantScreenId(plugin) {
184
197
  return `${plugin.feature}-tenant`;
185
198
  }
199
+ function assertUniquePanelIds(ownerGroup, panels) {
200
+ const seen = new Set();
201
+ for (const panel of panels) {
202
+ if (seen.has(panel.id)) {
203
+ throw new Error(`Settings dashboard "${ownerGroup}-tenant" has duplicate panel id "${panel.id}" — an ` +
204
+ `extensionSelector panel id collides with another panel of the same dashboard.`);
205
+ }
206
+ seen.add(panel.id);
207
+ }
208
+ return panels;
209
+ }
186
210
  // Plugin screens keep their `${F}-tenant` ids so consumer title keys stay valid;
187
211
  // they are dormant because only this dashboard embeds them.
188
- function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins, hub, secretsAccess) {
212
+ function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins, ownerPanels, hub, secretsAccess) {
189
213
  const selectionScreenId = `${ownerGroup}-tenant-selection`;
190
214
  const selectionScreen = {
191
215
  ...buildScreen(selectionScreenId, "tenant", ownerGroup, ownerKeys, ownerAccess, hub),
@@ -240,11 +264,12 @@ function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins
240
264
  id: `${ownerGroup}-tenant`,
241
265
  type: "dashboard",
242
266
  description: EXTENSION_SELECTOR_HINT_KEY,
243
- panels: [
267
+ panels: assertUniquePanelIds(ownerGroup, [
244
268
  { kind: "screen", id: "selection", screen: selectionScreenId },
269
+ ...ownerPanels,
245
270
  ...configPanels,
246
271
  ...secretsPanels,
247
- ],
272
+ ]),
248
273
  access,
249
274
  };
250
275
  return { screens: [dashboard, ...screens], access, translations };
@@ -504,6 +529,15 @@ function deriveField(def, selectorPluginIds) {
504
529
  case "boolean":
505
530
  return createBooleanField();
506
531
  case "select":
532
+ if (def.optionsQuery !== undefined) {
533
+ return createSelectField({
534
+ options: [],
535
+ optionsQuery: def.optionsQuery,
536
+ ...(def.optionsQueryPayload !== undefined && {
537
+ optionsQueryPayload: def.optionsQueryPayload,
538
+ }),
539
+ });
540
+ }
507
541
  return def.options !== undefined && def.options.length > 0
508
542
  ? createSelectField({ options: def.options })
509
543
  : createTextField({ personal: false, reason: "system_metadata" });
@@ -67,6 +67,12 @@ export function parseChangesetChanges(markdown, source) {
67
67
  let match = BLOCK_RE.exec(markdown);
68
68
  while (match !== null) {
69
69
  const block = match[1];
70
+ // BLOCK_RE closes at the first line starting with "-->"; text after it on
71
+ // that line means a field value contained one and the block ended early.
72
+ const restOfLine = markdown.slice(match.index + match[0].length).split("\n", 1)[0] ?? "";
73
+ if (restOfLine.trim() !== "") {
74
+ throw new Error(`${source}: kumiko-changes block closed early by a line starting with "-->"; field values must not contain such a line`);
75
+ }
70
76
  if (!block)
71
77
  throw new Error(`${source}: empty kumiko-changes block`);
72
78
  const fields = parseBlock(block, source);
@@ -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,
@@ -224,7 +224,7 @@ export function defineEntityWriteHandler(name, entity, options) {
224
224
  let handler;
225
225
  switch (verb) {
226
226
  case "create":
227
- schema = buildInsertSchema(entity, undefined, excludedFields);
227
+ schema = buildInsertSchema(entity, undefined, excludedFields, { allowCallerId: true });
228
228
  handler = async (event, ctx) => {
229
229
  const { runPreSave } = ctx;
230
230
  // A caller-chosen id (deterministic-id idempotent creates) is only
@@ -2,7 +2,7 @@ import { type WebSocketRouteDefinition } from "@cosmicdrift/kumiko-types/websock
2
2
  import type { EntityTableMeta } from "../db/entity-table-meta.js";
3
3
  import type { FeatureBuilderState } from "./feature-builder-state.js";
4
4
  import type { HttpRouteDefinition } from "./types/http-route.js";
5
- import type { BootCheckFn, EntityProjectionExtension, EscapeHatchDeclaration, HookPhase, LifecycleHookFn, LifecycleHookType, MultiStreamProjectionDefinition, NameOrRef, ProjectionDefinition, RegistrarExtensionDef, SearchPayloadContributorFn, StoreTableOptions, TreeActionDef, TreeActionsHandle, ValidationHookFn } from "./types/index.js";
5
+ import type { BootCheckFn, EntityProjectionExtension, EscapeHatchDeclaration, ExtensionSelectorPanel, HookPhase, LifecycleHookFn, LifecycleHookType, MultiStreamProjectionDefinition, NameOrRef, ProjectionDefinition, RegistrarExtensionDef, SearchPayloadContributorFn, StoreTableOptions, TreeActionDef, TreeActionsHandle, ValidationHookFn } from "./types/index.js";
6
6
  import type { ContentCollectionDefinition, NavDefinition } from "./types/nav.js";
7
7
  import type { ScreenDefinition } from "./types/screen.js";
8
8
  import type { WorkspaceDefinition } from "./types/workspace.js";
@@ -21,7 +21,9 @@ export declare function buildUiExtensionsMethods<TName extends string>(state: Fe
21
21
  } & object), entityRef?: NameOrRef, options?: object): void;
22
22
  extensionSelector(extensionName: string, key: {
23
23
  readonly name: string;
24
- } | string): void;
24
+ } | string, options?: {
25
+ readonly panels?: readonly ExtensionSelectorPanel[];
26
+ }): void;
25
27
  /**
26
28
  * Marker-Deklaration: dieses Feature stellt eine Cross-Feature-API
27
29
  * unter dem genannten Namen bereit. Die eigentliche Implementation
@@ -2,8 +2,39 @@ import { WEBSOCKET_MAX_CONNECTIONS_PER_USER_LIMIT, WEBSOCKET_MAX_PAYLOAD_BYTES,
2
2
  import { bindHookEscapeHatchGrant } from "../pipeline/system-identity-switch.js";
3
3
  import { LifecycleHookTypes } from "./constants.js";
4
4
  import { resolveName } from "./handler-helpers.js";
5
- import { isKebabSegment, toKebab } from "./qualified-name.js";
5
+ import { isKebabSegment, isValidQn, qualifyEntityName, toKebab } from "./qualified-name.js";
6
6
  import { HookPhases } from "./types/index.js";
7
+ // Builds hooks/extensions/projections/screens/nav/workspace/tables/tree-actions
8
+ // registrar methods.
9
+ // Id the generated owner dashboard reserves for its selector panel.
10
+ const RESERVED_SELECTION_PANEL_ID = "selection";
11
+ function assertSelectorPanelIds(featureName, extensionName, panels) {
12
+ const seen = new Set();
13
+ for (const { id } of panels) {
14
+ if (id === "" || id === RESERVED_SELECTION_PANEL_ID || seen.has(id)) {
15
+ throw new Error(`[Feature ${featureName}] extensionSelector("${extensionName}") panel id "${id}" must be unique, ` +
16
+ `non-empty and not "${RESERVED_SELECTION_PANEL_ID}" (reserved for the selector panel).`);
17
+ }
18
+ seen.add(id);
19
+ }
20
+ }
21
+ // The owner dashboard lives in the "config" namespace, so a short screen ref must be
22
+ // qualified against the declaring feature here. Untyped authors can pass other kinds.
23
+ function qualifySelectorPanel(featureName, panel) {
24
+ if (panel.kind === "custom")
25
+ return panel;
26
+ if (panel.kind === "screen") {
27
+ if ("label" in panel) {
28
+ throw new Error(`[Feature ${featureName}] extensionSelector screen panel "${panel.id}" must not set a label — ` +
29
+ `the settings dashboard has no i18n namespace for it; the embedded screen carries its own title.`);
30
+ }
31
+ if (panel.screen === "" || isValidQn(panel.screen))
32
+ return panel;
33
+ return { ...panel, screen: qualifyEntityName(featureName, "screen", panel.screen) };
34
+ }
35
+ throw new Error(`[Feature ${featureName}] extensionSelector panel ${JSON.stringify(panel)} has an unsupported kind — ` +
36
+ `only "custom" and "screen" panels are allowed.`);
37
+ }
7
38
  function isEntityWideHookType(type) {
8
39
  return (type === LifecycleHookTypes.postSave ||
9
40
  type === LifecycleHookTypes.preDelete ||
@@ -184,13 +215,19 @@ export function buildUiExtensionsMethods(state, name) {
184
215
  options: optionsBag,
185
216
  });
186
217
  },
187
- extensionSelector(extensionName, key) {
218
+ extensionSelector(extensionName, key, options) {
188
219
  if (state.extensionSelectors.some((s) => s.extensionName === extensionName)) {
189
220
  throw new Error(`[Feature ${name}] extensionSelector("${extensionName}") declared twice — ` +
190
221
  `one selector key per extension point.`);
191
222
  }
192
223
  const qualifiedKey = typeof key === "string" ? key : key.name;
193
- state.extensionSelectors.push({ extensionName, qualifiedKey });
224
+ const panels = options?.panels?.map((panel) => qualifySelectorPanel(name, panel));
225
+ assertSelectorPanelIds(name, extensionName, panels ?? []);
226
+ state.extensionSelectors.push({
227
+ extensionName,
228
+ qualifiedKey,
229
+ ...(panels !== undefined && { panels }),
230
+ });
194
231
  },
195
232
  /**
196
233
  * Marker-Deklaration: dieses Feature stellt eine Cross-Feature-API