@cosmicdrift/kumiko-framework 0.362.0 → 0.364.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 (259) hide show
  1. package/AGENTS.md +307 -0
  2. package/agents/examples/access-control.md +355 -0
  3. package/agents/examples/anonymous-access-multitenant.md +443 -0
  4. package/agents/examples/anonymous-access.md +300 -0
  5. package/agents/examples/apex-landing.md +528 -0
  6. package/agents/examples/apex-surface-auth.md +355 -0
  7. package/agents/examples/api-worker-split.md +434 -0
  8. package/agents/examples/auth-claims.md +351 -0
  9. package/agents/examples/auth-foundation-providers.md +155 -0
  10. package/agents/examples/auth-mfa.md +323 -0
  11. package/agents/examples/basic-entity.md +420 -0
  12. package/agents/examples/boot-check.md +122 -0
  13. package/agents/examples/chat-channels.md +254 -0
  14. package/agents/examples/compliance-profiles-demo.md +307 -0
  15. package/agents/examples/cross-feature-events.md +317 -0
  16. package/agents/examples/crypto-shredding-hr.md +314 -0
  17. package/agents/examples/currencies-global.md +609 -0
  18. package/agents/examples/currencies-per-tenant.md +453 -0
  19. package/agents/examples/custom-fields-basic.md +314 -0
  20. package/agents/examples/custom-handlers.md +397 -0
  21. package/agents/examples/delivery-notifications.md +432 -0
  22. package/agents/examples/designer-demo.md +86 -0
  23. package/agents/examples/e2e-generator.md +211 -0
  24. package/agents/examples/embedded-entity-form.md +598 -0
  25. package/agents/examples/embedded.md +649 -0
  26. package/agents/examples/encrypted-tenant-config.md +405 -0
  27. package/agents/examples/error-contract.md +539 -0
  28. package/agents/examples/event-sourcing.md +1287 -0
  29. package/agents/examples/feature-toggles.md +263 -0
  30. package/agents/examples/field-access.md +271 -0
  31. package/agents/examples/files-post-processing.md +433 -0
  32. package/agents/examples/i18n.md +166 -0
  33. package/agents/examples/idempotency.md +179 -0
  34. package/agents/examples/inbound-mail-triage.md +315 -0
  35. package/agents/examples/lane-routing.md +246 -0
  36. package/agents/examples/ledger-banking.md +214 -0
  37. package/agents/examples/ledger-invoicing.md +235 -0
  38. package/agents/examples/legal-pages.md +393 -0
  39. package/agents/examples/lifecycle-hooks.md +368 -0
  40. package/agents/examples/managed-config.md +428 -0
  41. package/agents/examples/managed-pages.md +353 -0
  42. package/agents/examples/notes-history-basic.md +345 -0
  43. package/agents/examples/ownership.md +396 -0
  44. package/agents/examples/pdf-audit-export.md +270 -0
  45. package/agents/examples/pipeline-basics.md +792 -0
  46. package/agents/examples/public-share-token.md +351 -0
  47. package/agents/examples/rate-limiting.md +211 -0
  48. package/agents/examples/realtime-sse.md +187 -0
  49. package/agents/examples/record-detail-layout.md +297 -0
  50. package/agents/examples/reference-data.md +151 -0
  51. package/agents/examples/relations.md +280 -0
  52. package/agents/examples/screens-nav.md +353 -0
  53. package/agents/examples/search.md +233 -0
  54. package/agents/examples/secrets-demo.md +417 -0
  55. package/agents/examples/security-baseline.md +187 -0
  56. package/agents/examples/seed-migration.md +99 -0
  57. package/agents/examples/session-revocation.md +259 -0
  58. package/agents/examples/state-machine.md +920 -0
  59. package/agents/examples/tags-basic.md +401 -0
  60. package/agents/examples/tenant-isolation.md +173 -0
  61. package/agents/examples/tenant-settings.md +275 -0
  62. package/agents/examples/tier-admin.md +347 -0
  63. package/agents/examples/timezones.md +366 -0
  64. package/agents/examples/user-data-rights.md +410 -0
  65. package/agents/examples/user-profile.md +226 -0
  66. package/agents/examples/webhook-step.md +597 -0
  67. package/agents/examples/wizard-form.md +379 -0
  68. package/agents/examples/workflow-engine.md +1731 -0
  69. package/agents/examples/writeform-section.md +300 -0
  70. package/agents/examples.md +74 -0
  71. package/agents/features.md +768 -0
  72. package/agents/widgets.md +181 -0
  73. package/dist/api/auth-middleware.d.ts +2 -0
  74. package/dist/api/auth-middleware.js +14 -5
  75. package/dist/api/auth-routes.js +5 -3
  76. package/dist/api/extra-route.d.ts +3 -0
  77. package/dist/api/index.d.ts +2 -2
  78. package/dist/api/index.js +1 -1
  79. package/dist/api/redis-sse-broker.js +10 -8
  80. package/dist/api/routes.js +48 -19
  81. package/dist/api/server.d.ts +2 -1
  82. package/dist/api/server.js +18 -6
  83. package/dist/api/session-cookies.d.ts +1 -0
  84. package/dist/api/session-cookies.js +20 -5
  85. package/dist/api/sse-route.js +16 -1
  86. package/dist/bun-db/index.d.ts +1 -1
  87. package/dist/bun-db/index.js +1 -1
  88. package/dist/bun-db/query.d.ts +12 -5
  89. package/dist/bun-db/query.js +88 -94
  90. package/dist/crypto/kek-source.js +5 -3
  91. package/dist/crypto/pii-field-encryption.d.ts +4 -1
  92. package/dist/crypto/pii-field-encryption.js +2 -2
  93. package/dist/db/apply-entity-event.js +2 -2
  94. package/dist/db/collect-table-metas.js +11 -7
  95. package/dist/db/cursor.js +2 -1
  96. package/dist/db/dialect.d.ts +1 -0
  97. package/dist/db/dialect.js +28 -13
  98. package/dist/db/eagerload.js +2 -2
  99. package/dist/db/entity-table-from-registry.js +1 -1
  100. package/dist/db/entity-table-meta.js +5 -13
  101. package/dist/db/event-store-executor-context.d.ts +5 -6
  102. package/dist/db/event-store-executor-context.js +29 -27
  103. package/dist/db/event-store-executor-read.js +40 -25
  104. package/dist/db/event-store-executor-write.js +18 -20
  105. package/dist/db/index.d.ts +1 -1
  106. package/dist/db/index.js +1 -1
  107. package/dist/db/lock-guard.d.ts +4 -0
  108. package/dist/db/lock-guard.js +14 -0
  109. package/dist/db/migrate-generator.js +6 -4
  110. package/dist/db/migrate-runner.js +32 -35
  111. package/dist/db/parent-ref-clause.js +2 -2
  112. package/dist/db/queries/backfill-pii.js +11 -9
  113. package/dist/db/queries/es-ops.js +1 -1
  114. package/dist/db/queries/event-consumer.js +17 -17
  115. package/dist/db/queries/event-store-admin.js +6 -6
  116. package/dist/db/queries/event-store-transfer.js +2 -2
  117. package/dist/db/queries/event-store.js +2 -2
  118. package/dist/db/queries/pii-backfill-state.js +2 -2
  119. package/dist/db/queries/raw-sql.js +1 -1
  120. package/dist/db/queries/test-stack.js +1 -1
  121. package/dist/db/reference-data.js +3 -5
  122. package/dist/db/table-builder.js +6 -4
  123. package/dist/db/table-introspection.d.ts +16 -0
  124. package/dist/db/table-introspection.js +76 -0
  125. package/dist/db/tenant-db-runner.d.ts +4 -0
  126. package/dist/db/tenant-db-runner.js +17 -0
  127. package/dist/db/tenant-db.d.ts +5 -2
  128. package/dist/db/tenant-db.js +34 -14
  129. package/dist/derivatives/derivatives-context.js +1 -1
  130. package/dist/derivatives/variant-key.js +2 -1
  131. package/dist/engine/boot-validator/access-declarations.js +2 -2
  132. package/dist/engine/boot-validator/api-ext.js +3 -3
  133. package/dist/engine/boot-validator/boot-check.js +1 -1
  134. package/dist/engine/boot-validator/config-deps.js +13 -13
  135. package/dist/engine/boot-validator/detail-screens.js +1 -1
  136. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  137. package/dist/engine/boot-validator/entity-handler.js +84 -35
  138. package/dist/engine/boot-validator/entity-list-screens.js +3 -3
  139. package/dist/engine/boot-validator/handler-field-references.js +19 -9
  140. package/dist/engine/boot-validator/i18n-keys.js +2 -2
  141. package/dist/engine/boot-validator/nav.js +1 -1
  142. package/dist/engine/boot-validator/pii-retention.js +9 -9
  143. package/dist/engine/boot-validator/projection-list-screens.js +5 -5
  144. package/dist/engine/boot-validator/query-output-columns.js +7 -7
  145. package/dist/engine/boot-validator/screens.d.ts +2 -1
  146. package/dist/engine/boot-validator/screens.js +155 -96
  147. package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
  148. package/dist/engine/changeset-changes.js +3 -3
  149. package/dist/engine/define-handler.d.ts +2 -2
  150. package/dist/engine/define-handler.js +11 -7
  151. package/dist/engine/embedded-derived.js +10 -6
  152. package/dist/engine/extensions/escape-hatch-usage.js +2 -1
  153. package/dist/engine/factories.js +5 -6
  154. package/dist/engine/feature-ast/extractors/events.js +43 -33
  155. package/dist/engine/feature-ast/extractors/handlers.js +2 -0
  156. package/dist/engine/feature-ast/patcher.d.ts +3 -3
  157. package/dist/engine/feature-ast/patcher.js +3 -3
  158. package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
  159. package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
  160. package/dist/engine/feature-ast/patterns.d.ts +3 -3
  161. package/dist/engine/feature-ast/render.js +16 -5
  162. package/dist/engine/feature-changelog.d.ts +2 -1
  163. package/dist/engine/feature-changelog.js +8 -6
  164. package/dist/engine/feature-config-events-jobs.d.ts +17 -7
  165. package/dist/engine/feature-config-events-jobs.js +20 -5
  166. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  167. package/dist/engine/feature-entity-handlers.js +12 -3
  168. package/dist/engine/feature-ui-extensions.js +2 -3
  169. package/dist/engine/handler-field-references.d.ts +21 -2
  170. package/dist/engine/handler-field-references.js +55 -17
  171. package/dist/engine/index.d.ts +3 -2
  172. package/dist/engine/index.js +1 -0
  173. package/dist/engine/ownership.js +7 -20
  174. package/dist/engine/pattern-library/mixed-schemas.js +0 -1
  175. package/dist/engine/registry-ingest.d.ts +2 -1
  176. package/dist/engine/registry-ingest.js +85 -2
  177. package/dist/engine/registry-validate.d.ts +1 -0
  178. package/dist/engine/registry-validate.js +38 -8
  179. package/dist/engine/registry.js +2 -1
  180. package/dist/engine/schema-builder.js +3 -4
  181. package/dist/engine/soft-delete-cleanup.js +12 -4
  182. package/dist/engine/steps/call-feature.js +1 -3
  183. package/dist/engine/steps/retry.js +2 -1
  184. package/dist/engine/steps/unsafe-projection-delete.js +2 -2
  185. package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
  186. package/dist/engine/template-variables.d.ts +32 -0
  187. package/dist/engine/template-variables.js +80 -0
  188. package/dist/engine/tier-resolver-extension.js +2 -3
  189. package/dist/engine/types/index.d.ts +2 -2
  190. package/dist/engine/validate-projection-allowlist.js +12 -13
  191. package/dist/engine/where-rule-lint.js +8 -7
  192. package/dist/env/_zod-introspect.d.ts +1 -0
  193. package/dist/errors/classes.js +1 -0
  194. package/dist/errors/kumiko-error.d.ts +2 -0
  195. package/dist/errors/kumiko-error.js +2 -0
  196. package/dist/errors/reasons.d.ts +1 -0
  197. package/dist/errors/reasons.js +2 -0
  198. package/dist/errors/write-error-info.js +2 -0
  199. package/dist/event-store/tenant-db-append.js +2 -2
  200. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
  201. package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
  202. package/dist/files/bind-pending-file-refs.d.ts +2 -0
  203. package/dist/files/bind-pending-file-refs.js +3 -3
  204. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  205. package/dist/files/bind-referenced-files.write.js +22 -13
  206. package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
  207. package/dist/files/create-file-ref-from-bytes.js +67 -4
  208. package/dist/files/create-file-ref.d.ts +22 -0
  209. package/dist/files/create-file-ref.js +53 -25
  210. package/dist/files/file-handle.d.ts +1 -1
  211. package/dist/files/file-handle.js +8 -0
  212. package/dist/files/file-routes.js +2 -2
  213. package/dist/files/index.d.ts +2 -2
  214. package/dist/files/index.js +1 -1
  215. package/dist/files/load-save-file-refs.d.ts +10 -0
  216. package/dist/files/load-save-file-refs.js +47 -0
  217. package/dist/files/local-provider.js +2 -1
  218. package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
  219. package/dist/files/purge-expired-file-ref-bytes.js +45 -19
  220. package/dist/i18n/index.d.ts +1 -1
  221. package/dist/i18n/index.js +1 -1
  222. package/dist/i18n/mail-registry.d.ts +2 -0
  223. package/dist/i18n/mail-registry.js +7 -0
  224. package/dist/jobs/index.d.ts +1 -1
  225. package/dist/jobs/job-runner.d.ts +4 -1
  226. package/dist/jobs/job-runner.js +13 -9
  227. package/dist/observability/escape-hatch-report.d.ts +1 -1
  228. package/dist/observability/escape-hatch-report.js +17 -5
  229. package/dist/pipeline/cascade-handler.js +5 -2
  230. package/dist/pipeline/dispatch-batch.js +79 -31
  231. package/dist/pipeline/dispatch-query.js +2 -3
  232. package/dist/pipeline/dispatch-shared.d.ts +7 -2
  233. package/dist/pipeline/dispatch-shared.js +142 -34
  234. package/dist/pipeline/dispatch-write.js +34 -22
  235. package/dist/pipeline/event-dispatcher-delivery.js +2 -3
  236. package/dist/pipeline/index.d.ts +2 -0
  237. package/dist/pipeline/index.js +2 -0
  238. package/dist/pipeline/member-read-only-transaction.js +1 -1
  239. package/dist/pipeline/notify-scope.d.ts +13 -0
  240. package/dist/pipeline/notify-scope.js +32 -0
  241. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
  242. package/dist/pipeline/pre-transaction-reservations.js +28 -9
  243. package/dist/pipeline/system-hooks.js +2 -1
  244. package/dist/pipeline/system-identity-switch.js +2 -2
  245. package/dist/rate-limit/bucket.d.ts +3 -3
  246. package/dist/rate-limit/bucket.js +14 -4
  247. package/dist/redis/cache-sync-bus.js +9 -3
  248. package/dist/search/meilisearch-adapter.js +39 -13
  249. package/dist/secrets/stored-envelope.js +7 -7
  250. package/dist/stack/push-entity-projection-tables.js +2 -3
  251. package/dist/stack/table-helpers.js +10 -26
  252. package/dist/stack/test-stack.js +2 -5
  253. package/dist/testing/multipart-helper.d.ts +0 -6
  254. package/dist/testing/multipart-helper.js +5 -5
  255. package/dist/testing/without-ambient-temporal.js +4 -4
  256. package/dist/utils/is-plain-object.d.ts +1 -2
  257. package/dist/utils/is-plain-object.js +1 -4
  258. package/package.json +7 -5
  259. package/src/changes.json +251 -8
@@ -6,6 +6,7 @@
6
6
  import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
7
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
+ import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
9
10
  import { rowMetaFieldNames } from "../../db/table-builder.js";
10
11
  import { REFERENCE_LOOKUP_SOURCES } from "../../ui-types/list-row-meta.js";
11
12
  import { isEncryptedAtRest } from "../config-helpers.js";
@@ -23,7 +24,7 @@ function validateAtMostOneRowClick(featureName, screenId, screenType, rowActions
23
24
  const rowClickActions = rowActions.filter((a) => a.kind === "navigate" && a.rowClick === true);
24
25
  if (rowClickActions.length > 1) {
25
26
  throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has ${rowClickActions.length} ` +
26
- "rowActions marked rowClick:true — at most one may fire on a row-body click.");
27
+ "rowActions marked rowClick:true — at most one may fire on a row-body click. Mark at most one rowAction with rowClick: true.");
27
28
  }
28
29
  }
29
30
  // A field type in NO_WIDGET_FIELD_TYPES renders read-only on the auto-wired
@@ -44,7 +45,7 @@ function validateEditFieldExtras(featureName, screenId, entityDef, fieldSpec) {
44
45
  if (listSpec !== undefined) {
45
46
  if (fieldDef.type !== "embedded" || fieldDef.multiple !== true) {
46
47
  throw new Error(`${where} sets embeddedList, which only applies to an embedded list ` +
47
- `(createEmbeddedListField); the field is type "${fieldDef.type}".`);
48
+ `(createEmbeddedListField); the field is type "${fieldDef.type}". Remove embeddedList or point it at an embedded list field.`);
48
49
  }
49
50
  for (const cell of listSpec.readOnlyCells ?? []) {
50
51
  if (fieldDef.schema[cell] === undefined) {
@@ -59,7 +60,7 @@ function validateEditFieldExtras(featureName, screenId, entityDef, fieldSpec) {
59
60
  return;
60
61
  if (prefill.kind === "today") {
61
62
  if (fieldDef.type !== "date") {
62
- throw new Error(`${where} prefill "today" needs a date field; the field is type "${fieldDef.type}".`);
63
+ throw new Error(`${where} prefill "today" needs a date field; the field is type "${fieldDef.type}". Use a date field or a different prefill.`);
63
64
  }
64
65
  // skip: today carries no property, nothing further to check
65
66
  return;
@@ -67,7 +68,7 @@ function validateEditFieldExtras(featureName, screenId, entityDef, fieldSpec) {
67
68
  const allowsUserId = fieldDef.type === "text" || fieldDef.type === "reference";
68
69
  if (prefill.property === "id" ? !allowsUserId : fieldDef.type !== "text") {
69
70
  throw new Error(`${where} prefill currentUser.${prefill.property} does not fit a field of type ` +
70
- `"${fieldDef.type}" (displayName and email need text; id needs text or a reference).`);
71
+ `"${fieldDef.type}" (displayName and email need text; id needs text or a reference). Use a fitting field type or a different prefill property.`);
71
72
  }
72
73
  }
73
74
  function validateNoWidgetRequiredField(featureName, screenId, entityDef, fieldSpec) {
@@ -120,7 +121,7 @@ export function readsNavigateParamsAsFormPrefill(target, explicitEntityId, sourc
120
121
  // for the value to land, and a params extractor whose keys don't include
121
122
  // the filter's id would silently miss it — both are boot errors instead of
122
123
  // a silently-empty dashboard.
123
- function validateRowActionNavigateParams(featureName, screenId, screenType, screenEntity, action, target) {
124
+ function validateRowActionNavigateParams(featureName, screenId, screenType, screenEntity, action, target, actionLabel = "rowAction") {
124
125
  // skip: not a navigate-with-params action — nothing to validate here.
125
126
  if (action.kind !== "navigate" || action.params === undefined)
126
127
  return;
@@ -136,14 +137,14 @@ function validateRowActionNavigateParams(featureName, screenId, screenType, scre
136
137
  const targetDescriptor = action.screen !== undefined ? `"${action.screen}"` : `entity "${action.entity}"`;
137
138
  const filter = target.screen.filter;
138
139
  if (filter === undefined) {
139
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) rowAction "${action.id}" sets ` +
140
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" sets ` +
140
141
  `params on navigate-target ${targetDescriptor} (dashboard) — target dashboard declares no ` +
141
142
  `filter — params would be a no-op. Remove the params extractor, or add a \`filter\` to the ` +
142
143
  `target dashboard so it has somewhere to read the value from.`);
143
144
  }
144
145
  const extractedKeys = rowFieldExtractorKeys(action.params);
145
146
  if (!extractedKeys.includes(filter.id)) {
146
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) rowAction "${action.id}" sets ` +
147
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" sets ` +
147
148
  `params [${extractedKeys.join(", ")}] on navigate-target ${targetDescriptor} (dashboard) whose ` +
148
149
  `filter id is "${filter.id}" — none of the extracted keys match, so the filter would stay ` +
149
150
  `unset. Fix the params extractor to produce a "${filter.id}" key, or remove it.`);
@@ -159,7 +160,7 @@ function validateRowActionNavigateParams(featureName, screenId, screenType, scre
159
160
  : `same entity "${screenEntity}" auto-fills row["id"]`})`
160
161
  : `screen type "${target.screen.type}"`;
161
162
  const targetDescriptor = action.screen !== undefined ? `"${action.screen}"` : `entity "${action.entity}"`;
162
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) rowAction "${action.id}" ` +
163
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
163
164
  `sets params on navigate-target ${targetDescriptor} which ${reason} — only actionForm, ` +
164
165
  `secretMint and entityEdit-create targets read URL search params as initial values. Remove ` +
165
166
  `the params extractor or retarget to an actionForm / secretMint / cross-entity ` +
@@ -168,7 +169,7 @@ function validateRowActionNavigateParams(featureName, screenId, screenType, scre
168
169
  const formTarget = target.screen;
169
170
  if ((formTarget.type === "actionForm" || formTarget.type === "secretMint") &&
170
171
  formTarget.submitPrefilled === true) {
171
- validateDrawerPrefillKeys(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) rowAction "${action.id}"`, "navigate-target", action.screen ?? `entity "${action.entity}"`, formTarget, action.params);
172
+ validateDrawerPrefillKeys(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}"`, "navigate-target", action.screen ?? `entity "${action.entity}"`, formTarget, action.params);
172
173
  }
173
174
  }
174
175
  // Shared by projectionDetail and entityEdit: a tab strip needs something to
@@ -196,7 +197,7 @@ function validateTabSections(featureName, screenId, screenType, sections) {
196
197
  }
197
198
  if (tabIds.has(section.id)) {
198
199
  throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has duplicate tab id ` +
199
- `"${section.id}" (sections[${index}]).`);
200
+ `"${section.id}" (sections[${index}]). Give each tab section a unique id.`);
200
201
  }
201
202
  tabIds.add(section.id);
202
203
  });
@@ -275,7 +276,7 @@ function validateWizardLayout(featureName, screenId, screenType, layout, feature
275
276
  // for an unregistered key instead of crashing — this check makes the same
276
277
  // gap fail loudly at boot instead of silently at render.
277
278
  function isRegisteredIcon(icon) {
278
- return NAV_ICON_KEYS.includes(icon);
279
+ return isOneOf(NAV_ICON_KEYS, icon);
279
280
  }
280
281
  export function validateActionHasIcon(featureName, screenId, screenType, context, action) {
281
282
  const resolved = resolveActionIcon(action.id, action.icon);
@@ -398,7 +399,7 @@ function validateDrawerTargetAction(featureName, screenId, screenKind, actionLab
398
399
  return;
399
400
  validateDrawerPrefillKeys(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}"`, "drawer-target", action.screen, target, action.params);
400
401
  }
401
- function validateDrawerPrefillKeys(where, targetLabel, targetScreenId, target, params) {
402
+ export function validateDrawerPrefillKeys(where, targetLabel, targetScreenId, target, params) {
402
403
  const renderedFieldNames = new Set();
403
404
  for (const section of target.layout.sections) {
404
405
  if (!isFieldsEditSection(section))
@@ -418,7 +419,7 @@ function validateDrawerPrefillKeys(where, targetLabel, targetScreenId, target, p
418
419
  ("format" in targetField && targetField.format === "password"));
419
420
  if (isSensitiveOrPassword) {
420
421
  throw new Error(`${where} params prefills "${fieldName}", which ${targetLabel} "${targetScreenId}" marks as ` +
421
- `sensitive or password — the renderer never prefills those fields and would leave the form empty.`);
422
+ `sensitive or password — the renderer never prefills those fields and would leave the form empty. Remove that param or prefill a non-sensitive field.`);
422
423
  }
423
424
  if (!renderedFieldNames.has(fieldName)) {
424
425
  throw new Error(`${where} params prefills "${fieldName}", which ${targetLabel} "${targetScreenId}" does not render in ` +
@@ -434,12 +435,12 @@ function validateRowActionNavigateTab(featureName, screenId, screenType, actionL
434
435
  return;
435
436
  if (target === undefined) {
436
437
  throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
437
- `sets tab "${action.tab}", but its navigate-target could not be resolved to a screen.`);
438
+ `sets tab "${action.tab}", but its navigate-target could not be resolved to a screen. Point the navigate-target at a registered screen.`);
438
439
  }
439
440
  if (target.screen.type !== "projectionDetail" || target.screen.layout.mode !== "tabs") {
440
441
  throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
441
442
  `sets tab "${action.tab}", but navigate-target screen "${target.screen.id}" is not a ` +
442
- `projectionDetail with layout.mode "tabs" — only those read the tab param.`);
443
+ `projectionDetail with layout.mode "tabs" — only those read the tab param. Remove the tab or navigate to a projectionDetail screen with layout.mode "tabs".`);
443
444
  }
444
445
  const sectionIds = target.screen.layout.sections
445
446
  .map((section) => section.id)
@@ -470,7 +471,7 @@ function resolveRowActionNavigateTarget(featureName, screenId, screenType, actio
470
471
  if (detail === undefined) {
471
472
  throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
472
473
  `navigate-target entity "${action.entity}" has no screen declaring ` +
473
- `detailFor: "${action.entity}".`);
474
+ `detailFor: "${action.entity}". Add detailFor: "${action.entity}" to a screen or navigate to a screen directly.`);
474
475
  }
475
476
  validateRowActionNavigateTab(featureName, screenId, screenType, actionLabel, action, detail);
476
477
  return detail;
@@ -482,7 +483,7 @@ function resolveRowActionNavigateTarget(featureName, screenId, screenType, actio
482
483
  const candidateQn = qualifyEntityName(featureName, "screen", action.screen);
483
484
  if (!allScreenQns.has(candidateQn) && !navTargetShortIds.has(action.screen)) {
484
485
  throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
485
- `navigate-target "${action.screen}" does not resolve to a registered screen in any feature.`);
486
+ `navigate-target "${action.screen}" does not resolve to a registered screen in any feature. Use "<feature>:screen:<id>" of an existing screen.`);
486
487
  }
487
488
  const target = screensByShortId.get(action.screen)?.[0];
488
489
  validateRowActionNavigateTab(featureName, screenId, screenType, actionLabel, action, target);
@@ -493,7 +494,7 @@ function resolveRowActionNavigateTarget(featureName, screenId, screenType, actio
493
494
  // same registration requirement.
494
495
  function validateWriteHandlerRegistered(featureName, screenId, context, handler, allWriteHandlerQns) {
495
496
  if (!handler || typeof handler !== "string") {
496
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) has empty or non-string handler.`);
497
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) has empty or non-string handler. Set handler to a non-empty handler name.`);
497
498
  }
498
499
  if (!allWriteHandlerQns.has(handler)) {
499
500
  throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) handler "${handler}" ` +
@@ -539,7 +540,7 @@ function validateFormFieldsMap(featureName, screenId, context, fields, allowEmpt
539
540
  function validateOptionsQueryFieldRefs(where, fieldName, payload, siblingFieldNames) {
540
541
  for (const ref of optionsQueryFieldRefs(payload)) {
541
542
  if (ref === fieldName) {
542
- throw new Error(`${where} optionsQueryPayload references itself ({ field: "${ref}" })`);
543
+ throw new Error(`${where} optionsQueryPayload references itself ({ field: "${ref}" }). Reference a different field of the form.`);
543
544
  }
544
545
  if (!siblingFieldNames.has(ref)) {
545
546
  throw new Error(`${where} optionsQueryPayload references unknown field "${ref}" — it must be another field of the same form`);
@@ -567,30 +568,33 @@ function validateFormSelectOptions(featureName, screenId, context, fieldName, fd
567
568
  if (fdef.type === "select") {
568
569
  const where = `[Feature ${featureName}] Screen "${screenId}" (${context}) select field "${fieldName}"`;
569
570
  if (fdef.conditionalOptions !== undefined) {
570
- throw new Error(`${where} declares conditionalOptions — ${CONDITIONAL_OPTIONS_ENTITY_ONLY}`);
571
+ throw new Error(`${where} declares conditionalOptions — ${CONDITIONAL_OPTIONS_ENTITY_ONLY}. Remove conditionalOptions from this field.`);
571
572
  }
572
573
  if (fdef.optionsQuery !== undefined) {
573
574
  if (fdef.optionsQuery.length === 0) {
574
- throw new Error(`${where} has an empty optionsQuery`);
575
+ throw new Error(`${where} has an empty optionsQuery. Set it to a query name or remove it.`);
575
576
  }
576
577
  if (fdef.options.length > 0) {
577
578
  throw new Error(`${where} declares both options and optionsQuery — pick one`);
578
579
  }
580
+ if (fdef.optionsAvailabilityQuery !== undefined) {
581
+ throw new Error(`${where} declares both optionsQuery and optionsAvailabilityQuery — availability only applies to static options`);
582
+ }
579
583
  validateOptionsQueryFieldRefs(where, fieldName, fdef.optionsQueryPayload, siblingFieldNames);
580
584
  }
581
585
  else if (fdef.optionsAvailabilityQuery !== undefined) {
582
586
  if (fdef.optionsAvailabilityQuery.length === 0) {
583
- throw new Error(`${where} has an empty optionsAvailabilityQuery`);
587
+ throw new Error(`${where} has an empty optionsAvailabilityQuery. Set it to a query name or remove it.`);
584
588
  }
585
589
  validateOptionsQueryFieldRefs(where, fieldName, fdef.optionsQueryPayload, siblingFieldNames);
586
590
  }
587
591
  else if (fdef.optionsQueryPayload !== undefined) {
588
- throw new Error(`${where} has optionsQueryPayload without optionsQuery`);
592
+ throw new Error(`${where} has optionsQueryPayload without optionsQuery. Set optionsQuery or remove optionsQueryPayload.`);
589
593
  }
590
594
  }
591
595
  else if (fdef.type === "multiSelect" && fdef.optionsAvailabilityQuery !== undefined) {
592
596
  if (fdef.optionsAvailabilityQuery.length === 0) {
593
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) multiSelect field "${fieldName}" has an empty optionsAvailabilityQuery`);
597
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) multiSelect field "${fieldName}" has an empty optionsAvailabilityQuery. Set it to a query name or remove it.`);
594
598
  }
595
599
  }
596
600
  }
@@ -663,11 +667,11 @@ function validateFormLayoutSections(featureName, screenId, context, layout, fiel
663
667
  if (section.kind === "relatedList") {
664
668
  throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) relatedList section ` +
665
669
  `"${section.title}" is not supported — relatedList is a projectionDetail-only ` +
666
- `primitive (fw#2166).`);
670
+ `primitive (fw#2166). Move the section to a projectionDetail screen or remove it.`);
667
671
  }
668
672
  if (isWriteFormEditSection(section)) {
669
673
  throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) writeForm section ` +
670
- `"${section.title}" is not supported — writeForm is a projectionDetail-only primitive.`);
674
+ `"${section.title}" is not supported — writeForm is a projectionDetail-only primitive. Move the section to a projectionDetail screen or remove it.`);
671
675
  }
672
676
  validateFieldsXorGroups(`[Feature ${featureName}] Screen "${screenId}" (${context})`, section);
673
677
  for (const fieldSpec of sectionFieldSpecs(section)) {
@@ -699,7 +703,7 @@ function validateRedirectTarget(feature, screenId, screenKind, redirect, lookups
699
703
  // never matches and the navigation silently lands without an entityId.
700
704
  if (typeof redirect !== "string" && redirect.idFrom !== redirect.idFrom.trim()) {
701
705
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screenKind}) redirect.idFrom "${redirect.idFrom}" ` +
702
- `has leading/trailing whitespace — it would never match a payload field.`);
706
+ `has leading/trailing whitespace — it would never match a payload field. Remove the whitespace from idFrom.`);
703
707
  }
704
708
  validateScreenNavTarget(feature.name, screenId, screenKind, "redirect", redirectTarget, allScreenQns, feature.screens);
705
709
  // idFrom only means something to a target that carries a record id; the
@@ -757,10 +761,10 @@ function validateScreenVisibleWhen(feature, screenId, screen, allScreenQns, feat
757
761
  const { visibleWhen, fallback } = screen;
758
762
  const context = `[Feature ${feature.name}] Screen "${screenId}" (${screen.type})`;
759
763
  if (visibleWhen === undefined && fallback !== undefined) {
760
- throw new Error(`${context} declares fallback without visibleWhen.`);
764
+ throw new Error(`${context} declares fallback without visibleWhen. Add visibleWhen or remove fallback.`);
761
765
  }
762
766
  if (visibleWhen !== undefined && (visibleWhen.query === "" || visibleWhen.field === "")) {
763
- throw new Error(`${context} visibleWhen needs a non-empty query and field.`);
767
+ throw new Error(`${context} visibleWhen needs a non-empty query and field. Set both visibleWhen.query and visibleWhen.field.`);
764
768
  }
765
769
  if (fallback !== undefined) {
766
770
  validateScreenFallback(feature, screenId, screen.type, fallback, allScreenQns, featureMap);
@@ -771,7 +775,7 @@ function validateScreenFallback(feature, screenId, screenType, fallback, allScre
771
775
  validateScreenNavTarget(feature.name, screenId, screenType, "fallback", fallback, allScreenQns, feature.screens);
772
776
  const fallbackQn = resolveScreenTargetQn(feature.name, fallback);
773
777
  if (fallbackQn === qualifyEntityName(feature.name, "screen", screenId)) {
774
- throw new Error(`${context} fallback points at the screen itself.`);
778
+ throw new Error(`${context} fallback points at the screen itself. Point fallback at a different screen.`);
775
779
  }
776
780
  // A gated fallback could hide in turn and chain or cycle (A→B→A); one hop keeps the
777
781
  // runtime gate trivially terminating.
@@ -835,7 +839,7 @@ function validateSecretMintConfirm(feature, screenId, screen, allWriteHandlerQns
835
839
  for (const carryField of confirm.carry) {
836
840
  if (typeof carryField !== "string" || carryField.trim() === "") {
837
841
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${context}) has an empty or non-string ` +
838
- `entry in "carry".`);
842
+ `entry in "carry". Use non-empty strings in carry.`);
839
843
  }
840
844
  if (fieldNames.has(carryField)) {
841
845
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${context}) carry field "${carryField}" ` +
@@ -914,7 +918,7 @@ function validateScreenHasNavArea(feature, screenId, screen, featureMap) {
914
918
  function validateRelatedListSection(args) {
915
919
  const { featureName, featureMap, where, section } = args;
916
920
  if (!section.query || typeof section.query !== "string") {
917
- throw new Error(`[Feature ${featureName}] ${where} has empty or non-string query.`);
921
+ throw new Error(`[Feature ${featureName}] ${where} has empty or non-string query. Set query to a registered query name.`);
918
922
  }
919
923
  if (section.entity !== undefined) {
920
924
  assertRefTargetRegistered(`[Feature ${featureName}] ${where}`, "entity", section.entity, featureName, featureMap);
@@ -945,23 +949,51 @@ function validateRelatedListSection(args) {
945
949
  validateRelatedListToolbarActions(args, section.toolbarActions);
946
950
  }
947
951
  }
952
+ // An empty list has no row, so params resolve against an empty record.
953
+ function rejectEmptyStateActionParams(ctx, actionLabel, action) {
954
+ const params = action.kind === "navigate" || action.kind === "drawer" ? action.params : undefined;
955
+ if (params !== undefined) {
956
+ throw new Error(`[Feature ${ctx.featureName}] ${ctx.where} ${actionLabel} "${action.id}" sets params — an empty list ` +
957
+ `has no row to read them from, so they would resolve against an empty record. Remove the params.`);
958
+ }
959
+ }
960
+ // Target checks shared by list/related-list rowActions and emptyState.action:
961
+ // navigate target + params, drawer target, write-handler registration.
962
+ function validateListActionTargets(ctx, actionLabel, action) {
963
+ const { featureName, screenId, screenType } = ctx;
964
+ rejectRedirectOnListRowAction(featureName, screenId, screenType, action);
965
+ if (actionLabel === "emptyState action")
966
+ rejectEmptyStateActionParams(ctx, actionLabel, action);
967
+ if (action.kind === "navigate") {
968
+ const target = resolveRowActionNavigateTarget(featureName, screenId, screenType, actionLabel, action, ctx.allScreenQns, ctx.navTargetShortIds, ctx.screensByShortId, ctx.detailForScreens);
969
+ validateRowActionNavigateParams(featureName, screenId, screenType, ctx.screenEntity, action, target, actionLabel);
970
+ }
971
+ else if (action.kind === "drawer") {
972
+ validateDrawerTargetAction(featureName, screenId, screenType, actionLabel, action, ctx.screens);
973
+ }
974
+ else if (!ctx.allWriteHandlerQns.has(action.handler)) {
975
+ throw new Error(`[Feature ${featureName}] ${ctx.where} ` +
976
+ `${actionLabel} "${action.id}" handler "${action.handler}" ` +
977
+ `is not a registered write-handler. Check the QN spelling (expected ` +
978
+ `"<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`);
979
+ }
980
+ }
948
981
  function validateRelatedListRowActions(args, rowActions) {
949
982
  const { featureName, screenId, screenType, where, section, screens, allWriteHandlerQns, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens, } = args;
950
983
  for (const action of rowActions) {
951
- rejectRedirectOnListRowAction(featureName, screenId, screenType, action);
952
- if (action.kind === "navigate") {
953
- const target = resolveRowActionNavigateTarget(featureName, screenId, screenType, "rowAction", action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
954
- validateRowActionNavigateParams(featureName, screenId, screenType, undefined, action, target);
955
- }
956
- else if (action.kind === "drawer") {
957
- validateDrawerTargetAction(featureName, screenId, screenType, "rowAction", action, screens);
958
- }
959
- else if (!allWriteHandlerQns.has(action.handler)) {
960
- throw new Error(`[Feature ${featureName}] ${where} ` +
961
- `rowAction "${action.id}" handler "${action.handler}" ` +
962
- `is not a registered write-handler. Check the QN spelling (expected ` +
963
- `"<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`);
964
- }
984
+ validateListActionTargets({
985
+ featureName,
986
+ screenId,
987
+ screenType,
988
+ screenEntity: undefined,
989
+ where,
990
+ screens,
991
+ allWriteHandlerQns,
992
+ allScreenQns,
993
+ navTargetShortIds,
994
+ screensByShortId,
995
+ detailForScreens,
996
+ }, "rowAction", action);
965
997
  }
966
998
  // section.rowClick (legacy, navigate-only) and a rowActions entry
967
999
  // marked rowClick:true both claim the row-body click — same
@@ -981,7 +1013,7 @@ function validateRelatedListDefaultSort(args, defaultSort) {
981
1013
  const col = section.columns.find((c) => normalizeListColumn(c).field === sortField);
982
1014
  if (col === undefined) {
983
1015
  throw new Error(`[Feature ${featureName}] ${where} ` +
984
- `defaultSort.field "${sortField}" is not a listed column.`);
1016
+ `defaultSort.field "${sortField}" is not a listed column. Use a listed column as defaultSort.field.`);
985
1017
  }
986
1018
  if (normalizeListColumn(col).sortable !== true) {
987
1019
  throw new Error(`[Feature ${featureName}] ${where} ` +
@@ -1031,7 +1063,16 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1031
1063
  if ((screen.type === "entityList" || screen.type === "projectionList") &&
1032
1064
  screen.emptyState?.action !== undefined) {
1033
1065
  validateActionHasIcon(feature.name, screenId, screen.type, "emptyState action", screen.emptyState.action);
1034
- validateNavigateActionTab(feature.name, screenId, screen.type, "emptyState action", screen.emptyState.action, navigateLookups);
1066
+ validateListActionTargets({
1067
+ featureName: feature.name,
1068
+ screenId,
1069
+ screenType: screen.type,
1070
+ screenEntity: undefined,
1071
+ where: `Screen "${screenId}" (${screen.type})`,
1072
+ screens: feature.screens,
1073
+ allWriteHandlerQns,
1074
+ ...navigateLookups,
1075
+ }, "emptyState action", screen.emptyState.action);
1035
1076
  }
1036
1077
  // Output of buildAppSchema, which overwrites it — an authored value would be a silent no-op.
1037
1078
  if ((screen.type === "entityEdit" ||
@@ -1062,7 +1103,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1062
1103
  // Column-Felder können nicht gegen eine Entity gecheckt werden; sie
1063
1104
  // werden zur Render-Zeit gegen die Projection-Rows aufgelöst).
1064
1105
  if (!screen.query || typeof screen.query !== "string") {
1065
- throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionList) has empty or non-string query.`);
1106
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionList) has empty or non-string query. Set query to a registered query name.`);
1066
1107
  }
1067
1108
  if (screen.columns.length === 0) {
1068
1109
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionList) has an empty columns list — ` +
@@ -1084,7 +1125,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1084
1125
  screen.filter.op === "in" &&
1085
1126
  !Array.isArray(screen.filter.value)) {
1086
1127
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionList) filter.op "in" requires ` +
1087
- `filter.value to be a readonly array.`);
1128
+ `filter.value to be a readonly array. Pass an array as filter.value.`);
1088
1129
  }
1089
1130
  if (screen.facets !== undefined) {
1090
1131
  validateListFacets(`[Feature ${feature.name}] Screen "${screenId}" (projectionList)`, screen.facets, screen.columns, feature.name, featureMap);
@@ -1122,7 +1163,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1122
1163
  // Columns. Kein Entity-Check möglich — Felder werden render-seitig
1123
1164
  // gegen die Query-Response aufgelöst, nicht gegen eine Entity.
1124
1165
  if (!screen.query || typeof screen.query !== "string") {
1125
- throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has empty or non-string query.`);
1166
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has empty or non-string query. Set query to a registered query name.`);
1126
1167
  }
1127
1168
  if (screen.singleton === true && screen.idParam !== undefined) {
1128
1169
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) sets both singleton: true ` +
@@ -1175,7 +1216,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1175
1216
  if (navigate.tab !== undefined &&
1176
1217
  !screen.layout.sections.some((section) => section.id === navigate.tab)) {
1177
1218
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) metric "${field}" ` +
1178
- `navigates to tab "${navigate.tab}", which is not a section id on this screen.`);
1219
+ `navigates to tab "${navigate.tab}", which is not a section id on this screen. Navigate to an existing section id or remove navigate.tab.`);
1179
1220
  }
1180
1221
  }
1181
1222
  else {
@@ -1204,14 +1245,14 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1204
1245
  if (Array.isArray(screen.header?.subtitle)) {
1205
1246
  if (screen.header.subtitleHref !== undefined) {
1206
1247
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) sets header.subtitleHref ` +
1207
- `together with a multi-part header.subtitle — subtitleHref only works with the string form.`);
1248
+ `together with a multi-part header.subtitle — subtitleHref only works with the string form. Use a single-field subtitle or remove subtitleHref.`);
1208
1249
  }
1209
1250
  const seenPartFields = new Set();
1210
1251
  for (const part of screen.header.subtitle) {
1211
1252
  const partField = typeof part === "string" ? part : part.field;
1212
1253
  if (seenPartFields.has(partField)) {
1213
1254
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) header.subtitle ` +
1214
- `lists field "${partField}" twice.`);
1255
+ `lists field "${partField}" twice. List each field once.`);
1215
1256
  }
1216
1257
  seenPartFields.add(partField);
1217
1258
  const navigate = typeof part === "string" ? undefined : part.navigate;
@@ -1243,7 +1284,16 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1243
1284
  }
1244
1285
  if (section.kind === "relatedList" && section.emptyState?.action !== undefined) {
1245
1286
  validateActionHasIcon(feature.name, screenId, "projectionDetail", `section "${sectionLabel}" emptyState action`, section.emptyState.action);
1246
- validateNavigateActionTab(feature.name, screenId, "projectionDetail", `section "${sectionLabel}" emptyState action`, section.emptyState.action, navigateLookups);
1287
+ validateListActionTargets({
1288
+ featureName: feature.name,
1289
+ screenId,
1290
+ screenType: "projectionDetail",
1291
+ screenEntity: undefined,
1292
+ where: `Screen "${screenId}" (projectionDetail) section "${sectionLabel}"`,
1293
+ screens: feature.screens,
1294
+ allWriteHandlerQns,
1295
+ ...navigateLookups,
1296
+ }, "emptyState action", section.emptyState.action);
1247
1297
  }
1248
1298
  if (isExtensionEditSection(section)) {
1249
1299
  // projectionDetail is read-only (no composed form submit) — an
@@ -1317,7 +1367,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1317
1367
  if (section.fieldDefs[fieldName] === undefined) {
1318
1368
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
1319
1369
  `(writeForm) field "${fieldName}" has no entry in fieldDefs — every rendered field needs ` +
1320
- `a type declared there.`);
1370
+ `a type declared there. Add the field to fieldDefs.`);
1321
1371
  }
1322
1372
  }
1323
1373
  if (screen.layout.mode === "wizard") {
@@ -1410,11 +1460,11 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1410
1460
  if (section.kind === "relatedList") {
1411
1461
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) relatedList section ` +
1412
1462
  `"${section.title}" is not supported — relatedList is a projectionDetail-only ` +
1413
- `primitive (fw#2166).`);
1463
+ `primitive (fw#2166). Move the section to a projectionDetail screen or remove it.`);
1414
1464
  }
1415
1465
  if (isWriteFormEditSection(section)) {
1416
1466
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) writeForm section ` +
1417
- `"${section.title}" is not supported — writeForm is a projectionDetail-only primitive.`);
1467
+ `"${section.title}" is not supported — writeForm is a projectionDetail-only primitive. Move the section to a projectionDetail screen or remove it.`);
1418
1468
  }
1419
1469
  validateFieldsXorGroups(`[Feature ${feature.name}] Screen "${screenId}" (configEdit)`, section);
1420
1470
  for (const fieldSpec of sectionFieldSpecs(section)) {
@@ -1431,15 +1481,15 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1431
1481
  for (const fname of fieldNames) {
1432
1482
  const qualified = screen.configKeys[fname];
1433
1483
  if (qualified === undefined) {
1434
- throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) field "${fname}" hat ` +
1435
- `keinen Eintrag in configKeys-Map. Jedes deklarierte Field braucht ein Mapping zu ` +
1436
- `einem qualifizierten Config-Key (\`<feature>:config:<short>\`).`);
1484
+ throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) field "${fname}" has ` +
1485
+ `no entry in the configKeys map. Every declared field needs a mapping to a ` +
1486
+ `qualified config key (\`<feature>:config:<short>\`). Add one to configKeys.`);
1437
1487
  }
1438
1488
  const configKeyDef = configKeyDefsByQn.get(qualified);
1439
1489
  if (configKeyDef === undefined) {
1440
1490
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) field "${fname}" → ` +
1441
- `Config-Key "${qualified}" ist in keiner Feature-Registry deklariert. Tippfehler? ` +
1442
- `Erwartetes Format: "<feature>:config:<short>". Bekannte Keys: ${[...configKeyDefsByQn.keys()].sort().join(", ") || "(keine)"}`);
1491
+ `Config key "${qualified}" is not declared in any feature registry. Typo? ` +
1492
+ `Expected format: "<feature>:config:<short>". Known keys: ${[...configKeyDefsByQn.keys()].sort().join(", ") || "(none)"}`);
1443
1493
  }
1444
1494
  if (isWriteOnlyField(screen.fields[fname]) && !isEncryptedAtRest(configKeyDef)) {
1445
1495
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (configEdit) field "${fname}" declares writeOnly ` +
@@ -1463,7 +1513,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1463
1513
  for (const revealField of screen.reveal.fields) {
1464
1514
  if (typeof revealField.field !== "string" || revealField.field.trim() === "") {
1465
1515
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (secretMint) has a reveal.fields entry ` +
1466
- `with an empty or non-string "field".`);
1516
+ `with an empty or non-string "field". Set field to a non-empty string.`);
1467
1517
  }
1468
1518
  if (revealFieldNames.has(revealField.field)) {
1469
1519
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (secretMint) reveal.fields has a ` +
@@ -1472,7 +1522,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1472
1522
  revealFieldNames.add(revealField.field);
1473
1523
  if (typeof revealField.label !== "string" || revealField.label.trim() === "") {
1474
1524
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (secretMint) reveal.fields entry ` +
1475
- `"${revealField.field}" has an empty or non-string "label".`);
1525
+ `"${revealField.field}" has an empty or non-string "label". Set label to a non-empty string.`);
1476
1526
  }
1477
1527
  }
1478
1528
  validateSecretMintConfirm(feature, screenId, screen, allWriteHandlerQns);
@@ -1607,7 +1657,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1607
1657
  }
1608
1658
  if (screen.filter.op === "in" && !Array.isArray(screen.filter.value)) {
1609
1659
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (entityList) filter.op "in" requires ` +
1610
- `filter.value to be a readonly array.`);
1660
+ `filter.value to be a readonly array. Pass an array as filter.value.`);
1611
1661
  }
1612
1662
  }
1613
1663
  // Tier 2.7e-1: rowActions pinnen — navigate-target existiert (selbes
@@ -1678,7 +1728,7 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1678
1728
  const candidateQn = qualifyEntityName(feature.name, "screen", action.screen);
1679
1729
  if (!allScreenQns.has(candidateQn) && !navTargetShortIds.has(action.screen)) {
1680
1730
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (entityList) toolbarAction "${action.id}" ` +
1681
- `navigate-target "${action.screen}" does not resolve to a registered screen in any feature.`);
1731
+ `navigate-target "${action.screen}" does not resolve to a registered screen in any feature. Use "<feature>:screen:<id>" of an existing screen.`);
1682
1732
  }
1683
1733
  if (action.tab !== undefined) {
1684
1734
  resolveRowActionNavigateTarget(feature.name, screenId, "entityList", "toolbarAction", action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
@@ -1746,11 +1796,11 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1746
1796
  if (section.kind === "relatedList") {
1747
1797
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (entityEdit) relatedList section ` +
1748
1798
  `"${section.title}" is not supported — relatedList is a projectionDetail-only ` +
1749
- `primitive (fw#2166).`);
1799
+ `primitive (fw#2166). Move the section to a projectionDetail screen or remove it.`);
1750
1800
  }
1751
1801
  if (isWriteFormEditSection(section)) {
1752
1802
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (entityEdit) writeForm section ` +
1753
- `"${section.title}" is not supported — writeForm is a projectionDetail-only primitive.`);
1803
+ `"${section.title}" is not supported — writeForm is a projectionDetail-only primitive. Move the section to a projectionDetail screen or remove it.`);
1754
1804
  }
1755
1805
  validateFieldsXorGroups(`[Feature ${feature.name}] Screen "${screenId}" (entityEdit)`, section);
1756
1806
  for (const fieldSpec of sectionFieldSpecs(section)) {
@@ -1818,7 +1868,7 @@ function validateDashboardScreen(featureName, screenId, screen, featureMap) {
1818
1868
  const panelIds = new Set();
1819
1869
  const addPanelId = (id, context) => {
1820
1870
  if (panelIds.has(id)) {
1821
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) has duplicate panel id "${id}" (${context}).`);
1871
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) has duplicate panel id "${id}" (${context}). Give each panel a unique id.`);
1822
1872
  }
1823
1873
  panelIds.add(id);
1824
1874
  };
@@ -1847,9 +1897,9 @@ function validateDashboardScreen(featureName, screenId, screen, featureMap) {
1847
1897
  function validateDashboardTimeRange(featureName, screenId, timeRange, filterId) {
1848
1898
  const prefix = `[Feature ${featureName}] Screen "${screenId}" (dashboard) timeRange`;
1849
1899
  if (timeRange.id.length === 0)
1850
- throw new Error(`${prefix} has an empty id.`);
1900
+ throw new Error(`${prefix} has an empty id. Set a non-empty id.`);
1851
1901
  if (timeRange.id === filterId) {
1852
- throw new Error(`${prefix} id "${timeRange.id}" collides with the filter id — both live in the URL search params.`);
1902
+ throw new Error(`${prefix} id "${timeRange.id}" collides with the filter id — both live in the URL search params. Use an id different from the filter id.`);
1853
1903
  }
1854
1904
  if (timeRange.options.length === 0) {
1855
1905
  throw new Error(`${prefix}.options is empty — declare at least one option.`);
@@ -1857,15 +1907,15 @@ function validateDashboardTimeRange(featureName, screenId, timeRange, filterId)
1857
1907
  const values = new Set();
1858
1908
  for (const option of timeRange.options) {
1859
1909
  if (option.value.length === 0 || option.label.length === 0) {
1860
- throw new Error(`${prefix} option needs a non-empty value and label.`);
1910
+ throw new Error(`${prefix} option needs a non-empty value and label. Give every option both.`);
1861
1911
  }
1862
1912
  if (values.has(option.value)) {
1863
- throw new Error(`${prefix}.options has duplicate value "${option.value}".`);
1913
+ throw new Error(`${prefix}.options has duplicate value "${option.value}". Use each option value once.`);
1864
1914
  }
1865
1915
  values.add(option.value);
1866
1916
  }
1867
1917
  if (!values.has(timeRange.default)) {
1868
- throw new Error(`${prefix}.default "${timeRange.default}" is not among the options (${[...values].join(", ")}).`);
1918
+ throw new Error(`${prefix}.default "${timeRange.default}" is not among the options (${[...values].join(", ")}). Set default to one of the options.`);
1869
1919
  }
1870
1920
  }
1871
1921
  const EMBEDDABLE_SCREEN_TYPES = new Set([
@@ -1901,7 +1951,7 @@ export function validateDashboardScreenPanel(featureName, screenId, panel, featu
1901
1951
  }
1902
1952
  function validateDashboardPanelVisibleWhen(context, visibleWhen) {
1903
1953
  if (visibleWhen !== undefined && (visibleWhen.query === "" || visibleWhen.field === "")) {
1904
- throw new Error(`${context} visibleWhen needs a non-empty query and field.`);
1954
+ throw new Error(`${context} visibleWhen needs a non-empty query and field. Set both visibleWhen.query and visibleWhen.field.`);
1905
1955
  }
1906
1956
  }
1907
1957
  function validateDashboardPanelSpan(featureName, screenId, panel) {
@@ -1912,10 +1962,10 @@ function validateDashboardPanelSpan(featureName, screenId, panel) {
1912
1962
  function validateDashboardStatGroupPanel(featureName, screenId, panel, addPanelId) {
1913
1963
  const context = `[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-group "${panel.id}"`;
1914
1964
  if (panel.stats.length === 0) {
1915
- throw new Error(`${context} has an empty stats list.`);
1965
+ throw new Error(`${context} has an empty stats list. Add at least one stat.`);
1916
1966
  }
1917
1967
  if (panel.subtitle !== undefined && panel.label === undefined) {
1918
- throw new Error(`${context} sets subtitle without label — an unlabeled group renders as a KPI strip without a header.`);
1968
+ throw new Error(`${context} sets subtitle without label — an unlabeled group renders as a KPI strip without a header. Set a label or remove subtitle.`);
1919
1969
  }
1920
1970
  validateDashboardPanelSpan(featureName, screenId, panel);
1921
1971
  validateDashboardPanelVisibleWhen(context, panel.visibleWhen);
@@ -1925,22 +1975,27 @@ function validateDashboardStatGroupPanel(featureName, screenId, panel, addPanelI
1925
1975
  throw new Error(`${context} child "${stat.id}" sets visibleWhen — gate the whole group instead; a hidden cell would leave a gap in the strip.`);
1926
1976
  }
1927
1977
  if (!stat.query || typeof stat.query !== "string") {
1928
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-group "${panel.id}" child "${stat.id}" has empty or non-string query.`);
1978
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-group "${panel.id}" child "${stat.id}" has empty or non-string query. Set query to a registered query name.`);
1929
1979
  }
1930
1980
  if (stat.valueField.length === 0) {
1931
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-group "${panel.id}" child "${stat.id}" has empty valueField.`);
1981
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-group "${panel.id}" child "${stat.id}" has empty valueField. Set valueField to a field of the query result.`);
1932
1982
  }
1933
1983
  validateDashboardValueFormat(featureName, screenId, stat.id, stat.valueFormat);
1934
1984
  }
1935
1985
  }
1986
+ function validateFromTodayWindowReach(where, panel) {
1987
+ if (panel.initialWindow === "from-today" && panel.ranges === undefined && panel.brush !== true) {
1988
+ throw new Error(`${where}.initialWindow "from-today" needs ranges or brush, otherwise data before today is unreachable.`);
1989
+ }
1990
+ }
1936
1991
  function validateDashboardChartDisplay(featureName, screenId, panel) {
1937
1992
  validateDashboardValueFormat(featureName, screenId, panel.id, panel.valueFormat);
1938
1993
  const where = `[Feature ${featureName}] Screen "${screenId}" (dashboard) chart-panel "${panel.id}"`;
1939
1994
  if (panel.scrollable !== undefined && typeof panel.scrollable !== "boolean") {
1940
- throw new Error(`${where} has a non-boolean scrollable.`);
1995
+ throw new Error(`${where} has a non-boolean scrollable. Use true or false.`);
1941
1996
  }
1942
1997
  if (panel.scrollable === true && panel.chart !== "stacked-area") {
1943
- throw new Error(`${where} sets scrollable on chart "${panel.chart}" — only "stacked-area" supports it.`);
1998
+ throw new Error(`${where} sets scrollable on chart "${panel.chart}" — only "stacked-area" supports it. Remove scrollable or use chart "stacked-area".`);
1944
1999
  }
1945
2000
  validateStackedAreaOnlyProps(where, panel);
1946
2001
  validateStackedAreaEnumProp(where, "dateFormat", panel.dateFormat, ["day", "month"]);
@@ -1951,8 +2006,9 @@ function validateDashboardChartDisplay(featureName, screenId, panel) {
1951
2006
  validateStackedAreaEnumProp(where, "markerLegend", panel.markerLegend, ["list", "legend"]);
1952
2007
  const nonBooleanProp = ["brush", "legendTotals", "markerLabels"].find((prop) => panel[prop] !== undefined && typeof panel[prop] !== "boolean");
1953
2008
  if (nonBooleanProp !== undefined) {
1954
- throw new Error(`${where} has a non-boolean ${nonBooleanProp}.`);
2009
+ throw new Error(`${where} has a non-boolean ${nonBooleanProp}. Use true or false.`);
1955
2010
  }
2011
+ validateFromTodayWindowReach(where, panel);
1956
2012
  for (const [key, color] of Object.entries(panel.seriesColors ?? {})) {
1957
2013
  if (typeof color !== "string" || color.trim() === "") {
1958
2014
  throw new Error(`${where} seriesColors["${key}"] must be a non-empty CSS color.`);
@@ -1987,7 +2043,7 @@ function validateStackedAreaOnlyProps(where, panel) {
1987
2043
  "markerLabels",
1988
2044
  ].filter((prop) => panel[prop] !== undefined);
1989
2045
  if (stackedAreaOnly.length > 0) {
1990
- throw new Error(`${where} sets ${stackedAreaOnly.join(", ")} on chart "${panel.chart}" — only "stacked-area" supports it.`);
2046
+ throw new Error(`${where} sets ${stackedAreaOnly.join(", ")} on chart "${panel.chart}" — only "stacked-area" supports it. Remove these props or use chart "stacked-area".`);
1991
2047
  }
1992
2048
  }
1993
2049
  const DASHBOARD_CHART_TONES = new Set([
@@ -1998,7 +2054,7 @@ const DASHBOARD_CHART_TONES = new Set([
1998
2054
  ]);
1999
2055
  function validateDashboardChartMarkerKind(where, look) {
2000
2056
  if (look.tone === undefined && look.color === undefined) {
2001
- throw new Error(`${where} needs a tone or a color.`);
2057
+ throw new Error(`${where} needs a tone or a color. Set tone or color.`);
2002
2058
  }
2003
2059
  if (look.tone !== undefined && !DASHBOARD_CHART_TONES.has(look.tone)) {
2004
2060
  throw new Error(`${where}.tone "${String(look.tone)}" is not one of ${[...DASHBOARD_CHART_TONES].join(", ")}.`);
@@ -2017,10 +2073,10 @@ function validateDashboardChartRanges(where, ranges) {
2017
2073
  const values = new Set();
2018
2074
  for (const option of ranges.options) {
2019
2075
  if (option.value.length === 0 || option.label.length === 0) {
2020
- throw new Error(`${where} option needs a non-empty value and label.`);
2076
+ throw new Error(`${where} option needs a non-empty value and label. Give every option both.`);
2021
2077
  }
2022
2078
  if (values.has(option.value)) {
2023
- throw new Error(`${where}.options has duplicate value "${option.value}".`);
2079
+ throw new Error(`${where}.options has duplicate value "${option.value}". Use each option value once.`);
2024
2080
  }
2025
2081
  if (option.months !== undefined && (!Number.isInteger(option.months) || option.months <= 0)) {
2026
2082
  throw new Error(`${where} option "${option.value}" months "${String(option.months)}" must be a positive integer.`);
@@ -2028,7 +2084,7 @@ function validateDashboardChartRanges(where, ranges) {
2028
2084
  values.add(option.value);
2029
2085
  }
2030
2086
  if (!values.has(ranges.default)) {
2031
- throw new Error(`${where}.default "${ranges.default}" is not among the options (${[...values].join(", ")}).`);
2087
+ throw new Error(`${where}.default "${ranges.default}" is not among the options (${[...values].join(", ")}). Set default to one of the options.`);
2032
2088
  }
2033
2089
  }
2034
2090
  const CURRENCY_CODE_RE = /^[A-Z]{3}$/;
@@ -2038,6 +2094,9 @@ function validateDashboardValueFormat(featureName, screenId, panelId, valueForma
2038
2094
  if (valueFormat === undefined)
2039
2095
  return;
2040
2096
  const where = `[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panelId}" valueFormat`;
2097
+ if (valueFormat.kind !== "currency") {
2098
+ throw new Error(`${where}.kind "${String(valueFormat.kind)}" must be "currency".`);
2099
+ }
2041
2100
  if (!CURRENCY_CODE_RE.test(valueFormat.currency)) {
2042
2101
  throw new Error(`${where}.currency "${String(valueFormat.currency)}" must be a 3-letter uppercase ISO currency code.`);
2043
2102
  }
@@ -2055,12 +2114,12 @@ export function validateDashboardCustomPanel(featureName, screenId, panel) {
2055
2114
  }
2056
2115
  function validateDashboardQueryPanel(featureName, screenId, panel, featureMap) {
2057
2116
  if (!panel.query || typeof panel.query !== "string") {
2058
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" has empty or non-string query.`);
2117
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" has empty or non-string query. Set query to a registered query name.`);
2059
2118
  }
2060
2119
  validateDashboardPanelSpan(featureName, screenId, panel);
2061
2120
  if (panel.kind === "stat") {
2062
2121
  if (panel.valueField.length === 0) {
2063
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-panel "${panel.id}" has empty valueField.`);
2122
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-panel "${panel.id}" has empty valueField. Set valueField to a field of the query result.`);
2064
2123
  }
2065
2124
  validateDashboardValueFormat(featureName, screenId, panel.id, panel.valueFormat);
2066
2125
  }
@@ -2069,7 +2128,7 @@ function validateDashboardQueryPanel(featureName, screenId, panel, featureMap) {
2069
2128
  validateDashboardChartDisplay(featureName, screenId, panel);
2070
2129
  if (panel.kind === "list") {
2071
2130
  if (panel.columns.length === 0) {
2072
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) list-panel "${panel.id}" has an empty columns list.`);
2131
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) list-panel "${panel.id}" has an empty columns list. Add at least one column.`);
2073
2132
  }
2074
2133
  for (const col of panel.columns) {
2075
2134
  const normalized = normalizeListColumn(col);
@@ -2082,10 +2141,10 @@ function validateDashboardQueryPanel(featureName, screenId, panel, featureMap) {
2082
2141
  }
2083
2142
  function validateDashboardFilterDefinition(featureName, screenId, filter) {
2084
2143
  if (filter.id.length === 0) {
2085
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) filter has an empty id.`);
2144
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) filter has an empty id. Set a non-empty id.`);
2086
2145
  }
2087
2146
  if (filter.label.length === 0) {
2088
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) filter has an empty label.`);
2147
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) filter has an empty label. Set a non-empty label.`);
2089
2148
  }
2090
2149
  const hasOptions = filter.options !== undefined;
2091
2150
  const hasOptionsQuery = filter.optionsQuery !== undefined;
@@ -2098,7 +2157,7 @@ function validateDashboardFilterDefinition(featureName, screenId, filter) {
2098
2157
  `declare at least one option or use optionsQuery instead.`);
2099
2158
  }
2100
2159
  if (hasOptionsQuery && filter.optionsQuery?.length === 0) {
2101
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) filter.optionsQuery is empty.`);
2160
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) filter.optionsQuery is empty. Set optionsQuery to a query name or remove it.`);
2102
2161
  }
2103
2162
  }
2104
2163
  // Form-check für ListColumn-Renderer in der PlatformComponent-Form
@@ -2119,7 +2178,7 @@ function rejectInertColumnSortable(featureName, screenId, screenType, column) {
2119
2178
  ? "set sortable: true on the entity field instead"
2120
2179
  : "sorting is derived from the query's Zod schema; only sortable: false is honored";
2121
2180
  throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) column "${column.field}" sets sortable — ` +
2122
- `it is ignored here; ${source}.`);
2181
+ `it is ignored here; ${source}. Remove sortable from this column.`);
2123
2182
  }
2124
2183
  export function validateColumnRendererForm(featureName, screenId, column) {
2125
2184
  const renderer = column.renderer;
@@ -2190,7 +2249,7 @@ function validateListFacets(prefix, facets, columns, currentFeatureName, feature
2190
2249
  const seenFacetFields = new Set();
2191
2250
  for (const facet of facets) {
2192
2251
  if (seenFacetFields.has(facet.field)) {
2193
- throw new Error(`${prefix} declares facet "${facet.field}" more than once.`);
2252
+ throw new Error(`${prefix} declares facet "${facet.field}" more than once. Declare each facet field once.`);
2194
2253
  }
2195
2254
  seenFacetFields.add(facet.field);
2196
2255
  if (facet.type !== "reference" && !columnFieldNames.has(facet.field)) {