@cosmicdrift/kumiko-framework 0.353.0 → 0.354.1

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 (165) hide show
  1. package/dist/api/index.d.ts +1 -1
  2. package/dist/api/index.js +1 -1
  3. package/dist/api/redis-sse-broker.d.ts +1 -0
  4. package/dist/api/redis-sse-broker.js +16 -10
  5. package/dist/api/request-context.d.ts +1 -0
  6. package/dist/api/request-context.js +5 -0
  7. package/dist/api/routes.js +4 -2
  8. package/dist/api/server.js +28 -3
  9. package/dist/api/sse-broker.d.ts +7 -3
  10. package/dist/api/sse-route.js +2 -6
  11. package/dist/api/token-expiry-timer.d.ts +1 -0
  12. package/dist/api/token-expiry-timer.js +10 -0
  13. package/dist/bun-db/query.js +29 -2
  14. package/dist/crypto/event-pii.js +4 -2
  15. package/dist/crypto/kms-wiring.js +31 -5
  16. package/dist/db/eagerload.d.ts +8 -3
  17. package/dist/db/eagerload.js +85 -81
  18. package/dist/db/event-store-executor-context.d.ts +12 -0
  19. package/dist/db/event-store-executor-context.js +59 -18
  20. package/dist/db/event-store-executor-read.js +81 -31
  21. package/dist/db/event-store-executor-write.js +54 -3
  22. package/dist/db/index.d.ts +1 -1
  23. package/dist/db/migrate-generator.js +1 -1
  24. package/dist/db/queries/backfill-pii.d.ts +1 -0
  25. package/dist/db/queries/backfill-pii.js +11 -6
  26. package/dist/db/queries/event-consumer.d.ts +2 -3
  27. package/dist/db/queries/event-consumer.js +54 -43
  28. package/dist/db/queries/event-store.d.ts +5 -0
  29. package/dist/db/queries/event-store.js +51 -3
  30. package/dist/db/queries/pii-backfill-state.d.ts +8 -1
  31. package/dist/db/queries/pii-backfill-state.js +16 -5
  32. package/dist/db/queries/shadow-swap.js +32 -3
  33. package/dist/db/render-ddl.js +6 -6
  34. package/dist/db/table-builder.js +6 -7
  35. package/dist/db/tenant-db.d.ts +5 -2
  36. package/dist/db/tenant-db.js +47 -7
  37. package/dist/derivatives/variant-key.d.ts +1 -0
  38. package/dist/derivatives/variant-key.js +3 -0
  39. package/dist/engine/boot-validator/access-declarations.js +5 -0
  40. package/dist/engine/boot-validator/config-deps.js +3 -0
  41. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  42. package/dist/engine/boot-validator/entity-handler.js +40 -0
  43. package/dist/engine/boot-validator/handler-field-references.d.ts +2 -0
  44. package/dist/engine/boot-validator/handler-field-references.js +25 -0
  45. package/dist/engine/boot-validator/index.js +6 -1
  46. package/dist/engine/boot-validator/nav.js +3 -1
  47. package/dist/engine/boot-validator/ownership.d.ts +2 -1
  48. package/dist/engine/boot-validator/ownership.js +32 -1
  49. package/dist/engine/boot-validator/pii-retention.js +8 -1
  50. package/dist/engine/boot-validator/query-output-columns.js +9 -9
  51. package/dist/engine/boot-validator/screens.js +98 -7
  52. package/dist/engine/boot-validator/transfer-graph.d.ts +2 -1
  53. package/dist/engine/boot-validator/transfer-graph.js +8 -63
  54. package/dist/engine/boot-validator/url-prefill-fields.d.ts +1 -0
  55. package/dist/engine/boot-validator/url-prefill-fields.js +35 -0
  56. package/dist/engine/build-app-schema.js +3 -0
  57. package/dist/engine/changeset-changes.d.ts +1 -0
  58. package/dist/engine/changeset-changes.js +49 -33
  59. package/dist/engine/define-feature.js +11 -0
  60. package/dist/engine/entity-handlers.d.ts +3 -3
  61. package/dist/engine/entity-handlers.js +55 -34
  62. package/dist/engine/escape-hatch-reason.d.ts +2 -0
  63. package/dist/engine/escape-hatch-reason.js +8 -0
  64. package/dist/engine/extension-names.d.ts +8 -9
  65. package/dist/engine/extension-names.js +8 -9
  66. package/dist/engine/extensions/tenant-data.d.ts +3 -1
  67. package/dist/engine/extensions/tenant-resource.d.ts +9 -1
  68. package/dist/engine/factories.d.ts +1 -1
  69. package/dist/engine/feature-changelog.d.ts +2 -0
  70. package/dist/engine/feature-changelog.js +6 -0
  71. package/dist/engine/feature-config-events-jobs.js +41 -4
  72. package/dist/engine/handler-field-references.d.ts +3 -0
  73. package/dist/engine/handler-field-references.js +31 -0
  74. package/dist/engine/i18n-key.js +2 -3
  75. package/dist/engine/index.d.ts +8 -2
  76. package/dist/engine/index.js +6 -1
  77. package/dist/engine/ownership.js +3 -4
  78. package/dist/engine/project-app-schema-for-roles.js +3 -1
  79. package/dist/engine/registry-ingest.d.ts +1 -0
  80. package/dist/engine/registry-ingest.js +8 -3
  81. package/dist/engine/run-pipeline.js +12 -1
  82. package/dist/engine/schema-builder.js +35 -2
  83. package/dist/engine/screen-helpers.d.ts +13 -0
  84. package/dist/engine/screen-helpers.js +31 -0
  85. package/dist/engine/soft-delete-cleanup.js +3 -2
  86. package/dist/engine/steps/_read-source.d.ts +4 -0
  87. package/dist/engine/steps/_read-source.js +22 -0
  88. package/dist/engine/steps/describe-workflow-step-error.js +3 -1
  89. package/dist/engine/steps/read-find-many.js +2 -1
  90. package/dist/engine/steps/read-find-one.js +2 -1
  91. package/dist/engine/steps/workflow-step-error.d.ts +5 -0
  92. package/dist/engine/steps/workflow-step-error.js +13 -0
  93. package/dist/engine/transfer-adjacency.d.ts +24 -0
  94. package/dist/engine/transfer-adjacency.js +107 -0
  95. package/dist/entrypoint/index.d.ts +2 -0
  96. package/dist/entrypoint/index.js +2 -0
  97. package/dist/env/index.js +45 -23
  98. package/dist/errors/classes.d.ts +8 -0
  99. package/dist/errors/classes.js +13 -0
  100. package/dist/errors/index.d.ts +2 -1
  101. package/dist/errors/index.js +2 -1
  102. package/dist/errors/reasons.d.ts +1 -0
  103. package/dist/errors/reasons.js +3 -0
  104. package/dist/errors/subject-erased.d.ts +2 -0
  105. package/dist/errors/subject-erased.js +9 -0
  106. package/dist/event-store/admin-api.js +14 -10
  107. package/dist/event-store/event-store.d.ts +1 -0
  108. package/dist/event-store/event-store.js +14 -9
  109. package/dist/event-store/events-schema.js +2 -1
  110. package/dist/event-store/index.d.ts +1 -1
  111. package/dist/event-store/index.js +1 -1
  112. package/dist/files/file-handle.d.ts +1 -1
  113. package/dist/files/file-handle.js +30 -6
  114. package/dist/files/file-routes.js +10 -4
  115. package/dist/files/resolve-content-type.d.ts +2 -0
  116. package/dist/files/resolve-content-type.js +8 -0
  117. package/dist/i18n/required-surface-keys.d.ts +2 -0
  118. package/dist/i18n/required-surface-keys.js +8 -0
  119. package/dist/jobs/index.d.ts +1 -1
  120. package/dist/jobs/job-runner.d.ts +13 -0
  121. package/dist/jobs/job-runner.js +128 -22
  122. package/dist/migrations/pii-event-backfill.js +46 -25
  123. package/dist/observability/index.d.ts +1 -1
  124. package/dist/observability/index.js +1 -1
  125. package/dist/observability/standard-metrics.d.ts +4 -0
  126. package/dist/observability/standard-metrics.js +12 -0
  127. package/dist/pipeline/dispatch-shared.d.ts +2 -0
  128. package/dist/pipeline/dispatch-shared.js +74 -9
  129. package/dist/pipeline/dispatch-stream.d.ts +5 -1
  130. package/dist/pipeline/dispatch-stream.js +19 -12
  131. package/dist/pipeline/dispatcher.d.ts +3 -1
  132. package/dist/pipeline/dispatcher.js +10 -15
  133. package/dist/pipeline/event-consumer-state.js +0 -26
  134. package/dist/pipeline/event-dispatcher-admin.js +17 -11
  135. package/dist/pipeline/event-dispatcher-delivery.d.ts +1 -1
  136. package/dist/pipeline/event-dispatcher-delivery.js +3 -2
  137. package/dist/pipeline/event-dispatcher.js +48 -7
  138. package/dist/pipeline/index.d.ts +2 -0
  139. package/dist/pipeline/index.js +1 -0
  140. package/dist/pipeline/member-read-only-transaction.js +25 -5
  141. package/dist/pipeline/msp-rebuild.js +3 -3
  142. package/dist/pipeline/pending-gap-ranges.d.ts +1 -0
  143. package/dist/pipeline/pending-gap-ranges.js +22 -0
  144. package/dist/pipeline/system-hooks.js +4 -4
  145. package/dist/pipeline/system-identity-switch.d.ts +1 -1
  146. package/dist/pipeline/system-identity-switch.js +5 -4
  147. package/dist/pipeline/tenant-timezone-cache.js +5 -3
  148. package/dist/redis/cache-sync-topics.d.ts +1 -0
  149. package/dist/redis/cache-sync-topics.js +1 -0
  150. package/dist/redis/client.d.ts +1 -0
  151. package/dist/redis/client.js +7 -0
  152. package/dist/redis/index.d.ts +1 -1
  153. package/dist/redis/index.js +1 -1
  154. package/dist/redis/pubsub-signal.d.ts +1 -0
  155. package/dist/redis/pubsub-signal.js +3 -0
  156. package/dist/search/meilisearch-adapter.js +34 -4
  157. package/dist/stack/test-stack.d.ts +4 -1
  158. package/dist/stack/test-stack.js +9 -2
  159. package/dist/testing/e2e-generator.js +7 -2
  160. package/dist/ui-types/index.d.ts +3 -3
  161. package/dist/ui-types/index.js +2 -2
  162. package/dist/upgrade-cli.js +7 -2
  163. package/package.json +12 -4
  164. package/src/changes.json +209 -0
  165. package/src/scripts/codemod/migrate-cross-tenant.ts +2 -1
@@ -12,7 +12,7 @@ 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, isFieldsEditSection, 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
18
  import { isSelectOptionTone } from "./select-option-tone.js";
@@ -127,6 +127,11 @@ function validateRowActionNavigateParams(featureName, screenId, screenType, scre
127
127
  `the params extractor or retarget to an actionForm / secretMint / cross-entity ` +
128
128
  `entityEdit-create screen.`);
129
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
+ }
130
135
  }
131
136
  // Shared by projectionDetail and entityEdit: a tab strip needs something to
132
137
  // label each tab with and a stable id per tab — the id anchors the ?tab=
@@ -353,9 +358,9 @@ function validateDrawerTargetAction(featureName, screenId, screenKind, actionLab
353
358
  // skip: no params extractor — nothing to check against the target's fields.
354
359
  if (action.params === undefined)
355
360
  return;
356
- validateDrawerPrefillKeys(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}"`, action.screen, target, action.params);
361
+ validateDrawerPrefillKeys(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}"`, "drawer-target", action.screen, target, action.params);
357
362
  }
358
- function validateDrawerPrefillKeys(where, targetScreenId, target, params) {
363
+ function validateDrawerPrefillKeys(where, targetLabel, targetScreenId, target, params) {
359
364
  const renderedFieldNames = new Set();
360
365
  for (const section of target.layout.sections) {
361
366
  if (!isFieldsEditSection(section))
@@ -365,7 +370,7 @@ function validateDrawerPrefillKeys(where, targetScreenId, target, params) {
365
370
  }
366
371
  for (const fieldName of rowFieldExtractorKeys(params)) {
367
372
  if (!Object.hasOwn(target.fields, fieldName)) {
368
- throw new Error(`${where} params prefills "${fieldName}", which drawer-target "${targetScreenId}" does not declare as a ` +
373
+ throw new Error(`${where} params prefills "${fieldName}", which ${targetLabel} "${targetScreenId}" does not declare as a ` +
369
374
  `field — the renderer would drop it and leave the form empty. Target fields: ` +
370
375
  `${Object.keys(target.fields).sort().join(", ") || "(none)"}.`);
371
376
  }
@@ -374,11 +379,11 @@ function validateDrawerPrefillKeys(where, targetScreenId, target, params) {
374
379
  (("sensitive" in targetField && targetField.sensitive === true) ||
375
380
  ("format" in targetField && targetField.format === "password"));
376
381
  if (isSensitiveOrPassword) {
377
- throw new Error(`${where} params prefills "${fieldName}", which drawer-target "${targetScreenId}" marks as ` +
382
+ throw new Error(`${where} params prefills "${fieldName}", which ${targetLabel} "${targetScreenId}" marks as ` +
378
383
  `sensitive or password — the renderer never prefills those fields and would leave the form empty.`);
379
384
  }
380
385
  if (!renderedFieldNames.has(fieldName)) {
381
- throw new Error(`${where} params prefills "${fieldName}", which drawer-target "${targetScreenId}" does not render in ` +
386
+ throw new Error(`${where} params prefills "${fieldName}", which ${targetLabel} "${targetScreenId}" does not render in ` +
382
387
  `its layout — the renderer only prefills fields the layout shows and would drop it.`);
383
388
  }
384
389
  }
@@ -519,9 +524,13 @@ function rejectWriteOnlyFormField(featureName, screenId, context, fieldName, fde
519
524
  // payload refs must name another field of the same form (the payload also feeds
520
525
  // optionsAvailabilityQuery). The QN existence check
521
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)";
522
528
  function validateFormSelectOptions(featureName, screenId, context, fieldName, fdef, siblingFieldNames) {
523
529
  if (fdef.type === "select") {
524
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
+ }
525
534
  if (fdef.optionsQuery !== undefined) {
526
535
  if (fdef.optionsQuery.length === 0) {
527
536
  throw new Error(`${where} has an empty optionsQuery`);
@@ -649,6 +658,17 @@ function validateRedirectTarget(feature, screenId, screenKind, redirect, allScre
649
658
  `has leading/trailing whitespace — it would never match a payload field.`);
650
659
  }
651
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
+ }
652
672
  }
653
673
  // redirect is only honored on record actions; on list/related-list row
654
674
  // actions it would be silently dropped, so the author would believe it works.
@@ -781,6 +801,20 @@ function getNavAreaIndex(featureMap) {
781
801
  navAreaIndexByFeatureMap.set(featureMap, index);
782
802
  return index;
783
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
+ }
784
818
  // Every screen must resolve nav via `nav`, `r.nav()`, a parent list, or
785
819
  // `dormant: true`. Skipped when the composed set has no nav entries at all.
786
820
  function validateScreenHasNavArea(feature, screenId, screen, featureMap) {
@@ -925,6 +959,22 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
925
959
  detailForScreens,
926
960
  };
927
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
+ }
928
978
  validateScreenHasNavArea(feature, screenId, screen, featureMap);
929
979
  validateScreenVisibleWhen(feature, screenId, screen, allScreenQns, featureMap);
930
980
  if (screen.type === "custom") {
@@ -948,6 +998,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
948
998
  for (const col of screen.columns) {
949
999
  const normalizedCol = normalizeListColumn(col);
950
1000
  validateColumnRendererForm(feature.name, screenId, normalizedCol);
1001
+ rejectInertColumnSortable(feature.name, screenId, "projectionList", normalizedCol);
951
1002
  if (normalizedCol.refEntity !== undefined) {
952
1003
  assertRefTargetRegistered(`[Feature ${feature.name}] Screen "${screenId}" (projectionList)`, `column "${normalizedCol.field}" (refEntity)`, normalizedCol.refEntity, feature.name, featureMap);
953
1004
  }
@@ -1411,6 +1462,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1411
1462
  throw new Error(buildUnknownFieldMessage(feature.name, screenId, normalized.field, screen.entity, columnFieldNames));
1412
1463
  }
1413
1464
  validateColumnRendererForm(feature.name, screenId, normalized);
1465
+ rejectInertColumnSortable(feature.name, screenId, "entityList", normalized);
1414
1466
  if (normalized.refEntity !== undefined) {
1415
1467
  assertRefTargetRegistered(`[Feature ${feature.name}] Screen "${screenId}" (entityList)`, `column "${normalized.field}" (refEntity)`, normalized.refEntity, feature.name, featureMap);
1416
1468
  }
@@ -1811,6 +1863,12 @@ function validateDashboardChartDisplay(featureName, screenId, panel) {
1811
1863
  throw new Error(`${where} sets scrollable on chart "${panel.chart}" — only "stacked-area" supports it.`);
1812
1864
  }
1813
1865
  validateStackedAreaOnlyProps(where, panel);
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"]);
1814
1872
  const nonBooleanProp = ["brush", "legendTotals"].find((prop) => panel[prop] !== undefined && typeof panel[prop] !== "boolean");
1815
1873
  if (nonBooleanProp !== undefined) {
1816
1874
  throw new Error(`${where} has a non-boolean ${nonBooleanProp}.`);
@@ -1826,11 +1884,27 @@ function validateDashboardChartDisplay(featureName, screenId, panel) {
1826
1884
  if (panel.ranges !== undefined)
1827
1885
  validateDashboardChartRanges(`${where} ranges`, panel.ranges);
1828
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
+ }
1829
1895
  function validateStackedAreaOnlyProps(where, panel) {
1830
1896
  // skip: stacked-area is the one chart kind that supports these props
1831
1897
  if (panel.chart === "stacked-area")
1832
1898
  return;
1833
- const stackedAreaOnly = ["markerKinds", "legendTotals", "ranges", "brush"].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);
1834
1908
  if (stackedAreaOnly.length > 0) {
1835
1909
  throw new Error(`${where} sets ${stackedAreaOnly.join(", ")} on chart "${panel.chart}" — only "stacked-area" supports it.`);
1836
1910
  }
@@ -1851,6 +1925,9 @@ function validateDashboardChartMarkerKind(where, look) {
1851
1925
  if (look.color !== undefined && (typeof look.color !== "string" || look.color.trim() === "")) {
1852
1926
  throw new Error(`${where}.color must be a non-empty CSS color.`);
1853
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
+ }
1854
1931
  }
1855
1932
  function validateDashboardChartRanges(where, ranges) {
1856
1933
  if (ranges.options.length === 0) {
@@ -1949,6 +2026,20 @@ function validateDashboardFilterDefinition(featureName, screenId, filter) {
1949
2026
  // stimmt: wenn `react` als Object gesetzt ist, MUSS `__component` ein
1950
2027
  // nicht-leerer String sein. Ein client-seitig ausgelassener Key löst
1951
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
+ }
1952
2043
  export function validateColumnRendererForm(featureName, screenId, column) {
1953
2044
  const renderer = column.renderer;
1954
2045
  // skip: nur die PlatformComponent-Form ({ react: { __component: "..." } })
@@ -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
+ }
@@ -459,6 +459,9 @@ function projectField(fieldDef) {
459
459
  // Select: tier/tenant availability of the static options, loaded by the renderer.
460
460
  if (typeof def["optionsAvailabilityQuery"] === "string")
461
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"];
462
465
  if (typeof def["multiple"] === "boolean")
463
466
  out["multiple"] = def["multiple"];
464
467
  // MultiSelect: display picks checkboxes vs. combobox in the renderer,
@@ -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. */
@@ -1,7 +1,7 @@
1
1
  import { validateChangelog } from "./feature-changelog.js";
2
2
  const BLOCK_RE = /<!--\s*kumiko-changes\s*\n([\s\S]*?)\n\s*-->/g;
3
3
  const TYPE_VALUES = new Set(["breaking", "improvement", "fix"]);
4
- const KEY_RE = /^(feature|type|title|detail|migration|codemod):(?:\s(.*))?$/;
4
+ const KEY_RE = /^(feature|type|title|detail|migration|codemod|manualAfterCodemod):(?:\s(.*))?$/;
5
5
  function requiredField(fields, field, source) {
6
6
  const value = fields.get(field)?.trim();
7
7
  if (!value)
@@ -70,6 +70,53 @@ function assertBlockNotClosedEarly(markdown, match, source) {
70
70
  throw new Error(`${source}: kumiko-changes block closed early by a line starting with "-->"; field values must not contain such a line`);
71
71
  }
72
72
  }
73
+ function resolveTitleAndDetail(fields, prose, source) {
74
+ const explicitTitle = fields.get("title")?.trim();
75
+ const title = explicitTitle || prose[0];
76
+ if (!title)
77
+ throw new Error(`${source}: kumiko-changes title is required`);
78
+ // The first prose line is the title's source only when no title was given or
79
+ // the explicit one restates it; otherwise it is genuine detail.
80
+ const firstLineIsTitle = !explicitTitle || sameTitle(explicitTitle, prose[0]);
81
+ const detail = fields.get("detail")?.trim() ||
82
+ prose
83
+ .slice(firstLineIsTitle ? 1 : 0)
84
+ .join("\n")
85
+ .trim();
86
+ return { title, detail };
87
+ }
88
+ function parseManualAfterCodemod(fields, source) {
89
+ const raw = fields.get("manualAfterCodemod")?.trim();
90
+ if (raw !== undefined && raw !== "true" && raw !== "false") {
91
+ throw new Error(`${source}: kumiko-changes manualAfterCodemod must be true or false`);
92
+ }
93
+ return raw === "true";
94
+ }
95
+ function buildChange(fields, prose, source) {
96
+ const feature = requiredField(fields, "feature", source);
97
+ const rawType = requiredField(fields, "type", source);
98
+ if (!TYPE_VALUES.has(rawType)) {
99
+ throw new Error(`${source}: kumiko-changes type must be breaking, improvement, or fix`);
100
+ }
101
+ const { title, detail } = resolveTitleAndDetail(fields, prose, source);
102
+ const migration = fields.get("migration")?.trim();
103
+ const codemod = fields.get("codemod")?.trim();
104
+ const manualAfterCodemod = parseManualAfterCodemod(fields, source);
105
+ const change = {
106
+ feature,
107
+ type: rawType,
108
+ title,
109
+ ...(detail ? { detail } : {}),
110
+ ...(migration ? { migration } : {}),
111
+ ...(codemod ? { codemod } : {}),
112
+ ...(manualAfterCodemod ? { manualAfterCodemod: true } : {}),
113
+ source,
114
+ };
115
+ const validation = validateChangelog({ version: "0.0.0", ...change });
116
+ if (validation.length > 0)
117
+ throw new Error(`${source}: ${validation.join("; ")}`);
118
+ return change;
119
+ }
73
120
  /** Parse structured upgrade metadata embedded in a Changeset body. */
74
121
  export function parseChangesetChanges(markdown, source) {
75
122
  const changes = [];
@@ -85,38 +132,7 @@ export function parseChangesetChanges(markdown, source) {
85
132
  assertBlockNotClosedEarly(markdown, match, source);
86
133
  if (!block)
87
134
  throw new Error(`${source}: empty kumiko-changes block`);
88
- const fields = parseBlock(block, source);
89
- const feature = requiredField(fields, "feature", source);
90
- const rawType = requiredField(fields, "type", source);
91
- if (!TYPE_VALUES.has(rawType)) {
92
- throw new Error(`${source}: kumiko-changes type must be breaking, improvement, or fix`);
93
- }
94
- const explicitTitle = fields.get("title")?.trim();
95
- const title = explicitTitle || prose[0];
96
- if (!title)
97
- throw new Error(`${source}: kumiko-changes title is required`);
98
- // The first prose line is the title's source only when no title was given or
99
- // the explicit one restates it; otherwise it is genuine detail.
100
- const firstLineIsTitle = !explicitTitle || sameTitle(explicitTitle, prose[0]);
101
- const detail = fields.get("detail")?.trim() ||
102
- prose
103
- .slice(firstLineIsTitle ? 1 : 0)
104
- .join("\n")
105
- .trim();
106
- const migration = fields.get("migration")?.trim();
107
- const codemod = fields.get("codemod")?.trim();
108
- const change = {
109
- feature,
110
- type: rawType,
111
- title,
112
- ...(detail ? { detail } : {}),
113
- ...(migration ? { migration } : {}),
114
- ...(codemod ? { codemod } : {}),
115
- source,
116
- };
117
- const validation = validateChangelog({ version: "0.0.0", ...change });
118
- if (validation.length > 0)
119
- throw new Error(`${source}: ${validation.join("; ")}`);
135
+ const change = buildChange(parseBlock(block, source), prose, source);
120
136
  changes.push(change);
121
137
  match = BLOCK_RE.exec(markdown);
122
138
  }
@@ -22,7 +22,18 @@ import { unwrapArrayForm } from "./object-form.js";
22
22
  function resolveFeatureNamesArgs(args) {
23
23
  return unwrapArrayForm(args, "features");
24
24
  }
25
+ // dedupe-features compares options with Object.is per key, so a nested object
26
+ // would make two structurally identical mounts look different and fail the boot
27
+ // with an unobvious "mounted twice with different options".
28
+ function assertDedupeOptionsComparable(featureName, options) {
29
+ for (const [key, value] of Object.entries(options ?? {})) {
30
+ if (typeof value === "object" && value !== null) {
31
+ throw new Error(`[Feature ${featureName}] dedupeOptions.${key} must be a primitive or a stable function reference; dedupeOptions are compared shallowly, so a nested object never matches a second mount`);
32
+ }
33
+ }
34
+ }
25
35
  export function defineFeature(name, setup, settings) {
36
+ assertDedupeOptionsComparable(name, settings?.dedupeOptions);
26
37
  const state = createInitialFeatureBuilderState();
27
38
  const registrar = {
28
39
  systemScope() {
@@ -4,8 +4,6 @@ import { type EntityTable } from "../db/table-builder.js";
4
4
  import type { EntityCrudRegistrar, EntityHandlerOptions, EntityQueryHandlerOptions, EntityWriteHandlerOptions, RegisterEntityCrudOptions } from "./types/entity-handlers.js";
5
5
  import type { AccessRule, EntityDefinition, EscapeHatchDeclaration, QueryHandlerDef, WriteHandlerDef } from "./types/index.js";
6
6
  export type { EntityCrudHandlerDefaults, EntityCrudRegistrar, EntityCrudVerb, EntityHandlerOptions, EntityQueryHandlerOptions, EntityWriteHandlerOptions, RegisterEntityCrudOptions, } from "./types/entity-handlers.js";
7
- export declare function isDeprecatedCrossTenantHandler(handler: object): boolean;
8
- export declare const DEPRECATED_CROSS_TENANT_SIGNAL = "deprecation:entity-handler-cross-tenant";
9
7
  export declare const MAX_LIST_LIMIT = 200;
10
8
  export declare const entityListSchema: z.ZodObject<{
11
9
  cursor: z.ZodOptional<z.ZodString>;
@@ -60,7 +58,9 @@ export declare function createEntityExecutor(entityName: string, entity: EntityD
60
58
  };
61
59
  export declare function defineProjectionQueryHandler(name: string, projectionQualifiedName: string, options: {
62
60
  access: AccessRule;
63
- unsafeAllTenants?: boolean;
61
+ unsafeAllTenants?: boolean | {
62
+ readonly reason: string;
63
+ };
64
64
  escapeHatch?: EscapeHatchDeclaration;
65
65
  }): QueryHandlerDef;
66
66
  /** Register standard entity CRUD handlers in one call. Access stays explicit — no openToAll default. */