@cosmicdrift/kumiko-framework 0.352.0 → 0.354.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 (188) hide show
  1. package/dist/api/index.d.ts +1 -1
  2. package/dist/api/index.js +1 -1
  3. package/dist/api/pii-leak-guard.js +3 -0
  4. package/dist/api/redis-sse-broker.d.ts +1 -0
  5. package/dist/api/redis-sse-broker.js +16 -10
  6. package/dist/api/request-context.d.ts +1 -0
  7. package/dist/api/request-context.js +5 -0
  8. package/dist/api/route-registrars.d.ts +2 -1
  9. package/dist/api/route-registrars.js +8 -0
  10. package/dist/api/routes.js +4 -2
  11. package/dist/api/server.js +39 -6
  12. package/dist/api/sse-broker.d.ts +7 -3
  13. package/dist/api/sse-route.js +7 -1
  14. package/dist/api/token-expiry-timer.d.ts +1 -0
  15. package/dist/api/token-expiry-timer.js +10 -0
  16. package/dist/bun-db/query.js +36 -4
  17. package/dist/compliance/sub-processors.js +5 -0
  18. package/dist/crypto/event-pii.js +4 -2
  19. package/dist/crypto/kms-wiring.js +31 -5
  20. package/dist/crypto/pii-field-encryption.d.ts +2 -3
  21. package/dist/crypto/pii-field-encryption.js +2 -3
  22. package/dist/db/eagerload.d.ts +8 -3
  23. package/dist/db/eagerload.js +85 -81
  24. package/dist/db/entity-table-meta.js +11 -0
  25. package/dist/db/event-store-executor-context.d.ts +12 -0
  26. package/dist/db/event-store-executor-context.js +78 -42
  27. package/dist/db/event-store-executor-read.js +67 -25
  28. package/dist/db/event-store-executor-write.js +54 -3
  29. package/dist/db/index.d.ts +1 -1
  30. package/dist/db/migrate-generator.js +10 -2
  31. package/dist/db/queries/backfill-pii.d.ts +2 -0
  32. package/dist/db/queries/backfill-pii.js +24 -6
  33. package/dist/db/queries/event-consumer.d.ts +2 -3
  34. package/dist/db/queries/event-consumer.js +54 -43
  35. package/dist/db/queries/event-store.d.ts +4 -0
  36. package/dist/db/queries/event-store.js +40 -3
  37. package/dist/db/queries/pii-backfill-state.d.ts +8 -1
  38. package/dist/db/queries/pii-backfill-state.js +16 -5
  39. package/dist/db/queries/shadow-swap.js +37 -5
  40. package/dist/db/render-ddl.js +6 -6
  41. package/dist/db/table-builder.js +6 -7
  42. package/dist/db/tenant-db.d.ts +5 -2
  43. package/dist/db/tenant-db.js +47 -7
  44. package/dist/derivatives/variant-key.d.ts +1 -0
  45. package/dist/derivatives/variant-key.js +3 -0
  46. package/dist/engine/boot-validator/access-declarations.js +21 -1
  47. package/dist/engine/boot-validator/config-deps.js +3 -0
  48. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  49. package/dist/engine/boot-validator/entity-handler.js +53 -0
  50. package/dist/engine/boot-validator/handler-field-references.d.ts +2 -0
  51. package/dist/engine/boot-validator/handler-field-references.js +25 -0
  52. package/dist/engine/boot-validator/index.js +6 -1
  53. package/dist/engine/boot-validator/nav.js +3 -1
  54. package/dist/engine/boot-validator/ownership.d.ts +2 -1
  55. package/dist/engine/boot-validator/ownership.js +32 -1
  56. package/dist/engine/boot-validator/pii-retention.js +8 -1
  57. package/dist/engine/boot-validator/query-output-columns.js +9 -9
  58. package/dist/engine/boot-validator/screens.js +136 -15
  59. package/dist/engine/boot-validator/select-option-tone.d.ts +2 -0
  60. package/dist/engine/boot-validator/select-option-tone.js +4 -0
  61. package/dist/engine/boot-validator/transfer-graph.d.ts +2 -1
  62. package/dist/engine/boot-validator/transfer-graph.js +8 -63
  63. package/dist/engine/boot-validator/url-prefill-fields.d.ts +1 -0
  64. package/dist/engine/boot-validator/url-prefill-fields.js +35 -0
  65. package/dist/engine/build-app-schema.js +33 -2
  66. package/dist/engine/build-config-feature-schema.js +6 -0
  67. package/dist/engine/changeset-changes.d.ts +1 -0
  68. package/dist/engine/changeset-changes.js +49 -33
  69. package/dist/engine/dedupe-features.js +17 -1
  70. package/dist/engine/define-feature.js +11 -0
  71. package/dist/engine/entity-handlers.d.ts +3 -3
  72. package/dist/engine/entity-handlers.js +70 -40
  73. package/dist/engine/escape-hatch-reason.d.ts +2 -0
  74. package/dist/engine/escape-hatch-reason.js +8 -0
  75. package/dist/engine/extension-names.d.ts +8 -9
  76. package/dist/engine/extension-names.js +8 -9
  77. package/dist/engine/extensions/tenant-data.d.ts +3 -1
  78. package/dist/engine/extensions/tenant-resource.d.ts +9 -1
  79. package/dist/engine/factories.d.ts +1 -1
  80. package/dist/engine/feature-builder-state.d.ts +2 -7
  81. package/dist/engine/feature-changelog.d.ts +2 -0
  82. package/dist/engine/feature-changelog.js +6 -0
  83. package/dist/engine/feature-config-events-jobs.js +44 -4
  84. package/dist/engine/feature-entity-handlers.js +1 -1
  85. package/dist/engine/handler-field-references.d.ts +3 -0
  86. package/dist/engine/handler-field-references.js +31 -0
  87. package/dist/engine/i18n-key.js +13 -3
  88. package/dist/engine/index.d.ts +9 -3
  89. package/dist/engine/index.js +7 -2
  90. package/dist/engine/ownership.js +3 -4
  91. package/dist/engine/pipeline.js +18 -1
  92. package/dist/engine/project-app-schema-for-roles.js +11 -3
  93. package/dist/engine/registry-ingest.d.ts +1 -0
  94. package/dist/engine/registry-ingest.js +8 -3
  95. package/dist/engine/role-assignment.d.ts +2 -0
  96. package/dist/engine/role-assignment.js +2 -0
  97. package/dist/engine/run-pipeline.js +12 -1
  98. package/dist/engine/schema-builder.js +8 -0
  99. package/dist/engine/screen-helpers.d.ts +13 -0
  100. package/dist/engine/screen-helpers.js +31 -0
  101. package/dist/engine/soft-delete-cleanup.js +3 -2
  102. package/dist/engine/steps/_read-source.d.ts +4 -0
  103. package/dist/engine/steps/_read-source.js +22 -0
  104. package/dist/engine/steps/describe-workflow-step-error.js +3 -1
  105. package/dist/engine/steps/read-find-many.js +2 -1
  106. package/dist/engine/steps/read-find-one.js +2 -1
  107. package/dist/engine/steps/workflow-step-error.d.ts +5 -0
  108. package/dist/engine/steps/workflow-step-error.js +13 -0
  109. package/dist/engine/transfer-adjacency.d.ts +24 -0
  110. package/dist/engine/transfer-adjacency.js +107 -0
  111. package/dist/engine/where-rule-lint.js +6 -3
  112. package/dist/entrypoint/index.d.ts +2 -0
  113. package/dist/entrypoint/index.js +2 -0
  114. package/dist/env/index.d.ts +4 -0
  115. package/dist/env/index.js +49 -23
  116. package/dist/errors/classes.d.ts +8 -0
  117. package/dist/errors/classes.js +13 -0
  118. package/dist/errors/index.d.ts +2 -1
  119. package/dist/errors/index.js +2 -1
  120. package/dist/errors/reasons.d.ts +1 -0
  121. package/dist/errors/reasons.js +3 -0
  122. package/dist/errors/subject-erased.d.ts +2 -0
  123. package/dist/errors/subject-erased.js +9 -0
  124. package/dist/es-ops/context.js +19 -1
  125. package/dist/event-store/admin-api.js +14 -10
  126. package/dist/event-store/event-store.js +11 -9
  127. package/dist/event-store/events-schema.js +2 -1
  128. package/dist/files/file-handle.d.ts +1 -1
  129. package/dist/files/file-handle.js +30 -6
  130. package/dist/files/file-ref-entity.d.ts +6 -0
  131. package/dist/files/file-ref-table.d.ts +6 -0
  132. package/dist/files/file-routes.js +13 -7
  133. package/dist/files/resolve-content-type.d.ts +2 -0
  134. package/dist/files/resolve-content-type.js +8 -0
  135. package/dist/files/types.js +21 -1
  136. package/dist/i18n/required-surface-keys.d.ts +2 -0
  137. package/dist/i18n/required-surface-keys.js +8 -0
  138. package/dist/jobs/index.d.ts +1 -1
  139. package/dist/jobs/job-runner.d.ts +13 -0
  140. package/dist/jobs/job-runner.js +128 -22
  141. package/dist/migrations/pii-event-backfill.js +46 -25
  142. package/dist/observability/index.d.ts +1 -1
  143. package/dist/observability/index.js +1 -1
  144. package/dist/observability/metrics-wiring.d.ts +1 -1
  145. package/dist/observability/metrics-wiring.js +2 -2
  146. package/dist/observability/standard-metrics.d.ts +4 -0
  147. package/dist/observability/standard-metrics.js +12 -0
  148. package/dist/pipeline/dispatch-shared.d.ts +2 -0
  149. package/dist/pipeline/dispatch-shared.js +74 -9
  150. package/dist/pipeline/dispatch-stream.d.ts +5 -1
  151. package/dist/pipeline/dispatch-stream.js +19 -12
  152. package/dist/pipeline/dispatcher.d.ts +3 -1
  153. package/dist/pipeline/dispatcher.js +10 -15
  154. package/dist/pipeline/event-consumer-state.js +0 -26
  155. package/dist/pipeline/event-dispatcher-admin.js +17 -11
  156. package/dist/pipeline/event-dispatcher-delivery.d.ts +1 -1
  157. package/dist/pipeline/event-dispatcher-delivery.js +3 -2
  158. package/dist/pipeline/event-dispatcher.js +48 -7
  159. package/dist/pipeline/index.d.ts +2 -0
  160. package/dist/pipeline/index.js +1 -0
  161. package/dist/pipeline/member-read-only-transaction.js +25 -5
  162. package/dist/pipeline/member-reader.js +1 -0
  163. package/dist/pipeline/msp-rebuild.js +3 -3
  164. package/dist/pipeline/pending-gap-ranges.d.ts +1 -0
  165. package/dist/pipeline/pending-gap-ranges.js +22 -0
  166. package/dist/pipeline/system-hooks.js +4 -4
  167. package/dist/pipeline/system-identity-switch.d.ts +1 -1
  168. package/dist/pipeline/system-identity-switch.js +5 -4
  169. package/dist/pipeline/tenant-timezone-cache.js +5 -3
  170. package/dist/redis/cache-sync-topics.d.ts +1 -0
  171. package/dist/redis/cache-sync-topics.js +1 -0
  172. package/dist/redis/client.d.ts +1 -0
  173. package/dist/redis/client.js +7 -0
  174. package/dist/redis/index.d.ts +1 -1
  175. package/dist/redis/index.js +1 -1
  176. package/dist/redis/pubsub-signal.d.ts +1 -0
  177. package/dist/redis/pubsub-signal.js +3 -0
  178. package/dist/search/meilisearch-adapter.js +34 -4
  179. package/dist/stack/test-stack.d.ts +4 -1
  180. package/dist/stack/test-stack.js +9 -2
  181. package/dist/testing/e2e-generator.js +7 -2
  182. package/dist/testing/shared-entities.d.ts +10 -0
  183. package/dist/ui-types/index.d.ts +3 -3
  184. package/dist/ui-types/index.js +2 -2
  185. package/dist/upgrade-cli.js +7 -2
  186. package/package.json +12 -4
  187. package/src/changes.json +228 -0
  188. package/src/scripts/codemod/migrate-cross-tenant.ts +2 -1
@@ -4,7 +4,7 @@
4
4
  // into validateColumnRendererForm, splitting them would create a
5
5
  // same-folder require cycle.
6
6
  import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
7
- import { NO_WIDGET_FIELD_TYPES, optionsQueryFieldRefs } from "@cosmicdrift/kumiko-types/fields";
7
+ import { NO_WIDGET_FIELD_TYPES, optionsQueryFieldRefs, SELECT_OPTION_TONES, } from "@cosmicdrift/kumiko-types/fields";
8
8
  import { NAV_ICON_KEYS } from "@cosmicdrift/kumiko-types/nav-icon";
9
9
  import { rowMetaFieldNames } from "../../db/table-builder.js";
10
10
  import { REFERENCE_LOOKUP_SOURCES } from "../../ui-types/list-row-meta.js";
@@ -12,9 +12,10 @@ import { isEncryptedAtRest } from "../config-helpers.js";
12
12
  import { parseRefTarget } from "../parse-ref-target.js";
13
13
  import { isKebabSegment, isValidQn, qualifyEntityName } from "../qualified-name.js";
14
14
  import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops.js";
15
- import { isExtensionEditSection, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../screen-helpers.js";
15
+ import { explicitListScreenId, isExtensionEditSection, isFieldsEditSection, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../screen-helpers.js";
16
16
  import { metricField } from "../types/index.js";
17
17
  import { LIST_ROW_META_COLUMN_NAMES } from "./entity-list-screens.js";
18
+ import { isSelectOptionTone } from "./select-option-tone.js";
18
19
  // entityList and projectionList both allow a rowAction to double as the
19
20
  // row-body click target (rowClick: true, fw#1708/#2164) — at most one per
20
21
  // screen, or the renderer can't tell which one should fire.
@@ -126,6 +127,11 @@ function validateRowActionNavigateParams(featureName, screenId, screenType, scre
126
127
  `the params extractor or retarget to an actionForm / secretMint / cross-entity ` +
127
128
  `entityEdit-create screen.`);
128
129
  }
130
+ const formTarget = target.screen;
131
+ if ((formTarget.type === "actionForm" || formTarget.type === "secretMint") &&
132
+ formTarget.submitPrefilled === true) {
133
+ validateDrawerPrefillKeys(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) rowAction "${action.id}"`, "navigate-target", action.screen ?? `entity "${action.entity}"`, formTarget, action.params);
134
+ }
129
135
  }
130
136
  // Shared by projectionDetail and entityEdit: a tab strip needs something to
131
137
  // label each tab with and a stable id per tab — the id anchors the ?tab=
@@ -345,19 +351,41 @@ function validateDrawerTargetAction(featureName, screenId, screenKind, actionLab
345
351
  `kind:"drawer" mounts an actionForm inside a Drawer widget — point "screen" at an ` +
346
352
  `actionForm screen, or use kind:"navigate" for a full-page target.`);
347
353
  }
348
- // A prefill key the target form doesn't declare is dropped silently by the
349
- // renderer (mergeSearchParamsIntoInitial iterates the target's fields), so
350
- // the typo would only show up as an empty field at click time.
354
+ // The renderer (mergeSearchParamsIntoInitial) silently drops a prefill key
355
+ // when the target doesn't declare the field, marks it sensitive/password,
356
+ // or doesn't render it in its layout — each would only show up as an empty
357
+ // field at click time.
351
358
  // skip: no params extractor — nothing to check against the target's fields.
352
359
  if (action.params === undefined)
353
360
  return;
354
- for (const fieldName of rowFieldExtractorKeys(action.params)) {
355
- if (Object.hasOwn(target.fields, fieldName))
361
+ validateDrawerPrefillKeys(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}"`, "drawer-target", action.screen, target, action.params);
362
+ }
363
+ function validateDrawerPrefillKeys(where, targetLabel, targetScreenId, target, params) {
364
+ const renderedFieldNames = new Set();
365
+ for (const section of target.layout.sections) {
366
+ if (!isFieldsEditSection(section))
356
367
  continue;
357
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}" ` +
358
- `params prefills "${fieldName}", which drawer-target "${action.screen}" does not declare as a ` +
359
- `field — the renderer would drop it and leave the form empty. Target fields: ` +
360
- `${Object.keys(target.fields).sort().join(", ") || "(none)"}.`);
368
+ for (const spec of sectionFieldSpecs(section))
369
+ renderedFieldNames.add(normalizeEditField(spec).field);
370
+ }
371
+ for (const fieldName of rowFieldExtractorKeys(params)) {
372
+ if (!Object.hasOwn(target.fields, fieldName)) {
373
+ throw new Error(`${where} params prefills "${fieldName}", which ${targetLabel} "${targetScreenId}" does not declare as a ` +
374
+ `field — the renderer would drop it and leave the form empty. Target fields: ` +
375
+ `${Object.keys(target.fields).sort().join(", ") || "(none)"}.`);
376
+ }
377
+ const targetField = target.fields[fieldName];
378
+ const isSensitiveOrPassword = targetField !== undefined &&
379
+ (("sensitive" in targetField && targetField.sensitive === true) ||
380
+ ("format" in targetField && targetField.format === "password"));
381
+ if (isSensitiveOrPassword) {
382
+ throw new Error(`${where} params prefills "${fieldName}", which ${targetLabel} "${targetScreenId}" marks as ` +
383
+ `sensitive or password — the renderer never prefills those fields and would leave the form empty.`);
384
+ }
385
+ if (!renderedFieldNames.has(fieldName)) {
386
+ throw new Error(`${where} params prefills "${fieldName}", which ${targetLabel} "${targetScreenId}" does not render in ` +
387
+ `its layout — the renderer only prefills fields the layout shows and would drop it.`);
388
+ }
361
389
  }
362
390
  }
363
391
  // The target can be any screen, so this checks the resolved target's
@@ -496,9 +524,13 @@ function rejectWriteOnlyFormField(featureName, screenId, context, fieldName, fde
496
524
  // payload refs must name another field of the same form (the payload also feeds
497
525
  // optionsAvailabilityQuery). The QN existence check
498
526
  // lives in query-refs.ts with the other query refs.
527
+ const CONDITIONAL_OPTIONS_ENTITY_ONLY = "conditionalOptions is only supported on entity select fields (enforced by the entity write path)";
499
528
  function validateFormSelectOptions(featureName, screenId, context, fieldName, fdef, siblingFieldNames) {
500
529
  if (fdef.type === "select") {
501
530
  const where = `[Feature ${featureName}] Screen "${screenId}" (${context}) select field "${fieldName}"`;
531
+ if (fdef.conditionalOptions !== undefined) {
532
+ throw new Error(`${where} declares conditionalOptions — ${CONDITIONAL_OPTIONS_ENTITY_ONLY}`);
533
+ }
502
534
  if (fdef.optionsQuery !== undefined) {
503
535
  if (fdef.optionsQuery.length === 0) {
504
536
  throw new Error(`${where} has an empty optionsQuery`);
@@ -564,7 +596,7 @@ function validateFieldsXorGroups(errorPrefix, section) {
564
596
  throw new Error(`${errorPrefix} section "${section.title}" declares both fields and groups — pass fields: [] ` +
565
597
  `when using groups.`);
566
598
  }
567
- if (section.fields.length === 0 && section.groups === undefined) {
599
+ if (section.fields.length === 0 && (section.groups?.length ?? 0) === 0) {
568
600
  throw new Error(`${errorPrefix} has a section "${section.title}" with zero fields — drop the section or add ` +
569
601
  `fields (or groups) to it.`);
570
602
  }
@@ -626,6 +658,17 @@ function validateRedirectTarget(feature, screenId, screenKind, redirect, allScre
626
658
  `has leading/trailing whitespace — it would never match a payload field.`);
627
659
  }
628
660
  validateScreenNavTarget(feature.name, screenId, screenKind, "redirect", redirectTarget, allScreenQns, feature.screens);
661
+ // idFrom only means something to a target that carries a record id; the
662
+ // renderer drops it silently otherwise. Cross-feature QN targets can't be
663
+ // typed from here and stay unchecked.
664
+ const sameFeatureTarget = feature.screens[redirectTarget];
665
+ if (typeof redirect !== "string" &&
666
+ sameFeatureTarget !== undefined &&
667
+ sameFeatureTarget.type !== "entityEdit" &&
668
+ sameFeatureTarget.type !== "projectionDetail") {
669
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screenKind}) redirect.idFrom is set but target screen ` +
670
+ `"${redirectTarget}" (${sameFeatureTarget.type}) carries no id — use the plain string form.`);
671
+ }
629
672
  }
630
673
  // redirect is only honored on record actions; on list/related-list row
631
674
  // actions it would be silently dropped, so the author would believe it works.
@@ -758,6 +801,20 @@ function getNavAreaIndex(featureMap) {
758
801
  navAreaIndexByFeatureMap.set(featureMap, index);
759
802
  return index;
760
803
  }
804
+ // resolveNavParentScreen falls back to a heuristic when the explicit
805
+ // listScreenId matches no screen, so a typo would silently get the guess.
806
+ function validateExplicitListScreenExists(featureName, screenId, screen, screensByShortId) {
807
+ const listScreenId = explicitListScreenId(screen);
808
+ // skip: no explicit listScreenId, the nav heuristic applies and there is nothing to validate
809
+ if (listScreenId === undefined)
810
+ return;
811
+ const shortId = listScreenId.slice(listScreenId.lastIndexOf(":") + 1);
812
+ // skip: the explicit listScreenId resolves to a mounted screen
813
+ if (screensByShortId.has(shortId))
814
+ return;
815
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screen.type}) listScreenId "${listScreenId}" ` +
816
+ `matches no mounted screen — fix the id or mount the list screen.`);
817
+ }
761
818
  // Every screen must resolve nav via `nav`, `r.nav()`, a parent list, or
762
819
  // `dormant: true`. Skipped when the composed set has no nav entries at all.
763
820
  function validateScreenHasNavArea(feature, screenId, screen, featureMap) {
@@ -902,6 +959,22 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
902
959
  detailForScreens,
903
960
  };
904
961
  for (const [screenId, screen] of Object.entries(feature.screens)) {
962
+ validateExplicitListScreenExists(feature.name, screenId, screen, screensByShortId);
963
+ // Output of buildAppSchema, which overwrites it — an authored value would be a silent no-op.
964
+ if ((screen.type === "entityEdit" ||
965
+ screen.type === "actionForm" ||
966
+ screen.type === "secretMint") &&
967
+ screen.urlPrefillFields !== undefined) {
968
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screen.type}) sets urlPrefillFields — ` +
969
+ `it is derived from navigate params by buildAppSchema; declare params on the navigating action instead.`);
970
+ }
971
+ if (screen.type !== "actionForm" &&
972
+ screen.type !== "secretMint" &&
973
+ "submitPrefilled" in screen &&
974
+ screen.submitPrefilled !== undefined) {
975
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screen.type}) sets submitPrefilled — ` +
976
+ `only actionForm and secretMint screens support it. Remove it.`);
977
+ }
905
978
  validateScreenHasNavArea(feature, screenId, screen, featureMap);
906
979
  validateScreenVisibleWhen(feature, screenId, screen, allScreenQns, featureMap);
907
980
  if (screen.type === "custom") {
@@ -925,6 +998,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
925
998
  for (const col of screen.columns) {
926
999
  const normalizedCol = normalizeListColumn(col);
927
1000
  validateColumnRendererForm(feature.name, screenId, normalizedCol);
1001
+ rejectInertColumnSortable(feature.name, screenId, "projectionList", normalizedCol);
928
1002
  if (normalizedCol.refEntity !== undefined) {
929
1003
  assertRefTargetRegistered(`[Feature ${feature.name}] Screen "${screenId}" (projectionList)`, `column "${normalizedCol.field}" (refEntity)`, normalizedCol.refEntity, feature.name, featureMap);
930
1004
  }
@@ -1048,6 +1122,12 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1048
1122
  }
1049
1123
  }
1050
1124
  }
1125
+ for (const [statusValue, tone] of Object.entries(screen.header?.statusTones ?? {})) {
1126
+ if (!isSelectOptionTone(tone)) {
1127
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) header.statusTones["${statusValue}"] ` +
1128
+ `is "${String(tone)}" — expected one of ${SELECT_OPTION_TONES.join(", ")}.`);
1129
+ }
1130
+ }
1051
1131
  if (Array.isArray(screen.header?.subtitle)) {
1052
1132
  if (screen.header.subtitleHref !== undefined) {
1053
1133
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) sets header.subtitleHref ` +
@@ -1382,6 +1462,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1382
1462
  throw new Error(buildUnknownFieldMessage(feature.name, screenId, normalized.field, screen.entity, columnFieldNames));
1383
1463
  }
1384
1464
  validateColumnRendererForm(feature.name, screenId, normalized);
1465
+ rejectInertColumnSortable(feature.name, screenId, "entityList", normalized);
1385
1466
  if (normalized.refEntity !== undefined) {
1386
1467
  assertRefTargetRegistered(`[Feature ${feature.name}] Screen "${screenId}" (entityList)`, `column "${normalized.field}" (refEntity)`, normalized.refEntity, feature.name, featureMap);
1387
1468
  }
@@ -1782,8 +1863,15 @@ function validateDashboardChartDisplay(featureName, screenId, panel) {
1782
1863
  throw new Error(`${where} sets scrollable on chart "${panel.chart}" — only "stacked-area" supports it.`);
1783
1864
  }
1784
1865
  validateStackedAreaOnlyProps(where, panel);
1785
- if (panel.legendTotals !== undefined && typeof panel.legendTotals !== "boolean") {
1786
- throw new Error(`${where} has a non-boolean legendTotals.`);
1866
+ validateStackedAreaEnumProp(where, "dateFormat", panel.dateFormat, ["day", "month"]);
1867
+ validateStackedAreaEnumProp(where, "initialWindow", panel.initialWindow, [
1868
+ "default-range",
1869
+ "from-today",
1870
+ ]);
1871
+ validateStackedAreaEnumProp(where, "markerLegend", panel.markerLegend, ["list", "legend"]);
1872
+ const nonBooleanProp = ["brush", "legendTotals"].find((prop) => panel[prop] !== undefined && typeof panel[prop] !== "boolean");
1873
+ if (nonBooleanProp !== undefined) {
1874
+ throw new Error(`${where} has a non-boolean ${nonBooleanProp}.`);
1787
1875
  }
1788
1876
  for (const [key, color] of Object.entries(panel.seriesColors ?? {})) {
1789
1877
  if (typeof color !== "string" || color.trim() === "") {
@@ -1796,11 +1884,27 @@ function validateDashboardChartDisplay(featureName, screenId, panel) {
1796
1884
  if (panel.ranges !== undefined)
1797
1885
  validateDashboardChartRanges(`${where} ranges`, panel.ranges);
1798
1886
  }
1887
+ function validateStackedAreaEnumProp(where, prop, value, allowed) {
1888
+ // skip: optional prop, nothing to validate when unset
1889
+ if (value === undefined)
1890
+ return;
1891
+ if (!allowed.includes(value)) {
1892
+ throw new Error(`${where}.${prop} "${String(value)}" must be one of ${allowed.join(", ")}.`);
1893
+ }
1894
+ }
1799
1895
  function validateStackedAreaOnlyProps(where, panel) {
1800
1896
  // skip: stacked-area is the one chart kind that supports these props
1801
1897
  if (panel.chart === "stacked-area")
1802
1898
  return;
1803
- const stackedAreaOnly = ["markerKinds", "legendTotals", "ranges"].filter((prop) => panel[prop] !== undefined);
1899
+ const stackedAreaOnly = [
1900
+ "markerKinds",
1901
+ "legendTotals",
1902
+ "ranges",
1903
+ "brush",
1904
+ "dateFormat",
1905
+ "initialWindow",
1906
+ "markerLegend",
1907
+ ].filter((prop) => panel[prop] !== undefined);
1804
1908
  if (stackedAreaOnly.length > 0) {
1805
1909
  throw new Error(`${where} sets ${stackedAreaOnly.join(", ")} on chart "${panel.chart}" — only "stacked-area" supports it.`);
1806
1910
  }
@@ -1821,6 +1925,9 @@ function validateDashboardChartMarkerKind(where, look) {
1821
1925
  if (look.color !== undefined && (typeof look.color !== "string" || look.color.trim() === "")) {
1822
1926
  throw new Error(`${where}.color must be a non-empty CSS color.`);
1823
1927
  }
1928
+ if (look.label !== undefined && (typeof look.label !== "string" || look.label.trim() === "")) {
1929
+ throw new Error(`${where}.label must be a non-empty i18n key.`);
1930
+ }
1824
1931
  }
1825
1932
  function validateDashboardChartRanges(where, ranges) {
1826
1933
  if (ranges.options.length === 0) {
@@ -1919,6 +2026,20 @@ function validateDashboardFilterDefinition(featureName, screenId, filter) {
1919
2026
  // stimmt: wenn `react` als Object gesetzt ist, MUSS `__component` ein
1920
2027
  // nicht-leerer String sein. Ein client-seitig ausgelassener Key löst
1921
2028
  // nur eine Warnung aus, kein Boot-Fail.
2029
+ // Column-level sortable: true only enables relatedList headers. On entityList
2030
+ // the entity field's flag wins; on projectionList sortable comes from the
2031
+ // query schema and a column can only opt OUT with sortable: false.
2032
+ function rejectInertColumnSortable(featureName, screenId, screenType, column) {
2033
+ const isInert = screenType === "entityList" ? column.sortable !== undefined : column.sortable === true;
2034
+ // skip: the sortable flag is meaningful for this screen type
2035
+ if (!isInert)
2036
+ return;
2037
+ const source = screenType === "entityList"
2038
+ ? "set sortable: true on the entity field instead"
2039
+ : "sorting is derived from the query's Zod schema; only sortable: false is honored";
2040
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) column "${column.field}" sets sortable — ` +
2041
+ `it is ignored here; ${source}.`);
2042
+ }
1922
2043
  export function validateColumnRendererForm(featureName, screenId, column) {
1923
2044
  const renderer = column.renderer;
1924
2045
  // skip: nur die PlatformComponent-Form ({ react: { __component: "..." } })
@@ -0,0 +1,2 @@
1
+ import { type SelectOptionTone } from "@cosmicdrift/kumiko-types/fields";
2
+ export declare function isSelectOptionTone(value: unknown): value is SelectOptionTone;
@@ -0,0 +1,4 @@
1
+ import { SELECT_OPTION_TONES } from "@cosmicdrift/kumiko-types/fields";
2
+ export function isSelectOptionTone(value) {
3
+ return SELECT_OPTION_TONES.some((tone) => tone === value);
4
+ }
@@ -1,3 +1,4 @@
1
+ import { MAX_TRANSFER_DEPTH } from "../transfer-adjacency.js";
1
2
  import type { FeatureDefinition } from "../types/index.js";
2
- export declare const MAX_TRANSFER_DEPTH = 5;
3
+ export { MAX_TRANSFER_DEPTH };
3
4
  export declare function validateTransferGraph(feature: FeatureDefinition, featureMap: ReadonlyMap<string, FeatureDefinition>): void;
@@ -1,4 +1,5 @@
1
1
  import { parseRefTargetEntityName } from "../parse-ref-target.js";
2
+ import { findOverDeepTransferChain, MAX_TRANSFER_DEPTH, resolveTransferAdjacency, } from "../transfer-adjacency.js";
2
3
  // --- Transfer-graph boot validation (fw#3088) ---
3
4
  //
4
5
  // tenant-handover walks an entity graph across the tenant boundary along two
@@ -9,16 +10,7 @@ import { parseRefTargetEntityName } from "../parse-ref-target.js";
9
10
  //
10
11
  // Scoped to entities declaring `transferable: true`, so a consumer that has
11
12
  // one of these shapes but never hands that entity over is unaffected.
12
- // The single definition of the limit. It lives here rather than next to the
13
- // resolver in bundled-features because the dependency only runs that way:
14
- // bundled-features imports from the framework, never the reverse.
15
- //
16
- // Guards against a schema whose reference edges span more levels than anyone
17
- // intended — a runaway graph would move rows an operator never associated with
18
- // the handover. Deliberately a constant and not per-feature config: the limit
19
- // is a safety net, and a configurable one gets raised by whoever trips it
20
- // instead of prompting them to reconsider their graph.
21
- export const MAX_TRANSFER_DEPTH = 5;
13
+ export { MAX_TRANSFER_DEPTH };
22
14
  function allEntities(featureMap) {
23
15
  const out = [];
24
16
  for (const feature of featureMap.values()) {
@@ -28,17 +20,6 @@ function allEntities(featureMap) {
28
20
  }
29
21
  return out;
30
22
  }
31
- function referenceTargets(entity) {
32
- const targets = [];
33
- for (const field of Object.values(entity.fields)) {
34
- if (field.type !== "reference")
35
- continue;
36
- if (field.multiple === true)
37
- continue;
38
- targets.push(parseRefTargetEntityName(field.entity));
39
- }
40
- return targets;
41
- }
42
23
  // A `multiple` reference stores a jsonb array of ids, which the handover's
43
24
  // `= ANY($ids)` column match cannot address. Declaring one towards a transferable
44
25
  // entity would mean its rows never move with their host. A target outside the
@@ -52,6 +33,8 @@ function validateNoMultipleReferenceEdge(entry, featureName, transferable) {
52
33
  for (const [fieldName, field] of Object.entries(entry.entity.fields)) {
53
34
  if (field.type !== "reference" || field.multiple !== true)
54
35
  continue;
36
+ if (field.handover === "stay")
37
+ continue;
55
38
  if (!transferable.has(parseRefTargetEntityName(field.entity)))
56
39
  continue;
57
40
  throw new Error(`[Kumiko TransferGraph] entity "${entry.name}" declares transferable: true and a ` +
@@ -62,31 +45,6 @@ function validateNoMultipleReferenceEdge(entry, featureName, transferable) {
62
45
  `from "${entry.name}".`);
63
46
  }
64
47
  }
65
- // Depth is measured over reference edges between transferable entities, the
66
- // only chain that nests (parentRef is one level by construction — see
67
- // engine/boot-validator/parent-ref.ts).
68
- //
69
- // `onPath` counts NODES, the limit counts EDGES: a chain of exactly
70
- // MAX_TRANSFER_DEPTH edges holds MAX_TRANSFER_DEPTH + 1 entities, and the
71
- // mover runs MAX_TRANSFER_DEPTH rounds of one hop each, so it still walks that
72
- // chain whole. Rejecting it here would make a schema the mover handles
73
- // correctly refuse to boot.
74
- function longestTransferableChain(startName, bySource, onPath) {
75
- if (onPath.length > MAX_TRANSFER_DEPTH + 1)
76
- return onPath;
77
- for (const target of bySource.get(startName) ?? []) {
78
- // skip: a cycle revisits a type already on this path, and schema depth
79
- // cannot measure how far it actually runs — that depends on the rows, not
80
- // the declaration. The mover carries this one instead, failing with
81
- // `transfer_graph_too_deep` when its rounds run out (#3131).
82
- if (onPath.includes(target))
83
- continue;
84
- const deeper = longestTransferableChain(target, bySource, [...onPath, target]);
85
- if (deeper !== undefined)
86
- return deeper;
87
- }
88
- return undefined;
89
- }
90
48
  export function validateTransferGraph(feature, featureMap) {
91
49
  const transferable = new Set(allEntities(featureMap)
92
50
  .filter((e) => e.entity.transferable === true)
@@ -94,30 +52,17 @@ export function validateTransferGraph(feature, featureMap) {
94
52
  for (const [name, entity] of Object.entries(feature.entities ?? {})) {
95
53
  validateNoMultipleReferenceEdge({ name, entity }, feature.name, transferable);
96
54
  }
97
- // Edges point child -> parent in the schema; the graph walks parent -> child,
98
- // so the lookup is inverted here.
99
- const childrenByParent = new Map();
100
- for (const { name, entity } of allEntities(featureMap)) {
101
- if (!transferable.has(name))
102
- continue;
103
- for (const target of referenceTargets(entity)) {
104
- if (!transferable.has(target))
105
- continue;
106
- const children = childrenByParent.get(target) ?? [];
107
- children.push(name);
108
- childrenByParent.set(target, children);
109
- }
110
- }
55
+ const adjacency = resolveTransferAdjacency(new Map(allEntities(featureMap).map(({ name, entity }) => [name, entity])));
111
56
  for (const [name, entity] of Object.entries(feature.entities ?? {})) {
112
57
  if (entity.transferable !== true)
113
58
  continue;
114
- const tooDeep = longestTransferableChain(name, childrenByParent, [name]);
59
+ const tooDeep = findOverDeepTransferChain(adjacency, [name]);
115
60
  if (tooDeep !== undefined) {
116
61
  throw new Error(`[Kumiko TransferGraph] the transfer graph rooted at entity "${name}" is deeper than ` +
117
62
  `the ${MAX_TRANSFER_DEPTH}-level limit (feature: "${feature.name}"): ` +
118
63
  `${tooDeep.join(" -> ")}. Beyond that depth tenant-handover would move fewer rows ` +
119
- `than the declaration implies. Fix: flatten the chain, or drop transferable from an ` +
120
- `entity in it that should not move with its host.`);
64
+ `than the declaration implies. Fix: flatten the chain, or mark a reference field on ` +
65
+ `an entity in it that should not move with its host \`handover: "stay"\`.`);
121
66
  }
122
67
  }
123
68
  }
@@ -1,2 +1,3 @@
1
1
  import type { FeatureDefinition } from "../types/index.js";
2
2
  export declare function collectUrlPrefillFieldsByScreenQn(features: readonly FeatureDefinition[]): Map<string, Set<string>>;
3
+ export declare function validateSubmitPrefilledHiddenFields(features: readonly FeatureDefinition[]): void;
@@ -1,4 +1,5 @@
1
1
  import { qualifyEntityName } from "../qualified-name.js";
2
+ import { isFieldsEditSection, normalizeEditField, sectionFieldSpecs } from "../screen-helpers.js";
2
3
  import { collectScreensByShortId, readsNavigateParamsAsFormPrefill, rowFieldExtractorKeys, } from "./screens.js";
3
4
  function isNavigateKind(action) {
4
5
  return action.kind === undefined || action.kind === "navigate";
@@ -123,3 +124,37 @@ function resolveTarget(source, screensByShortId, detailForByEntity) {
123
124
  const match = screensByShortId.get(source.screen)?.[0];
124
125
  return match === undefined ? undefined : { ...match, shortId: source.screen };
125
126
  }
127
+ // submitPrefilled lets an unchanged form submit, so a required field the layout
128
+ // hides must be seeded by a navigate param — nothing else can ever fill it.
129
+ export function validateSubmitPrefilledHiddenFields(features) {
130
+ const prefillByScreenQn = collectUrlPrefillFieldsByScreenQn(features);
131
+ for (const feature of features) {
132
+ for (const [screenId, screen] of Object.entries(feature.screens)) {
133
+ if (screen.type !== "actionForm" && screen.type !== "secretMint")
134
+ continue;
135
+ if (screen.submitPrefilled !== true)
136
+ continue;
137
+ const seeded = prefillByScreenQn.get(qualifyEntityName(feature.name, "screen", screenId));
138
+ for (const section of screen.layout.sections) {
139
+ if (!isFieldsEditSection(section))
140
+ continue;
141
+ for (const rawSpec of sectionFieldSpecs(section)) {
142
+ const spec = normalizeEditField(rawSpec);
143
+ if (spec.visible !== false)
144
+ continue;
145
+ const fieldDef = screen.fields[spec.field];
146
+ if (fieldDef === undefined || !("required" in fieldDef) || fieldDef.required !== true) {
147
+ continue;
148
+ }
149
+ if (seeded?.has(spec.field) === true)
150
+ continue;
151
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screen.type}) sets submitPrefilled but ` +
152
+ `hides required field "${spec.field}" (visible: false) and no navigate action passes it in ` +
153
+ `params — the field could never get a value and the form could never submit. Add ` +
154
+ `"${spec.field}" to the params of a navigate action targeting this screen, make the field ` +
155
+ `visible, or drop required.`);
156
+ }
157
+ }
158
+ }
159
+ }
160
+ }
@@ -268,6 +268,7 @@ export function findNonJsonSafePath(value, path) {
268
268
  // one that contradicts the schema.
269
269
  function projectScreens(featureName, screens, registry, urlPrefillFieldsByScreenQn, encryptedConfigKeyQns) {
270
270
  return Object.entries(screens).map(([shortId, screen]) => {
271
+ assertValidScreenMultilineRows(featureName, shortId, screen);
271
272
  if (screen.type === "configEdit")
272
273
  return projectConfigEditScreen(screen, encryptedConfigKeyQns);
273
274
  if (screen.type === "projectionList")
@@ -330,7 +331,7 @@ function deriveProjectionListCapabilities(schema) {
330
331
  function projectEntities(entities) {
331
332
  const out = {};
332
333
  for (const [name, entity] of Object.entries(entities)) {
333
- out[name] = projectEntity(entity);
334
+ out[name] = projectEntity(name, entity);
334
335
  }
335
336
  return out;
336
337
  }
@@ -339,9 +340,10 @@ function projectEntities(entities) {
339
340
  // einzeln auszuhandeln, walken wir die Field-Map und filtern auf die
340
341
  // Whitelist. Was nicht durchkommt: Server-only-runtime wie ZodValidate-
341
342
  // Functions, Computed-Functions, Default-Functions.
342
- function projectEntity(entity) {
343
+ function projectEntity(entityName, entity) {
343
344
  const fieldsOut = {};
344
345
  for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
346
+ assertValidMultilineRows(`Entity "${entityName}"`, fieldName, fieldDef);
345
347
  fieldsOut[fieldName] = projectField(fieldDef);
346
348
  }
347
349
  // derivedFields MÜSSEN mit ins Client-Schema (nur die Metadaten, nicht die
@@ -380,6 +382,32 @@ function projectEntity(entity) {
380
382
  function projectDerivedField(derivedDef) {
381
383
  return { valueType: derivedDef.valueType };
382
384
  }
385
+ // A textarea height that is not a positive integer cannot render anywhere; fail
386
+ // at boot with the field name instead of letting each renderer guess a fallback.
387
+ function assertValidMultilineRows(owner, fieldName, fieldDef) {
388
+ const multiline = fieldDef.type === "text" || fieldDef.type === "longText" ? fieldDef.multiline : undefined;
389
+ const rows = typeof multiline === "object" ? multiline.rows : undefined;
390
+ // Non-number values are not JSON-safe and get dropped by the projection below.
391
+ if (typeof rows === "number" && (!Number.isInteger(rows) || rows < 1)) {
392
+ throw new Error(`${owner} field "${fieldName}": multiline.rows must be a positive integer, got ${rows}`);
393
+ }
394
+ }
395
+ // Screen forms carry their own inline field maps into the client schema.
396
+ function assertValidScreenMultilineRows(featureName, shortId, screen) {
397
+ const owner = `Screen "${featureName}:${shortId}"`;
398
+ const check = (fields) => {
399
+ for (const [fieldName, fieldDef] of Object.entries(fields)) {
400
+ assertValidMultilineRows(owner, fieldName, fieldDef);
401
+ }
402
+ };
403
+ if (screen.type === "configEdit" || screen.type === "actionForm")
404
+ check(screen.fields);
405
+ if (screen.type === "secretMint") {
406
+ check(screen.fields);
407
+ if (screen.confirm !== undefined)
408
+ check(screen.confirm.fields);
409
+ }
410
+ }
383
411
  // Per-field whitelist. Every forwarded value must be JSON-safe (literal, or
384
412
  // an array/plain-object built only from JSON-safe values) — even though the
385
413
  // FieldDefinition types only type `default` as a literal, the sample-authoring
@@ -431,6 +459,9 @@ function projectField(fieldDef) {
431
459
  // Select: tier/tenant availability of the static options, loaded by the renderer.
432
460
  if (typeof def["optionsAvailabilityQuery"] === "string")
433
461
  out["optionsAvailabilityQuery"] = def["optionsAvailabilityQuery"];
462
+ // Select: options only offered while a sibling-field condition holds; the renderer filters, the write path enforces.
463
+ if (Array.isArray(def["conditionalOptions"]))
464
+ out["conditionalOptions"] = def["conditionalOptions"];
434
465
  if (typeof def["multiple"] === "boolean")
435
466
  out["multiple"] = def["multiple"];
436
467
  // MultiSelect: display picks checkboxes vs. combobox in the renderer,
@@ -415,10 +415,16 @@ function buildSecretsEditScreen(screenId, secrets, access, declaredTranslationKe
415
415
  // need a writable local type; each entry is still a fresh, never-mutated object.
416
416
  const translations = {};
417
417
  const sections = [];
418
+ const seenFieldIds = new Map();
418
419
  for (const feature of [...new Set(secrets.map((s) => s.feature))]) {
419
420
  const fieldIds = [];
420
421
  for (const s of secrets.filter((v) => v.feature === feature)) {
421
422
  const fieldId = `${s.feature}-${s.shortKey}`;
423
+ const prevQn = seenFieldIds.get(fieldId);
424
+ if (prevQn !== undefined) {
425
+ throw new Error(`[Settings-Hub] secrets "${prevQn}" and "${s.qn}" both resolve to field id "${fieldId}" — rename one secret or its feature.`);
426
+ }
427
+ seenFieldIds.set(fieldId, s.qn);
422
428
  fieldIds.push(fieldId);
423
429
  secretKeys[fieldId] = s.qn;
424
430
  const labelKey = `config.secret.${s.feature}.${s.shortKey}.label`;
@@ -6,6 +6,7 @@ export type PendingChange = {
6
6
  readonly detail?: string;
7
7
  readonly migration?: string;
8
8
  readonly codemod?: string;
9
+ readonly manualAfterCodemod?: boolean;
9
10
  readonly source: string;
10
11
  };
11
12
  /** Parse structured upgrade metadata embedded in a Changeset body. */