@cosmicdrift/kumiko-framework 0.384.0 → 0.386.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 (78) hide show
  1. package/agents/examples/i18n.md +36 -1
  2. package/agents/features.md +4 -0
  3. package/dist/api/auth-rate-limiters.d.ts +8 -0
  4. package/dist/api/auth-rate-limiters.js +105 -0
  5. package/dist/api/auth-routes-context.d.ts +34 -0
  6. package/dist/api/auth-routes-context.js +192 -0
  7. package/dist/api/auth-routes-invite.d.ts +2 -0
  8. package/dist/api/auth-routes-invite.js +171 -0
  9. package/dist/api/auth-routes-login.d.ts +2 -0
  10. package/dist/api/auth-routes-login.js +103 -0
  11. package/dist/api/auth-routes-mfa.d.ts +4 -0
  12. package/dist/api/auth-routes-mfa.js +211 -0
  13. package/dist/api/auth-routes-session.d.ts +2 -0
  14. package/dist/api/auth-routes-session.js +144 -0
  15. package/dist/api/auth-routes-shared.d.ts +33 -0
  16. package/dist/api/auth-routes-shared.js +81 -0
  17. package/dist/api/auth-routes-signup.d.ts +2 -0
  18. package/dist/api/auth-routes-signup.js +75 -0
  19. package/dist/api/auth-routes-token-flows.d.ts +4 -0
  20. package/dist/api/auth-routes-token-flows.js +91 -0
  21. package/dist/api/auth-routes-types.d.ts +76 -0
  22. package/dist/api/auth-routes-types.js +1 -0
  23. package/dist/api/auth-routes.d.ts +5 -85
  24. package/dist/api/auth-routes.js +20 -1095
  25. package/dist/api/request-context.d.ts +2 -0
  26. package/dist/api/request-context.js +16 -7
  27. package/dist/config/index.d.ts +1 -1
  28. package/dist/crypto/pii-ciphertext-format.js +1 -1
  29. package/dist/db/blind-index-cleanup.js +1 -1
  30. package/dist/db/cross-tenant-reads.d.ts +8 -0
  31. package/dist/db/cross-tenant-reads.js +23 -0
  32. package/dist/db/dialect.js +1 -1
  33. package/dist/db/distinct-on.d.ts +9 -0
  34. package/dist/db/distinct-on.js +28 -0
  35. package/dist/db/event-store-executor-write.js +2 -2
  36. package/dist/db/migrate-runner.js +4 -4
  37. package/dist/db/pg-error.js +0 -12
  38. package/dist/db/queries/event-consumer.js +3 -3
  39. package/dist/db/queries/postgres-array-literal.js +2 -2
  40. package/dist/db/queries/test-stack.d.ts +4 -1
  41. package/dist/db/queries/test-stack.js +15 -2
  42. package/dist/db/query.js +19 -19
  43. package/dist/db/tenant-db.js +1 -1
  44. package/dist/engine/boot-validator/i18n-keys.d.ts +1 -0
  45. package/dist/engine/boot-validator/i18n-keys.js +8 -1
  46. package/dist/engine/boot-validator/index.d.ts +4 -0
  47. package/dist/engine/boot-validator/index.js +2 -1
  48. package/dist/engine/boot-validator/screens-actions.d.ts +67 -0
  49. package/dist/engine/boot-validator/screens-actions.js +488 -0
  50. package/dist/engine/boot-validator/screens-dashboard.d.ts +15 -0
  51. package/dist/engine/boot-validator/screens-dashboard.js +467 -0
  52. package/dist/engine/boot-validator/screens-forms.d.ts +24 -0
  53. package/dist/engine/boot-validator/screens-forms.js +516 -0
  54. package/dist/engine/boot-validator/screens-lists.d.ts +47 -0
  55. package/dist/engine/boot-validator/screens-lists.js +319 -0
  56. package/dist/engine/boot-validator/screens.d.ts +4 -21
  57. package/dist/engine/boot-validator/screens.js +26 -1799
  58. package/dist/engine/feature-ast/pattern-change-schema.d.ts +8 -0
  59. package/dist/engine/steps/read-find-one.js +1 -1
  60. package/dist/event-store/provenance-append.js +1 -1
  61. package/dist/files/file-routes.js +1 -1
  62. package/dist/i18n/required-surface-keys.d.ts +16 -0
  63. package/dist/i18n/required-surface-keys.js +62 -0
  64. package/dist/internal/stack-support.d.ts +1 -1
  65. package/dist/internal/stack-support.js +1 -1
  66. package/dist/jobs/job-runner-helpers.d.ts +44 -0
  67. package/dist/jobs/job-runner-helpers.js +265 -0
  68. package/dist/jobs/job-runner-schedulers.d.ts +23 -0
  69. package/dist/jobs/job-runner-schedulers.js +106 -0
  70. package/dist/jobs/job-runner-types.d.ts +65 -0
  71. package/dist/jobs/job-runner-types.js +2 -0
  72. package/dist/jobs/job-runner.d.ts +4 -75
  73. package/dist/jobs/job-runner.js +10 -386
  74. package/dist/pipeline/dispatch-shared.js +29 -4
  75. package/dist/pipeline/event-dispatcher.js +2 -3
  76. package/dist/schema-cli.js +5 -0
  77. package/package.json +3 -3
  78. package/src/changes.json +19 -0
@@ -1,1215 +1,30 @@
1
- // Screen validation, including the dashboard-panel sub-validators.
2
- // Dashboard stays here (not a separate dashboard.ts) — validateScreens
3
- // calls validateDashboardScreen and the dashboard validators call back
4
- // into validateColumnRendererForm, splitting them would create a
5
- // same-folder require cycle.
6
- import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
7
- import { NO_WIDGET_FIELD_TYPES, optionsQueryFieldRefs, SELECT_OPTION_TONES, } from "@cosmicdrift/kumiko-types/fields";
8
- import { NAV_ICON_KEYS } from "@cosmicdrift/kumiko-types/nav-icon";
9
- import { DASHBOARD_MIN_REFRESH_SECONDS } from "@cosmicdrift/kumiko-types/screen";
10
- import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
1
+ // Screen validation entry point. The sub-validators live in the screens-*.ts
2
+ // siblings; dependencies only point one way (screens.ts -> dashboard/forms/lists
3
+ // -> actions), so there is no same-folder require cycle.
4
+ import { SELECT_OPTION_TONES } from "@cosmicdrift/kumiko-types/fields";
11
5
  import { isListFilterAllowed } from "../../db/entity-field-encryption.js";
12
6
  import { rowMetaFieldNames } from "../../db/table-builder.js";
13
- import { REFERENCE_LOOKUP_SOURCES } from "../../ui-types/list-row-meta.js";
14
7
  import { isEncryptedAtRest } from "../config-helpers.js";
15
- import { parseRefTarget } from "../parse-ref-target.js";
16
- import { isKebabSegment, isValidQn, qualifyEntityName } from "../qualified-name.js";
8
+ import { qualifyEntityName } from "../qualified-name.js";
17
9
  import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops.js";
18
- import { explicitListScreenId, isExtensionEditSection, isFieldsEditSection, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../screen-helpers.js";
10
+ import { isExtensionEditSection, isFieldsEditSection, isWriteFormEditSection, normalizeEditField, normalizeListColumn, sectionFieldSpecs, } from "../screen-helpers.js";
19
11
  import { LIST_ROW_META_COLUMN_NAMES } from "./entity-list-screens.js";
20
- import { validateFormDerivations, validateFormDerivationsEnforced } from "./form-derivations.js";
12
+ import { isRowClickAction, rejectRedirectOnListRowAction, resolveRowActionNavigateTarget, validateActionFieldRefs, validateActionHasIcon, validateAtMostOneRowClick, validateClientActionName, validateDrawerTargetAction, validateNavigateActionTab, validatePrimaryActionTarget, validateRecordActionRedirect, validateRedirectTarget, validateRowActionNavigateParams, validateScreenVisibleWhen, } from "./screens-actions.js";
13
+ import { validateDashboardActions, validateDashboardRecordBinding, validateDashboardScreen, } from "./screens-dashboard.js";
14
+ import { isWriteOnlyField, rejectWriteOnlyFormField, validateConfigEditSecretField, validateEditFieldExtras, validateFieldFormRefs, validateFieldsXorGroups, validateFillActionTarget, validateFormSelectOptions, validateInlineFormScreen, validateNoWidgetRequiredField, validateSecretMintConfirm, validateTabSections, validateWizardLayout, } from "./screens-forms.js";
15
+ import { assertRefTargetRegistered, rejectInertColumnSortable, validateColumnRendererForm, validateExplicitListScreenExists, validateListActionTargets, validateListFacets, validateRelatedListSection, validateScreenHasNavArea, } from "./screens-lists.js";
21
16
  import { isSelectOptionTone } from "./select-option-tone.js";
22
- function isRowClickAction(action) {
23
- return (action.kind === "navigate" || action.kind === "drawer") && action.rowClick === true;
24
- }
25
- // entityList and projectionList both allow a rowAction to double as the
26
- // row-body click target (rowClick: true, fw#1708/#2164) — at most one per
27
- // screen, or the renderer can't tell which one should fire.
28
- function validateAtMostOneRowClick(featureName, screenId, screenType, rowActions) {
29
- const rowClickActions = rowActions.filter(isRowClickAction);
30
- if (rowClickActions.length > 1) {
31
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has ${rowClickActions.length} ` +
32
- "rowActions marked rowClick:true — at most one may fire on a row-body click. Mark at most one rowAction with rowClick: true.");
33
- }
34
- }
35
- // A field type in NO_WIDGET_FIELD_TYPES renders read-only on the auto-wired
36
- // entityEdit path (#1925) — a required field the user can never fill would
37
- // block every save. Only the statically-resolvable case is caught here: a
38
- // literal `required: true` (screen-spec override or entity-level default).
39
- // A dynamic FieldCondition depends on runtime form values and can't be
40
- // evaluated at boot; buildFormSchema() silently skips presence-checking it.
41
- // `embeddedList` and `prefill` only make sense on field types that support them;
42
- // a misplaced spec would otherwise be a silent no-op.
43
- function validateEditFieldExtras(featureName, screenId, entityDef, fieldSpec) {
44
- const fieldDef = entityDef.fields[fieldSpec.field];
45
- // skip: the field name is not on the entity, other checks report unknown fields
46
- if (fieldDef === undefined)
47
- return;
48
- const where = `[Feature ${featureName}] Screen "${screenId}" (entityEdit) field "${fieldSpec.field}"`;
49
- const listSpec = fieldSpec.embeddedList;
50
- if (listSpec !== undefined) {
51
- if (fieldDef.type !== "embedded" || fieldDef.multiple !== true) {
52
- throw new Error(`${where} sets embeddedList, which only applies to an embedded list ` +
53
- `(createEmbeddedListField); the field is type "${fieldDef.type}". Remove embeddedList or point it at an embedded list field.`);
54
- }
55
- for (const cell of listSpec.readOnlyCells ?? []) {
56
- if (fieldDef.schema[cell] === undefined) {
57
- throw new Error(`${where} embeddedList.readOnlyCells names "${cell}", which is not a sub-field ` +
58
- `(known: ${Object.keys(fieldDef.schema).sort().join(", ")}).`);
59
- }
60
- }
61
- }
62
- const prefill = fieldSpec.prefill;
63
- // skip: no prefill declared
64
- if (prefill === undefined)
65
- return;
66
- if (prefill.kind === "today") {
67
- if (fieldDef.type !== "date") {
68
- throw new Error(`${where} prefill "today" needs a date field; the field is type "${fieldDef.type}". Use a date field or a different prefill.`);
69
- }
70
- // skip: today carries no property, nothing further to check
71
- return;
72
- }
73
- const allowsUserId = fieldDef.type === "text" || fieldDef.type === "reference";
74
- if (prefill.property === "id" ? !allowsUserId : fieldDef.type !== "text") {
75
- throw new Error(`${where} prefill currentUser.${prefill.property} does not fit a field of type ` +
76
- `"${fieldDef.type}" (displayName and email need text; id needs text or a reference). Use a fitting field type or a different prefill property.`);
77
- }
78
- }
79
- // `{ field }` affixes and fillAction params read live form values, so every
80
- // name must be a field of the same form — a typo would otherwise render no
81
- // affix or send an undefined param without a hint.
82
- function validateFieldFormRefs(where, fieldSpec, formFieldNames) {
83
- const affixes = [
84
- ["prefix", fieldSpec.prefix],
85
- ["suffix", fieldSpec.suffix],
86
- ];
87
- for (const [prop, affix] of affixes) {
88
- if (affix === undefined || typeof affix === "string")
89
- continue;
90
- if (!formFieldNames.has(affix.field)) {
91
- throw new Error(`${where} ${prop} references unknown field "${affix.field}" — it must be another field of the same form.`);
92
- }
93
- }
94
- const params = fieldSpec.fillAction?.params;
95
- // skip: no fill-action params to cross-check
96
- if (params === undefined)
97
- return;
98
- const sourceFields = "pick" in params ? params.pick : Object.values(params.map);
99
- for (const sourceField of sourceFields) {
100
- if (!formFieldNames.has(sourceField)) {
101
- throw new Error(`${where} fillAction.params reads unknown field "${sourceField}" — it must be a field of the same form.`);
102
- }
103
- }
104
- }
105
- const FILL_ACTION_FIELD_TYPES = ["text", "number", "decimal", "bigInt"];
106
- // The fill button writes a plain text or numeric query result into the field;
107
- // a secret value must never be filled from a query response.
108
- function validateFillActionTarget(where, fieldSpec, fieldDef) {
109
- // skip: no fill action, or the field is unknown (reported elsewhere)
110
- if (fieldSpec.fillAction === undefined || fieldDef === undefined)
111
- return;
112
- if (!FILL_ACTION_FIELD_TYPES.includes(fieldDef.type)) {
113
- throw new Error(`${where} fillAction needs a ${FILL_ACTION_FIELD_TYPES.join(", ")} field, not "${fieldDef.type}". ` +
114
- `Remove fillAction or use a fitting field type.`);
115
- }
116
- if (isSensitiveOrPasswordField(fieldDef)) {
117
- throw new Error(`${where} fillAction targets a sensitive, writeOnly or password field — a query result is ` +
118
- `never written into those. Remove fillAction.`);
119
- }
120
- }
121
- function validateNoWidgetRequiredField(featureName, screenId, entityDef, fieldSpec) {
122
- const fieldDef = entityDef.fields[fieldSpec.field];
123
- // skip: field doesn't exist or its type already has a widget — nothing to validate.
124
- if (fieldDef === undefined || !NO_WIDGET_FIELD_TYPES.includes(fieldDef.type))
125
- return;
126
- // Embedded LIST fields (`multiple: true`) get their own EmbeddedListField
127
- // grid widget (#1838) — only plain (non-list) embedded has no widget.
128
- const isEmbeddedList = fieldDef.type === "embedded" && fieldDef.multiple === true;
129
- // skip: list variant has a widget — not the no-widget case this guard targets.
130
- if (isEmbeddedList)
131
- return;
132
- // skip: already read-only by spec — no fillable widget needed regardless of type.
133
- if (fieldSpec.readOnly === true)
134
- return;
135
- const entityRequired = "required" in fieldDef && fieldDef.required === true;
136
- const isStaticallyRequired = fieldSpec.required === undefined ? entityRequired : fieldSpec.required === true;
137
- // skip: not required — a read-only widget-less field is fine to leave empty.
138
- if (!isStaticallyRequired)
139
- return;
140
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (entityEdit) field "${fieldSpec.field}" is ` +
141
- `type "${fieldDef.type}", which renders read-only on the auto-wired entityEdit path — a ` +
142
- `required field the user could never fill would block every save. Set required: false, ` +
143
- `move the field to a custom-component section, or drop the required constraint.`);
144
- }
145
- export function rowFieldExtractorKeys(params) {
146
- return "pick" in params ? params.pick : Object.keys(params.map);
147
- }
148
- // Mirrors the renderer's navigate: an explicit entityId, or a same-entity
149
- // entityEdit target (row["id"] auto-fill), opens UPDATE mode, which ignores params.
150
- export function readsNavigateParamsAsFormPrefill(target, explicitEntityId, sourceScreenEntity) {
151
- if (target.type === "actionForm" || target.type === "secretMint")
152
- return true;
153
- if (target.type !== "entityEdit")
154
- return false;
155
- if (explicitEntityId !== undefined)
156
- return false;
157
- return sourceScreenEntity === undefined || target.entity !== sourceScreenEntity;
158
- }
159
- // Tier 2.7e navigate rowAction → target-screen params validity. Shared by
160
- // entityList and projectionList (framework#1708) — projectionList has no
161
- // `screen.entity`, so there's no same-entity row["id"] auto-fill case: any
162
- // entityEdit target without an explicit entityId reaches create there.
163
- //
164
- // dashboard targets (framework#1708 follow-up, commit 4e0d6cb26) also read
165
- // URL search params — but only for the single `filter.id` a dashboard
166
- // declares (useFilterParams in dashboard-body.tsx seeds its value from
167
- // `nav.searchParams[filter.id]`). A dashboard with no `filter` has nowhere
168
- // for the value to land, and a params extractor whose keys don't include
169
- // the filter's id would silently miss it — both are boot errors instead of
170
- // a silently-empty dashboard.
171
- function validateRowActionNavigateParams(featureName, screenId, screenType, screenEntity, action, target, actionLabel = "rowAction") {
172
- // skip: not a navigate-with-params action — nothing to validate here.
173
- if (action.kind !== "navigate" || action.params === undefined)
174
- return;
175
- // skip: unresolvable/custom target already reported (or exempt) elsewhere.
176
- if (target === undefined || target.screen.type === "custom")
177
- return;
178
- // skip: entityList/projectionList targets also read URL search params (Tier
179
- // 2.7c filter-prefill, see use-list-url-state.ts: `<screenId>.q/.sort/
180
- // .dir/.page/.f.<field>`), not just actionForm/entityEdit-create.
181
- if (target.screen.type === "entityList" || target.screen.type === "projectionList")
182
- return;
183
- if (target.screen.type === "dashboard") {
184
- const targetDescriptor = action.screen !== undefined ? `"${action.screen}"` : `entity "${action.entity}"`;
185
- const { filter, record, timeRange } = target.screen;
186
- const readIds = [filter?.id, record?.param, timeRange?.id].filter((id) => id !== undefined);
187
- if (readIds.length === 0) {
188
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" sets ` +
189
- `params on navigate-target ${targetDescriptor} (dashboard) — target dashboard declares no ` +
190
- `filter, time range or record binding — params would be a no-op. Remove the params extractor, or add a \`filter\` (or a \`record\` binding) to the ` +
191
- `target dashboard so it has somewhere to read the value from.`);
192
- }
193
- const extractedKeys = rowFieldExtractorKeys(action.params);
194
- if (!readIds.some((id) => extractedKeys.includes(id))) {
195
- const readDescriptor = filter !== undefined && readIds.length === 1
196
- ? `filter id is "${filter.id}"`
197
- : `URL params are [${readIds.join(", ")}]`;
198
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" sets ` +
199
- `params [${extractedKeys.join(", ")}] on navigate-target ${targetDescriptor} (dashboard) whose ` +
200
- `${readDescriptor} — none of the extracted keys match, so the value would stay ` +
201
- `unset. Fix the params extractor to produce one of these keys, or remove it.`);
202
- }
203
- // skip: a read id is present and the extractor's keys cover it — valid dashboard deep-link.
204
- return;
205
- }
206
- if (!readsNavigateParamsAsFormPrefill(target.screen, action.entityId, screenEntity)) {
207
- const isEntityEditUpdate = target.screen.type === "entityEdit";
208
- const reason = isEntityEditUpdate
209
- ? `resolves to UPDATE mode (${action.entityId !== undefined
210
- ? `explicit entityId "${action.entityId}"`
211
- : `same entity "${screenEntity}" auto-fills row["id"]`})`
212
- : `screen type "${target.screen.type}"`;
213
- const targetDescriptor = action.screen !== undefined ? `"${action.screen}"` : `entity "${action.entity}"`;
214
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
215
- `sets params on navigate-target ${targetDescriptor} which ${reason} — only actionForm, ` +
216
- `secretMint and entityEdit-create targets read URL search params as initial values. Remove ` +
217
- `the params extractor or retarget to an actionForm / secretMint / cross-entity ` +
218
- `entityEdit-create screen.`);
219
- }
220
- const formTarget = target.screen;
221
- if ((formTarget.type === "actionForm" || formTarget.type === "secretMint") &&
222
- formTarget.submitPrefilled === true) {
223
- validateDrawerPrefillKeys(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}"`, "navigate-target", action.screen ?? `entity "${action.entity}"`, formTarget, action.params);
224
- }
225
- }
226
- // Shared by projectionDetail and entityEdit: a tab strip needs something to
227
- // label each tab with and a stable id per tab — the id anchors the ?tab=
228
- // param on projectionDetail and the jump-to-erroring-tab logic on entityEdit
229
- // (fw#3134).
230
- function validateTabSections(featureName, screenId, screenType, sections) {
231
- if (sections.length < 2) {
232
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has mode: "tabs" but only ` +
233
- `${sections.length} section(s) — tabs need at least 2 sections.`);
234
- }
235
- const tabIds = new Set();
236
- sections.forEach((section, index) => {
237
- if (section.title === undefined || section.title.trim().length === 0) {
238
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has mode: "tabs" but ` +
239
- `sections[${index}] has no title — every tab needs a title.`);
240
- }
241
- if (section.id === undefined || section.id.trim().length === 0) {
242
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has mode: "tabs" but ` +
243
- `sections[${index}] ("${section.title}") has no id — every tab needs a stable id.`);
244
- }
245
- if (!isKebabSegment(section.id)) {
246
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) sections[${index}] ` +
247
- `("${section.title}") has id "${section.id}" — must be kebab-case.`);
248
- }
249
- if (tabIds.has(section.id)) {
250
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has duplicate tab id ` +
251
- `"${section.id}" (sections[${index}]). Give each tab section a unique id.`);
252
- }
253
- tabIds.add(section.id);
254
- });
255
- }
256
- // Wizard layouts (mode: "wizard") need >= 2 titled sections — a single or
257
- // untitled step would leave the progress indicator blank, so both fail at
258
- // boot rather than as a broken step UI.
259
- function validateWizardLayout(featureName, screenId, screenType, layout, featureMap) {
260
- // entityEdit renders tabs like wizard steps: every section stays mounted,
261
- // submit validates across all of them, and a field error jumps to the tab
262
- // holding it (fw#3134). The other three have no such jump — actionForm and
263
- // secretMint are one-shot forms, configEdit fires a write per field — so a
264
- // required field on a hidden tab would still block their submit silently.
265
- if (layout.mode === "tabs") {
266
- if (screenType !== "entityEdit") {
267
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) sets mode: "tabs" — tabs are only ` +
268
- `supported on projectionDetail and entityEdit. Use mode: "wizard" or "single" instead.`);
269
- }
270
- validateTabSections(featureName, screenId, screenType, layout.sections);
271
- }
272
- // "form-draft" is hardcoded because the framework layer must not depend on
273
- // @cosmicdrift/kumiko-bundled-features — same precedence as the
274
- // "user-data-rights" check in gdpr-storage.ts.
275
- if (layout.draft === true) {
276
- if (layout.mode !== "wizard") {
277
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) sets draft: true but ` +
278
- `mode is not "wizard" — draft persistence only applies to wizard layouts. Remove ` +
279
- `draft: true or set mode: "wizard".`);
280
- }
281
- if (!featureMap.has("form-draft")) {
282
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) sets draft: true but the ` +
283
- `bundled feature "form-draft" is not mounted — every resume would silently lose its ` +
284
- `values. Add createFormDraftFeature() from @cosmicdrift/kumiko-bundled-features to the app's ` +
285
- `feature list.`);
286
- }
287
- }
288
- // skip: mode omitted/"single" — no wizard constraints apply.
289
- if (layout.mode !== "wizard")
290
- return;
291
- if (layout.sections.length < 2) {
292
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has mode: "wizard" but only ` +
293
- `${layout.sections.length} section(s) — a wizard needs at least 2 sections (one per step).`);
294
- }
295
- layout.sections.forEach((section, index) => {
296
- if (section.title === undefined || section.title.trim().length === 0) {
297
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has mode: "wizard" but ` +
298
- `sections[${index}] has no title — every wizard step needs a title.`);
299
- }
300
- });
301
- }
302
- // --- Screen validation ---
303
- //
304
- // For every r.screen() declaration check what's locally knowable at boot:
305
- // - entityList / entityEdit: the referenced entity must exist in the
306
- // feature (cross-feature entity-refs aren't allowed — a feature owns
307
- // the screens over its own entities) and every column/field ref must
308
- // name a real field on that entity
309
- // - custom: the renderer must at least have one platform component set
310
- // (react OR native), otherwise the screen is structurally empty
311
- //
312
- // Field-level renderer QN strings (cross-feature `component:` references)
313
- // are NOT validated here — the r.uiComponent registry that would resolve
314
- // them ships in M4/M5. Until then those are kept opaque on purpose.
315
- // every action rendered in a Card title row (screen-level
316
- // `actions`, section-level `actions`, `emptyState.action`) must draw an
317
- // icon — declared or id-derived (resolveActionIcon, same resolver the
318
- // renderer uses). Uncovered here: entityList/projectionList rowActions —
319
- // those render inside a DataTable row, not a Card title row, so they keep
320
- // today's "icon optional" behavior.
321
- //
322
- // Registration check (not just "resolved is not undefined"): action.icon is
323
- // typed IconKey, a closed union — but that only protects call sites this
324
- // repo's own `tsc --build` actually covers (samples and external consumers
325
- // aren't in that project graph). The renderer's actionIconFor
326
- // (renderer/src/web/primitives/index.tsx) already falls back to "no icon"
327
- // for an unregistered key instead of crashing — this check makes the same
328
- // gap fail loudly at boot instead of silently at render.
329
- function isRegisteredIcon(icon) {
330
- return isOneOf(NAV_ICON_KEYS, icon);
331
- }
332
- export function validateActionHasIcon(featureName, screenId, screenType, context, action) {
333
- const resolved = resolveActionIcon(action.id, action.icon);
334
- if (resolved === undefined || !isRegisteredIcon(resolved)) {
335
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${context} "${action.id}" has no ` +
336
- `resolvable icon — set icon to a key registered in NAV_ICONS, or use an id that resolves one of ` +
337
- `its kebab segments via the default icon map (resolveActionIcon).`);
338
- }
339
- }
340
- // Tier 2.7e-3: deklarative Feld-Referenzen einer Action gegen die Entity-
341
- // Felder pinnen — ein Tippfehler in pick/map-Quellfeldern oder
342
- // visible.field erzeugte sonst still `undefined` im Payload bzw. dauerhaft
343
- // falsche Sichtbarkeit (gleiche "Typo fällt erst beim Klick"-Klasse wie
344
- // navigate/handler). Exported for query-output-columns.ts, which reuses it
345
- // against a projectionDetail's outputSchema-derived record shape instead of
346
- // an entity's field map (relatedList toolbarActions' visible/params).
347
- export function validateActionFieldRefs(featureName, screenId, actionKind, actionId, action, fieldNames, rowMeta) {
348
- // ToolbarAction.payload ist ein STATISCHER Record (kein Row-Context) —
349
- // nur echte pick/map-Extractoren werden gegen die Feldnamen geprüft.
350
- const isExtractor = (v) => typeof v === "object" && v !== null && ("pick" in v || "map" in v);
351
- const payload = "payload" in action && isExtractor(action.payload) ? action.payload : undefined;
352
- const params = "params" in action && isExtractor(action.params) ? action.params : undefined;
353
- const visible = "visible" in action ? action.visible : undefined;
354
- const entityId = "entityId" in action ? action.entityId : undefined;
355
- const known = () => [...fieldNames].sort().join(", ") || "(none)";
356
- const checkExtractor = (label, extractor) => {
357
- // skip: extractor ist ein optionaler Action-Slot — ohne ihn gibt es
358
- // keine Feld-Referenzen zu validieren.
359
- if (extractor === undefined) {
360
- return;
361
- }
362
- const sources = "pick" in extractor ? extractor.pick : Object.values(extractor.map);
363
- for (const source of sources) {
364
- if (rowMeta.has(source))
365
- continue;
366
- if (!fieldNames.has(source)) {
367
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" ${actionKind} "${actionId}" ` +
368
- `${label} references unknown field "${source}". Known fields: ${known()}.`);
369
- }
370
- }
371
- };
372
- checkExtractor("payload", payload);
373
- checkExtractor("params", params);
374
- if (visible !== undefined &&
375
- typeof visible !== "boolean" &&
376
- !rowMeta.has(visible.field) &&
377
- !fieldNames.has(visible.field)) {
378
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" ${actionKind} "${actionId}" ` +
379
- `visible.field references unknown field "${visible.field}". Known fields: ${known()}.`);
380
- }
381
- if (entityId !== undefined && entityId !== "id" && !fieldNames.has(entityId)) {
382
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" ${actionKind} "${actionId}" ` +
383
- `entityId references unknown field "${entityId}". Known fields: ${known()}.`);
384
- }
385
- }
386
- // Two features registering the same short screen-id is a silent routing
387
- // footgun: create-app.tsx's runtime router resolves a bare navigate-target
388
- // id by scanning features[] and taking the first match — the collision
389
- // never surfaces as an error, the second feature's screen is just
390
- // unreachable by that id (and always the same one that loses, in whatever
391
- // order the app composed its features).
392
- export function validateScreenShortIdCollisions(screensByShortId) {
393
- for (const [shortId, entries] of screensByShortId) {
394
- const featureNames = new Set(entries.map((e) => e.featureName));
395
- if (featureNames.size > 1) {
396
- throw new Error(`Screen short-id "${shortId}" is registered by ${featureNames.size} features ` +
397
- `(${[...featureNames].join(", ")}) — the runtime router resolves a bare navigate-target ` +
398
- `id by taking the first match, so all but one of these screens would be unreachable by ` +
399
- `that id. Give each screen a distinct id.`);
400
- }
401
- }
402
- }
403
- // redirect/cancelTarget accept either a same-feature short id (unchanged
404
- // behavior, qualified against the owning feature) or a fully-qualified
405
- // cross-feature screen QN (`<feature>:screen:<id>`) given verbatim — a
406
- // short id can never itself be a valid QN (QN_SEGMENT forbids colons), so
407
- // the two forms don't collide.
408
- export function resolveScreenTargetQn(featureName, target) {
409
- return isValidQn(target) ? target : qualifyEntityName(featureName, "screen", target);
410
- }
411
- function validateScreenNavTarget(featureName, screenId, screenKind, fieldName, value, allScreenQns, screens) {
412
- const candidateQn = resolveScreenTargetQn(featureName, value);
413
- if (!allScreenQns.has(candidateQn)) {
414
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${fieldName} "${value}" ` +
415
- `does not resolve to a registered screen (checked "${candidateQn}"). Known screens ` +
416
- `in this feature: ${[...Object.keys(screens)].sort().join(", ") || "(none)"}.`);
417
- }
418
- }
419
- // kind:"drawer" resolves same-feature only — unlike navigate/redirect,
420
- // which the runtime router resolves app-wide (see the comment on
421
- // validateScreens below), the drawer mounts the target inline using this
422
- // feature's schema, so a cross-feature reference could never actually
423
- // render. Two distinct failure messages: dangling reference vs. wrong
424
- // screen type. Shared by ToolbarAction (fw#2225) and RowAction (fw#2710)
425
- // drawer variants — same target-resolution rule, only the label in the
426
- // error message ("toolbarAction" vs. "rowAction" vs. "action") differs.
427
- function validateDrawerTargetAction(featureName, screenId, screenKind, actionLabel, action, screens) {
428
- const target = screens[action.screen];
429
- if (target === undefined) {
430
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}" ` +
431
- `drawer-target "${action.screen}" does not resolve to a registered screen in this feature. ` +
432
- `kind:"drawer" only resolves same-feature screens (unlike kind:"navigate", which can target ` +
433
- `screens in any feature) — the drawer mounts the target inline using this feature's schema. ` +
434
- `Known screens in this feature: ${[...Object.keys(screens)].sort().join(", ") || "(none)"}.`);
435
- }
436
- // secretMint is intentionally excluded — a one-time secret reveal belongs on
437
- // its own page, not layered in a Drawer above a list.
438
- if (target.type !== "actionForm") {
439
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}" ` +
440
- `drawer-target "${action.screen}" is a "${target.type}" screen, not an actionForm. ` +
441
- `kind:"drawer" mounts an actionForm inside a Drawer widget — point "screen" at an ` +
442
- `actionForm screen, or use kind:"navigate" for a full-page target.`);
443
- }
444
- // The renderer (mergeSearchParamsIntoInitial) silently drops a prefill key
445
- // when the target doesn't declare the field, marks it sensitive/password,
446
- // or doesn't render it in its layout — each would only show up as an empty
447
- // field at click time.
448
- // skip: no params extractor — nothing to check against the target's fields.
449
- if (action.params === undefined)
450
- return;
451
- validateDrawerPrefillKeys(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}"`, "drawer-target", action.screen, target, action.params);
452
- }
453
- function renderedEditFieldNames(sections) {
454
- const names = new Set();
455
- for (const section of sections) {
456
- if (!isFieldsEditSection(section))
457
- continue;
458
- for (const spec of sectionFieldSpecs(section))
459
- names.add(normalizeEditField(spec).field);
460
- }
461
- return names;
462
- }
463
- function isSensitiveOrPasswordField(field) {
464
- return (field !== undefined &&
465
- (("sensitive" in field && field.sensitive === true) ||
466
- ("writeOnly" in field && field.writeOnly === true) ||
467
- ("format" in field && field.format === "password")));
468
- }
469
- export function validateDrawerPrefillKeys(where, targetLabel, targetScreenId, target, params) {
470
- const renderedFieldNames = renderedEditFieldNames(target.layout.sections);
471
- for (const fieldName of rowFieldExtractorKeys(params)) {
472
- // A `load` form reads only the record id from these params, never a value.
473
- if (fieldName === "id" && target.type === "actionForm" && target.load !== undefined)
474
- continue;
475
- if (!Object.hasOwn(target.fields, fieldName)) {
476
- throw new Error(`${where} params prefills "${fieldName}", which ${targetLabel} "${targetScreenId}" does not declare as a ` +
477
- `field — the renderer would drop it and leave the form empty. Target fields: ` +
478
- `${Object.keys(target.fields).sort().join(", ") || "(none)"}.`);
479
- }
480
- if (isSensitiveOrPasswordField(target.fields[fieldName])) {
481
- throw new Error(`${where} params prefills "${fieldName}", which ${targetLabel} "${targetScreenId}" marks as ` +
482
- `sensitive or password — the renderer never prefills those fields and would leave the form empty. Remove that param or prefill a non-sensitive field.`);
483
- }
484
- if (!renderedFieldNames.has(fieldName)) {
485
- throw new Error(`${where} params prefills "${fieldName}", which ${targetLabel} "${targetScreenId}" does not render in ` +
486
- `its layout — the renderer only prefills fields the layout shows and would drop it.`);
487
- }
488
- }
489
- }
490
- // The target can be any screen, so this checks the resolved target's
491
- // sections, not the host's.
492
- function validateRowActionNavigateTab(featureName, screenId, screenType, actionLabel, action, target) {
493
- // skip: no tab declared — nothing to check against the target's sections
494
- if (action.tab === undefined)
495
- return;
496
- if (target === undefined) {
497
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
498
- `sets tab "${action.tab}", but its navigate-target could not be resolved to a screen. Point the navigate-target at a registered screen.`);
499
- }
500
- if (target.screen.type !== "projectionDetail" || target.screen.layout.mode !== "tabs") {
501
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
502
- `sets tab "${action.tab}", but navigate-target screen "${target.screen.id}" is not a ` +
503
- `projectionDetail with layout.mode "tabs" — only those read the tab param. Remove the tab or navigate to a projectionDetail screen with layout.mode "tabs".`);
504
- }
505
- const sectionIds = target.screen.layout.sections
506
- .map((section) => section.id)
507
- .filter((id) => id !== undefined);
508
- if (!sectionIds.includes(action.tab)) {
509
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
510
- `navigates to tab "${action.tab}", which is not a section id on target screen ` +
511
- `"${target.screen.id}". Available: ${sectionIds.join(", ") || "(none)"}.`);
512
- }
513
- }
514
- function resolveRowActionNavigateTarget(featureName, screenId, screenType, actionLabel, action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens) {
515
- if (action.entity !== undefined) {
516
- if (action.screen !== undefined) {
517
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
518
- `sets both "screen" and "entity" — exactly one navigate-target form is allowed.`);
519
- }
520
- if (screenType !== "entityList" && action.entityId === undefined) {
521
- // entityList rows are always a real entity record, so row["id"] is a
522
- // safe implicit default. projectionList/projectionDetail rows come from
523
- // an arbitrary query projection with no guaranteed "id" field — an
524
- // entity-target there needs an explicit entityId, or navigation silently
525
- // opens the detail screen with no entity context at runtime.
526
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
527
- `navigate-target entity "${action.entity}" needs an explicit "entityId" — ${screenType} rows ` +
528
- `come from a query projection with no guaranteed "id" field.`);
529
- }
530
- const detail = detailForScreens.get(action.entity);
531
- if (detail === undefined) {
532
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
533
- `navigate-target entity "${action.entity}" has no screen declaring ` +
534
- `detailFor: "${action.entity}". Add detailFor: "${action.entity}" to a screen or navigate to a screen directly.`);
535
- }
536
- validateRowActionNavigateTab(featureName, screenId, screenType, actionLabel, action, detail);
537
- return detail;
538
- }
539
- if (action.screen === undefined) {
540
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
541
- `sets neither "screen" nor "entity" — exactly one navigate-target form is required.`);
542
- }
543
- const candidateQn = qualifyEntityName(featureName, "screen", action.screen);
544
- if (!allScreenQns.has(candidateQn) && !navTargetShortIds.has(action.screen)) {
545
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
546
- `navigate-target "${action.screen}" does not resolve to a registered screen in any feature. Use "<feature>:screen:<id>" of an existing screen.`);
547
- }
548
- const target = screensByShortId.get(action.screen)?.[0];
549
- validateRowActionNavigateTab(featureName, screenId, screenType, actionLabel, action, target);
550
- return target;
551
- }
552
- // Shared by the mint step's own handler (screen.handler) and its optional
553
- // confirm step (confirm.handler) — both are a bare write-handler QN with the
554
- // same registration requirement.
555
- function validateWriteHandlerRegistered(featureName, screenId, context, handler, allWriteHandlerQns) {
556
- if (!handler || typeof handler !== "string") {
557
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) has empty or non-string handler. Set handler to a non-empty handler name.`);
558
- }
559
- if (!allWriteHandlerQns.has(handler)) {
560
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) handler "${handler}" ` +
561
- `is not a registered write-handler. Check the QN spelling (expected ` +
562
- `"<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`);
563
- }
564
- }
565
- // Every field entry must carry a `type` discriminator. An author typo
566
- // (`title: { required: true }` without a type) would otherwise let
567
- // RenderField silently fall through to the default renderer and submit an
568
- // empty string — failing at boot is clearer. `type as unknown` because
569
- // FieldDefinition, as a union, only allows known type strings; here we're
570
- // checking author code that may have circumvented the type check.
571
- //
572
- // Parametrized over (fields, layout) instead of a whole screen so a
573
- // secretMint's own `confirm` step — a second, independent inline form on the
574
- // same screen — can reuse the same checks as its `fields`/`layout` pair.
575
- // `allowEmpty` is only ever true for a secretMint's own mint step (an
576
- // input-less mint, fw#2838): the secret is server-generated and the mint
577
- // step is just its submit button — actionForm and the confirm step always
578
- // require at least one field.
579
- function validateFormFieldsMap(featureName, screenId, context, fields, allowEmpty) {
580
- const fieldNames = new Set(Object.keys(fields));
581
- if (fieldNames.size === 0 && !allowEmpty) {
582
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) has empty fields map — ` +
583
- `declare at least one field.`);
584
- }
585
- for (const [fname, fdef] of Object.entries(fields)) {
586
- // @cast-boundary schema-walk — feature-config inspection (Author may circumvent type-check)
587
- const ftype = fdef.type;
588
- if (typeof ftype !== "string" || ftype.length === 0) {
589
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) field "${fname}" has no ` +
590
- `\`type\` set. Each field must declare a type (e.g. "text", "number", "select").`);
591
- }
592
- if (ftype === "money") {
593
- validateFormMoneyCurrency(featureName, screenId, context, fname, fdef);
594
- }
595
- validateFormSelectOptions(featureName, screenId, context, fname, fdef, fieldNames);
596
- rejectWriteOnlyFormField(featureName, screenId, context, fname, fdef);
597
- }
598
- return fieldNames;
599
- }
600
- function validateOptionsQueryFieldRefs(where, fieldName, payload, siblingFieldNames) {
601
- for (const ref of optionsQueryFieldRefs(payload)) {
602
- if (ref === fieldName) {
603
- throw new Error(`${where} optionsQueryPayload references itself ({ field: "${ref}" }). Reference a different field of the form.`);
604
- }
605
- if (!siblingFieldNames.has(ref)) {
606
- throw new Error(`${where} optionsQueryPayload references unknown field "${ref}" — it must be another field of the same form`);
607
- }
608
- }
609
- }
610
- function isWriteOnlyField(fdef) {
611
- return fdef?.writeOnly === true; // @cast-boundary schema-walk
612
- }
613
- // A secret on a configEdit form keeps its value out of the client like an encrypted
614
- // config key does, and the secrets store only exists per tenant.
615
- function validateConfigEditSecretField(featureName, screen, fieldName, secretQn, featureMap) {
616
- const where = `[Feature ${featureName}] Screen "${screen.id}" (configEdit) field "${fieldName}"`;
617
- if (screen.configKeys[fieldName] !== undefined) {
618
- throw new Error(`${where} is mapped in both configKeys and secretKeys — keep one.`);
619
- }
620
- if (screen.scope !== "tenant") {
621
- throw new Error(`${where} maps secret "${secretQn}" on a ${screen.scope}-scope screen — secrets are tenant-scoped.`);
622
- }
623
- const fdef = screen.fields[fieldName];
624
- if (fdef?.type !== "text" || !isWriteOnlyField(fdef)) {
625
- throw new Error(`${where} maps secret "${secretQn}" but is no writeOnly text field — a stored secret never goes back to the client.`);
626
- }
627
- const declared = [...featureMap.values()].some((feature) => Object.values(feature.secretKeys ?? {}).some((def) => def.qualifiedName === secretQn));
628
- if (!declared) {
629
- throw new Error(`${where} → secret "${secretQn}" is not declared via r.secret() in any feature.`);
630
- }
631
- }
632
- // Inline forms have no stored value to hide or keep: writeOnly there would
633
- // render a "set / keep" affordance with nothing behind it.
634
- function rejectWriteOnlyFormField(featureName, screenId, context, fieldName, fdef) {
635
- if (isWriteOnlyField(fdef)) {
636
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) field "${fieldName}" declares writeOnly — ` +
637
- `writeOnly only applies to entity fields (nothing to hide on a form that stores no value). ` +
638
- `Use format: "password" for a masked input.`);
639
- }
640
- }
641
- // Static options XOR optionsQuery on inline-form select fields, and `{ field }`
642
- // payload refs must name another field of the same form (the payload also feeds
643
- // optionsAvailabilityQuery). The QN existence check
644
- // lives in query-refs.ts with the other query refs.
645
- const CONDITIONAL_OPTIONS_ENTITY_ONLY = "conditionalOptions is only supported on entity select fields (enforced by the entity write path)";
646
- function validateFormSelectOptions(featureName, screenId, context, fieldName, fdef, siblingFieldNames) {
647
- if (fdef.type === "select") {
648
- const where = `[Feature ${featureName}] Screen "${screenId}" (${context}) select field "${fieldName}"`;
649
- if (fdef.conditionalOptions !== undefined) {
650
- throw new Error(`${where} declares conditionalOptions — ${CONDITIONAL_OPTIONS_ENTITY_ONLY}. Remove conditionalOptions from this field.`);
651
- }
652
- if (fdef.optionsQuery !== undefined) {
653
- if (fdef.optionsQuery.length === 0) {
654
- throw new Error(`${where} has an empty optionsQuery. Set it to a query name or remove it.`);
655
- }
656
- if (fdef.options.length > 0) {
657
- throw new Error(`${where} declares both options and optionsQuery — pick one`);
658
- }
659
- if (fdef.optionsAvailabilityQuery !== undefined) {
660
- throw new Error(`${where} declares both optionsQuery and optionsAvailabilityQuery — availability only applies to static options`);
661
- }
662
- validateOptionsQueryFieldRefs(where, fieldName, fdef.optionsQueryPayload, siblingFieldNames);
663
- }
664
- else if (fdef.optionsAvailabilityQuery !== undefined) {
665
- if (fdef.optionsAvailabilityQuery.length === 0) {
666
- throw new Error(`${where} has an empty optionsAvailabilityQuery. Set it to a query name or remove it.`);
667
- }
668
- validateOptionsQueryFieldRefs(where, fieldName, fdef.optionsQueryPayload, siblingFieldNames);
669
- }
670
- else if (fdef.optionsQueryPayload !== undefined) {
671
- throw new Error(`${where} has optionsQueryPayload without optionsQuery. Set optionsQuery or remove optionsQueryPayload.`);
672
- }
673
- }
674
- else if (fdef.type === "multiSelect" && fdef.optionsAvailabilityQuery !== undefined) {
675
- if (fdef.optionsAvailabilityQuery.length === 0) {
676
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) multiSelect field "${fieldName}" has an empty optionsAvailabilityQuery. Set it to a query name or remove it.`);
677
- }
678
- }
679
- }
680
- // Fail-closed currency-source gate (fw#2839), the runtime half of the
681
- // narrowed `FormFieldDefinition` — an untyped JS consumer has no compiler to
682
- // stop it. An inline form screen has no entity, so a money field there can't
683
- // borrow `entity.defaultCurrency`: without a declared source the renderer
684
- // seeds a bare `0` and the handler's zod schema rejects the submit. Entity
685
- // money fields are exempt — create-app already refuses an entity that holds
686
- // money without a `defaultCurrency`.
687
- function validateFormMoneyCurrency(featureName, screenId, context, fieldName, fdef) {
688
- const where = `[Feature ${featureName}] Screen "${screenId}" (${context}) money field "${fieldName}"`;
689
- const validForms = `Declare \`currency: { kind: "literal", code: "EUR" }\` for a fixed currency, or ` +
690
- `\`currency: { kind: "tenant" }\` to take the tenant-settings bundle's per-tenant currency.`;
691
- // @cast-boundary schema-walk — feature-config inspection (Author may circumvent type-check)
692
- const currency = fdef.currency;
693
- if (currency === undefined) {
694
- throw new Error(`${where} must declare where its currency comes from — this screen has no entity whose ` +
695
- `\`defaultCurrency\` it could inherit, so the form would seed a bare \`0\` that the ` +
696
- `handler's schema rejects. ${validForms}`);
697
- }
698
- if (typeof currency !== "object" || currency === null) {
699
- throw new Error(`${where} has a non-object \`currency\`. ${validForms}`);
700
- }
701
- const kind = currency.kind;
702
- if (kind === "literal") {
703
- const code = currency.code;
704
- if (typeof code !== "string" || code.trim() === "") {
705
- throw new Error(`${where} declares \`currency: { kind: "literal" }\` with an empty or non-string \`code\`. ` +
706
- `Pass the ISO code, e.g. { kind: "literal", code: "EUR" }.`);
707
- }
708
- }
709
- else if (kind !== "tenant") {
710
- throw new Error(`${where} declares an unknown currency kind ${JSON.stringify(kind)}. ${validForms}`);
711
- }
712
- }
713
- // `allowEmptySections` mirrors `validateFormFieldsMap`'s `allowEmpty` — the
714
- // caller only ever passes true together with an actually-empty fields map
715
- // (an input-less secretMint declares BOTH `fields: {}` and
716
- // `layout.sections: []`; fields declared with no layout to show them stays
717
- // an error).
718
- // `fields`/`groups` are mutually exclusive on a fields-kind section; shared by
719
- // every EditLayout-walking screen type instead of four hand-rolled copies.
720
- function validateFieldsXorGroups(errorPrefix, section) {
721
- if (section.fields !== undefined && section.groups !== undefined) {
722
- throw new Error(`${errorPrefix} section "${section.title}" declares both fields and groups — remove ` +
723
- `\`fields: []\` when using groups.`);
724
- }
725
- if ((section.fields?.length ?? 0) === 0 && (section.groups?.length ?? 0) === 0) {
726
- throw new Error(`${errorPrefix} has a section "${section.title}" with zero fields — drop the section or add ` +
727
- `fields (or groups) to it.`);
728
- }
729
- }
730
- function validateFormLayoutSections(featureName, screenId, context, layout, fieldNames, fieldDefs, allowEmptySections) {
731
- if (layout.sections.length === 0) {
732
- // skip: an input-less secretMint declares fields: {} and sections: [] together — no sections to validate.
733
- if (allowEmptySections)
734
- return;
735
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) has an empty sections list — ` +
736
- `declare at least one section.`);
737
- }
738
- for (const section of layout.sections) {
739
- if (isExtensionEditSection(section)) {
740
- if (section.component?.react === undefined && section.component?.native === undefined) {
741
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) extension section ` +
742
- `"${section.title}" has no component — declare a react/native component marker.`);
743
- }
744
- continue;
745
- }
746
- if (section.kind === "relatedList") {
747
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) relatedList section ` +
748
- `"${section.title}" is not supported — relatedList is a projectionDetail-only ` +
749
- `primitive (fw#2166). Move the section to a projectionDetail screen or remove it.`);
750
- }
751
- if (isWriteFormEditSection(section)) {
752
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) writeForm section ` +
753
- `"${section.title}" is not supported — writeForm is a projectionDetail-only primitive. Move the section to a projectionDetail screen or remove it.`);
754
- }
755
- validateFieldsXorGroups(`[Feature ${featureName}] Screen "${screenId}" (${context})`, section);
756
- for (const fieldSpec of sectionFieldSpecs(section)) {
757
- const normalized = normalizeEditField(fieldSpec);
758
- if (!fieldNames.has(normalized.field)) {
759
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${context}) layout references unknown field ` +
760
- `"${normalized.field}". Known fields: ${[...fieldNames].sort().join(", ")}`);
761
- }
762
- const fieldWhere = `[Feature ${featureName}] Screen "${screenId}" (${context}) field "${normalized.field}"`;
763
- validateFieldFormRefs(fieldWhere, normalized, fieldNames);
764
- validateFillActionTarget(fieldWhere, normalized, fieldDefs[normalized.field]);
765
- }
766
- }
767
- }
768
- // redirect is either a short screen id (same-feature, e.g. "item-list") or
769
- // a fully-qualified cross-feature QN (`<feature>:screen:<id>`) — the
770
- // renderer strips the latter to the short id (lastSegment) when
771
- // navigating, which the nav-router resolves app-wide (#1946). The object
772
- // form (fw#2670) carries the same target under `screen` plus the payload
773
- // field `idFrom`. Shared by actionForm/secretMint (validateInlineFormNavTargets)
774
- // and entityEdit — same rule, same error message, one place to keep them in sync.
775
- function validateRedirectTarget(feature, screenId, screenKind, redirect, lookups,
776
- // Property name used in error messages (`successLinks[0]` for a success link).
777
- propertyPath = "redirect") {
778
- const { allScreenQns } = lookups;
779
- const redirectTarget = typeof redirect === "string" ? redirect : redirect.screen;
780
- if (typeof redirect !== "string" &&
781
- (typeof redirect.idFrom !== "string" || redirect.idFrom.trim() === "")) {
782
- throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screenKind}) ${propertyPath}.idFrom is empty or not a string — ` +
783
- `name the success-payload field carrying the navigation id, or use the plain string ` +
784
- `redirect form to navigate with the handler's own "id".`);
785
- }
786
- // The renderer reads idFrom verbatim (Object.hasOwn), so stray whitespace
787
- // never matches and the navigation silently lands without an entityId.
788
- if (typeof redirect !== "string" && redirect.idFrom !== redirect.idFrom.trim()) {
789
- throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screenKind}) ${propertyPath}.idFrom "${redirect.idFrom}" ` +
790
- `has leading/trailing whitespace — it would never match a payload field. Remove the whitespace from idFrom.`);
791
- }
792
- validateScreenNavTarget(feature.name, screenId, screenKind, propertyPath, redirectTarget, allScreenQns, feature.screens);
793
- // idFrom only means something to a target that carries a record id; the
794
- // renderer drops it silently otherwise. Cross-feature QN targets can't be
795
- // typed from here and stay unchecked.
796
- const sameFeatureTarget = feature.screens[redirectTarget];
797
- if (typeof redirect !== "string" &&
798
- sameFeatureTarget !== undefined &&
799
- sameFeatureTarget.type !== "entityEdit" &&
800
- sameFeatureTarget.type !== "projectionDetail") {
801
- throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screenKind}) ${propertyPath}.idFrom is set but target screen ` +
802
- `"${redirectTarget}" (${sameFeatureTarget.type}) carries no id — use the plain string form.`);
803
- }
804
- if (typeof redirect !== "string" && redirect.tab !== undefined) {
805
- validateRowActionNavigateTab(feature.name, screenId, screenKind, propertyPath, { kind: "navigate", id: redirectTarget, label: redirectTarget, tab: redirect.tab }, findNavTargetEntry(feature, redirectTarget, lookups));
806
- }
807
- }
808
- function findNavTargetEntry(feature, target, lookups) {
809
- const targetQn = resolveScreenTargetQn(feature.name, target);
810
- return lookups.screensByShortId
811
- .get(targetQn.slice(targetQn.lastIndexOf(":") + 1))
812
- ?.find((entry) => qualifyEntityName(entry.featureName, "screen", entry.screen.id) === targetQn);
813
- }
814
- // A success link without idFrom navigates without an id, so its target must not
815
- // be a record screen (entityEdit/projectionDetail need one).
816
- function validateIdlessSuccessLink(feature, screenId, screenKind, link, lookups, propertyPath) {
817
- validateScreenNavTarget(feature.name, screenId, screenKind, propertyPath, link.screen, lookups.allScreenQns, feature.screens);
818
- const target = findNavTargetEntry(feature, link.screen, lookups);
819
- const targetType = target?.screen.type;
820
- if (targetType === "entityEdit" || targetType === "projectionDetail") {
821
- throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screenKind}) ${propertyPath} targets "${link.screen}" ` +
822
- `(${targetType}), a record screen — set ${propertyPath}.idFrom to the success-payload field carrying the record id.`);
823
- }
824
- if (link.tab !== undefined) {
825
- validateRowActionNavigateTab(feature.name, screenId, screenKind, propertyPath, { kind: "navigate", id: link.screen, label: link.screen, tab: link.tab }, target);
826
- }
827
- }
828
- // A clientAction names a handler a client feature registers
829
- // (ClientFeatureDefinition.clientActions). Nothing server-side can confirm the
830
- // registration, so the shape is the only boot-time check.
831
- function validateClientActionName(featureName, screenId, screenKind, actionLabel, action) {
832
- const segments = action.action.split(":");
833
- const [scope, name] = segments;
834
- const wellFormed = segments.length === 2 &&
835
- scope !== undefined &&
836
- name !== undefined &&
837
- isKebabSegment(scope) &&
838
- isKebabSegment(name);
839
- // skip: well-formed name, nothing to reject
840
- if (wellFormed)
841
- return;
842
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}" ` +
843
- `action "${action.action}" is not a client action name — expected "<feature>:<kebab-name>", ` +
844
- `e.g. "auth-email-password:request-password-reset".`);
845
- }
846
- // redirect is only honored on record actions; on list/related-list row
847
- // actions it would be silently dropped, so the author would believe it works.
848
- function rejectRedirectOnListRowAction(featureName, screenId, screenKind, action) {
849
- if (action.kind !== "navigate" &&
850
- action.kind !== "drawer" &&
851
- action.kind !== "clientAction" &&
852
- action.redirect !== undefined) {
853
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) rowAction "${action.id}" sets redirect — ` +
854
- `redirect is only honored on record actions (projectionDetail/entityEdit header and section ` +
855
- `actions) and is ignored on list row actions. Remove it.`);
856
- }
857
- }
858
- // A writeHandler record action's redirect (projectionDetail/entityEdit
859
- // header and section actions) follows the entityEdit.redirect rule.
860
- function validateRecordActionRedirect(feature, screenId, screenKind, action, lookups) {
861
- if (action.kind !== "navigate" &&
862
- action.kind !== "drawer" &&
863
- action.kind !== "clientAction" &&
864
- action.redirect !== undefined) {
865
- validateRedirectTarget(feature, screenId, screenKind, action.redirect, lookups);
866
- }
867
- }
868
- // "edit" is the default edit action, which only exists when detailFor names
869
- // the entity; a typo would otherwise silently fall back to the default precedence.
870
- function validatePrimaryActionTarget(featureName, screenId, screen) {
871
- // skip: no declared primaryAction, the default header precedence applies
872
- if (screen.primaryAction === undefined)
873
- return;
874
- const declaredIds = (screen.actions ?? []).map((action) => action.id);
875
- const editIsAvailable = screen.detailFor !== undefined;
876
- const targetExists = declaredIds.includes(screen.primaryAction) ||
877
- (editIsAvailable && screen.primaryAction === "edit");
878
- if (!targetExists) {
879
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (projectionDetail) primaryAction "${screen.primaryAction}" ` +
880
- `matches no action — use one of the declared action ids (${declaredIds.join(", ") || "none declared"})` +
881
- `${editIsAvailable ? ' or "edit"' : ' ("edit" needs detailFor on this screen)'}.`);
882
- }
883
- }
884
- function validateScreenVisibleWhen(feature, screenId, screen, allScreenQns, featureMap) {
885
- const { visibleWhen, fallback } = screen;
886
- const context = `[Feature ${feature.name}] Screen "${screenId}" (${screen.type})`;
887
- if (visibleWhen === undefined && fallback !== undefined) {
888
- throw new Error(`${context} declares fallback without visibleWhen. Add visibleWhen or remove fallback.`);
889
- }
890
- if (visibleWhen !== undefined && (visibleWhen.query === "" || visibleWhen.field === "")) {
891
- throw new Error(`${context} visibleWhen needs a non-empty query and field. Set both visibleWhen.query and visibleWhen.field.`);
892
- }
893
- if (fallback !== undefined) {
894
- validateScreenFallback(feature, screenId, screen.type, fallback, allScreenQns, featureMap);
895
- }
896
- }
897
- function validateScreenFallback(feature, screenId, screenType, fallback, allScreenQns, featureMap) {
898
- const context = `[Feature ${feature.name}] Screen "${screenId}" (${screenType})`;
899
- validateScreenNavTarget(feature.name, screenId, screenType, "fallback", fallback, allScreenQns, feature.screens);
900
- const fallbackQn = resolveScreenTargetQn(feature.name, fallback);
901
- if (fallbackQn === qualifyEntityName(feature.name, "screen", screenId)) {
902
- throw new Error(`${context} fallback points at the screen itself. Point fallback at a different screen.`);
903
- }
904
- // A gated fallback could hide in turn and chain or cycle (A→B→A); one hop keeps the
905
- // runtime gate trivially terminating.
906
- const [fallbackFeatureName = "", , fallbackShortId = ""] = fallbackQn.split(":");
907
- if (featureMap.get(fallbackFeatureName)?.screens[fallbackShortId]?.visibleWhen !== undefined) {
908
- throw new Error(`${context} fallback "${fallback}" has its own visibleWhen; a fallback must always render.`);
909
- }
910
- }
911
- function validateInlineFormNavTargets(feature, screenId, screen, kind, lookups) {
912
- const { allScreenQns } = lookups;
913
- if (screen.redirect !== undefined) {
914
- validateRedirectTarget(feature, screenId, kind, screen.redirect, lookups);
915
- }
916
- if (screen.type === "actionForm" &&
917
- screen.successFacts !== undefined &&
918
- screen.redirect !== undefined) {
919
- throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (actionForm) sets both redirect and successFacts — ` +
920
- `redirect leaves the screen, successFacts are shown on it. Use one of them.`);
921
- }
922
- if (screen.type === "actionForm" && screen.successLinks !== undefined) {
923
- if (screen.redirect !== undefined) {
924
- throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (actionForm) sets both redirect and successLinks — ` +
925
- `redirect leaves the screen, successLinks keep it open. Use one of them.`);
926
- }
927
- screen.successLinks.forEach((link, index) => {
928
- const propertyPath = `successLinks[${index}]`;
929
- if (link.idFrom === undefined) {
930
- validateIdlessSuccessLink(feature, screenId, "actionForm", link, lookups, propertyPath);
931
- }
932
- else {
933
- validateRedirectTarget(feature, screenId, kind, { ...link, idFrom: link.idFrom }, lookups, propertyPath);
934
- }
935
- });
936
- }
937
- if (typeof screen.cancelTarget === "string") {
938
- // Same rule as redirect — `false` (no Cancel button) needs no validation.
939
- validateScreenNavTarget(feature.name, screenId, kind, "cancelTarget", screen.cancelTarget, allScreenQns, feature.screens);
940
- }
941
- }
942
- // actionForm/secretMint have no entity link, only a write-handler QN +
943
- // inline fields: checks handler registration, field types, layout refs, and nav targets.
944
- function validateInlineFormScreen(feature, screenId, screen, kind, allWriteHandlerQns, lookups, featureMap) {
945
- validateWriteHandlerRegistered(feature.name, screenId, kind, screen.handler, allWriteHandlerQns);
946
- // Only a secretMint's own mint step may skip fields/sections entirely
947
- // (input-less mint, fw#2838) — actionForm always needs at least one field.
948
- const allowEmptyMintForm = kind === "secretMint";
949
- const fieldNames = validateFormFieldsMap(feature.name, screenId, kind, screen.fields, allowEmptyMintForm);
950
- validateFormLayoutSections(feature.name, screenId, kind, screen.layout, fieldNames, screen.fields, allowEmptyMintForm && fieldNames.size === 0);
951
- validateWizardLayout(feature.name, screenId, kind, screen.layout, featureMap);
952
- if (screen.type === "actionForm") {
953
- validateFormDerivations(feature.name, screenId, screen);
954
- validateFormDerivationsEnforced(feature.name, screenId, screen, featureMap);
955
- }
956
- if (screen.type === "actionForm" &&
957
- screen.load?.singleton === true &&
958
- screen.load.idParam !== undefined) {
959
- throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (actionForm) sets both load.singleton: true ` +
960
- `and load.idParam — singleton means the query picks the row from the caller's context, so ` +
961
- `there is no record id to send under idParam. Remove idParam.`);
962
- }
963
- validateInlineFormNavTargets(feature, screenId, screen, kind, lookups);
964
- }
965
- // SecretMintConfirmStep (fw#2838): an optional proof-of-receipt form rendered
966
- // on the reveal card in place of the bare acknowledge button (TOTP enrollment:
967
- // scan the code, then enter one). Unlike the mint step, confirm always needs
968
- // at least one field — a step whose only job is proving receipt with no input
969
- // would be meaningless — and its layout is a single plain form, never a
970
- // wizard/tabs flow and never a persisted draft: a one-time reveal-confirm is
971
- // not a multi-step flow, and persisting a draft of it would re-open the leak
972
- // fw#2548 closed (a lingering copy of the just-revealed secret in storage).
973
- function validateSecretMintConfirm(feature, screenId, screen, allWriteHandlerQns) {
974
- const confirm = screen.confirm;
975
- // skip: the screen declares no confirm step — nothing to validate.
976
- if (confirm === undefined)
977
- return;
978
- const context = "secretMint confirm";
979
- validateWriteHandlerRegistered(feature.name, screenId, context, confirm.handler, allWriteHandlerQns);
980
- const fieldNames = validateFormFieldsMap(feature.name, screenId, context, confirm.fields, false);
981
- validateFormLayoutSections(feature.name, screenId, context, confirm.layout, fieldNames, confirm.fields, false);
982
- if (confirm.layout.mode !== undefined && confirm.layout.mode !== "single") {
983
- throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${context}) sets layout.mode: ` +
984
- `"${confirm.layout.mode}" — a one-time reveal confirm step must stay a single-step form. ` +
985
- `Remove mode or set it to "single".`);
986
- }
987
- if (confirm.layout.draft === true) {
988
- throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${context}) sets layout.draft: true — ` +
989
- `persisting a draft of the reveal-confirm step would leak the one-time secret into storage, ` +
990
- `the exact leak fw#2548 closed. Remove draft: true.`);
991
- }
992
- // skip: no carry list declared — no carried fields to cross-check against confirm.fields.
993
- if (confirm.carry === undefined)
994
- return;
995
- for (const carryField of confirm.carry) {
996
- if (typeof carryField !== "string" || carryField.trim() === "") {
997
- throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${context}) has an empty or non-string ` +
998
- `entry in "carry". Use non-empty strings in carry.`);
999
- }
1000
- if (fieldNames.has(carryField)) {
1001
- throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${context}) carry field "${carryField}" ` +
1002
- `also names a confirm.fields entry — a carried mint-payload value would silently ` +
1003
- `overwrite the user-entered field of the same name. Rename one of them.`);
1004
- }
1005
- }
1006
- }
1007
- // validateScreenHasNavArea runs once per screen; the index is built once per
1008
- // featureMap (immutable during boot) instead of re-scanning every feature each call.
1009
- const navAreaIndexByFeatureMap = new WeakMap();
1010
- function navTargetScreenQns(nav) {
1011
- return [nav.screen, nav.createAction?.screen, ...(nav.actions ?? []).map((a) => a.screen)].filter((qn) => qn !== undefined);
1012
- }
1013
- function getNavAreaIndex(featureMap) {
1014
- const cached = navAreaIndexByFeatureMap.get(featureMap);
1015
- if (cached !== undefined)
1016
- return cached;
1017
- const navTargetQns = new Set();
1018
- const allScreens = [];
1019
- let anyNavRegistered = false;
1020
- for (const f of featureMap.values()) {
1021
- allScreens.push(...Object.values(f.screens));
1022
- for (const nav of Object.values(f.navs)) {
1023
- anyNavRegistered = true;
1024
- for (const qn of navTargetScreenQns(nav))
1025
- navTargetQns.add(qn);
1026
- }
1027
- }
1028
- const index = { anyNavRegistered, navTargetQns, allScreens };
1029
- navAreaIndexByFeatureMap.set(featureMap, index);
1030
- return index;
1031
- }
1032
- // resolveNavParentScreen falls back to a heuristic when the explicit
1033
- // listScreenId matches no screen, so a typo would silently get the guess.
1034
- function validateExplicitListScreenExists(featureName, screenId, screen, screensByShortId) {
1035
- const listScreenId = explicitListScreenId(screen);
1036
- // skip: no explicit listScreenId, the nav heuristic applies and there is nothing to validate
1037
- if (listScreenId === undefined)
1038
- return;
1039
- const shortId = listScreenId.slice(listScreenId.lastIndexOf(":") + 1);
1040
- // skip: the explicit listScreenId resolves to a mounted screen
1041
- if (screensByShortId.has(shortId))
1042
- return;
1043
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screen.type}) listScreenId "${listScreenId}" ` +
1044
- `matches no mounted screen — fix the id or mount the list screen.`);
1045
- }
1046
- // Every screen must resolve nav via `nav`, `r.nav()`, a parent list, or
1047
- // `dormant: true`. Skipped when the composed set has no nav entries at all.
1048
- function validateScreenHasNavArea(feature, screenId, screen, featureMap) {
1049
- // skip: the screen already declares its own nav entry.
1050
- if (screen.nav !== undefined)
1051
- return;
1052
- // skip: explicitly opted out via `dormant: true`.
1053
- if (screen.dormant === true)
1054
- return;
1055
- const { anyNavRegistered, navTargetQns, allScreens } = getNavAreaIndex(featureMap);
1056
- // skip: no nav entries exist anywhere in the composed set — a feature/
1057
- // recipe/test fixture booted without an app shell, not an omission.
1058
- if (!anyNavRegistered)
1059
- return;
1060
- const targetQn = qualifyEntityName(feature.name, "screen", screenId);
1061
- // skip: a standalone r.nav() elsewhere already points at this screen.
1062
- if (navTargetQns.has(targetQn))
1063
- return;
1064
- // skip: resolves to a parent list via listScreenId/rowAction-target/same-entity.
1065
- if (resolveNavParentScreen(allScreens, screen, (s) => s.id) !== undefined)
1066
- return;
1067
- throw new Error(`[Feature ${feature.name}] Screen "${targetQn}" has no nav entry and no resolvable list — ` +
1068
- `declare listScreenId (or a rowAction/toolbarAction navigate target from a list screen), add ` +
1069
- `a standalone r.nav() pointing at it, or set dormant: true if it's intentionally reachable ` +
1070
- `only via a direct link/redirect or a consuming app's own r.nav().`);
1071
- }
1072
- // Shared by projectionDetail relatedList sections and entityList.expandableRow:
1073
- // both render a relatedList whose rows come from an arbitrary query.
1074
- function validateRelatedListSection(args) {
1075
- const { featureName, featureMap, where, section } = args;
1076
- if (!section.query || typeof section.query !== "string") {
1077
- throw new Error(`[Feature ${featureName}] ${where} has empty or non-string query. Set query to a registered query name.`);
1078
- }
1079
- if (section.entity !== undefined) {
1080
- assertRefTargetRegistered(`[Feature ${featureName}] ${where}`, "entity", section.entity, featureName, featureMap);
1081
- }
1082
- for (const col of section.columns) {
1083
- const normalizedCol = normalizeListColumn(col);
1084
- if (normalizedCol.refEntity !== undefined) {
1085
- assertRefTargetRegistered(`[Feature ${featureName}] ${where}`, `column "${normalizedCol.field}" (refEntity)`, normalizedCol.refEntity, featureName, featureMap);
1086
- }
1087
- }
1088
- if (section.rowClick !== undefined) {
1089
- const targetEntity = section.rowClick.entity;
1090
- const hasDetailScreen = [...featureMap.values()].some((f) => Object.values(f.screens).some((s) => s.detailFor === targetEntity));
1091
- if (!hasDetailScreen) {
1092
- throw new Error(`[Feature ${featureName}] ${where} ` +
1093
- `rowClick targets entity "${targetEntity}", but no screen declares ` +
1094
- `detailFor: "${targetEntity}". Add detailFor: "${targetEntity}" to the screen that shows it.`);
1095
- }
1096
- }
1097
- if (section.rowActions !== undefined)
1098
- validateRelatedListRowActions(args, section.rowActions);
1099
- if (section.defaultSort !== undefined)
1100
- validateRelatedListDefaultSort(args, section.defaultSort);
1101
- if (section.facets !== undefined) {
1102
- validateListFacets(`[Feature ${featureName}] ${where}`, section.facets, section.columns, featureName, featureMap);
1103
- }
1104
- if (section.toolbarActions !== undefined) {
1105
- validateRelatedListToolbarActions(args, section.toolbarActions);
1106
- }
1107
- }
1108
- // An empty list has no row, so params resolve against an empty record.
1109
- function rejectEmptyStateActionParams(ctx, actionLabel, action) {
1110
- const params = action.kind === "navigate" || action.kind === "drawer" ? action.params : undefined;
1111
- if (params !== undefined) {
1112
- throw new Error(`[Feature ${ctx.featureName}] ${ctx.where} ${actionLabel} "${action.id}" sets params — an empty list ` +
1113
- `has no row to read them from, so they would resolve against an empty record. Remove the params.`);
1114
- }
1115
- }
1116
- // Target checks shared by list/related-list rowActions and emptyState.action:
1117
- // navigate target + params, drawer target, write-handler registration.
1118
- function validateListActionTargets(ctx, actionLabel, action) {
1119
- const { featureName, screenId, screenType } = ctx;
1120
- rejectRedirectOnListRowAction(featureName, screenId, screenType, action);
1121
- if (actionLabel === "emptyState action")
1122
- rejectEmptyStateActionParams(ctx, actionLabel, action);
1123
- if (action.kind === "navigate") {
1124
- const target = resolveRowActionNavigateTarget(featureName, screenId, screenType, actionLabel, action, ctx.allScreenQns, ctx.navTargetShortIds, ctx.screensByShortId, ctx.detailForScreens);
1125
- validateRowActionNavigateParams(featureName, screenId, screenType, ctx.screenEntity, action, target, actionLabel);
1126
- }
1127
- else if (action.kind === "drawer") {
1128
- validateDrawerTargetAction(featureName, screenId, screenType, actionLabel, action, ctx.screens);
1129
- }
1130
- else if (action.kind === "clientAction") {
1131
- validateClientActionName(featureName, screenId, screenType, actionLabel, action);
1132
- }
1133
- else if (!ctx.allWriteHandlerQns.has(action.handler)) {
1134
- throw new Error(`[Feature ${featureName}] ${ctx.where} ` +
1135
- `${actionLabel} "${action.id}" handler "${action.handler}" ` +
1136
- `is not a registered write-handler. Check the QN spelling (expected ` +
1137
- `"<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`);
1138
- }
1139
- }
1140
- function validateRelatedListRowActions(args, rowActions) {
1141
- const { featureName, screenId, screenType, where, section, screens, allWriteHandlerQns, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens, } = args;
1142
- for (const action of rowActions) {
1143
- validateListActionTargets({
1144
- featureName,
1145
- screenId,
1146
- screenType,
1147
- screenEntity: undefined,
1148
- where,
1149
- screens,
1150
- allWriteHandlerQns,
1151
- allScreenQns,
1152
- navTargetShortIds,
1153
- screensByShortId,
1154
- detailForScreens,
1155
- }, "rowAction", action);
1156
- }
1157
- // section.rowClick (legacy, navigate-only) and a rowActions entry
1158
- // marked rowClick:true both claim the row-body click — same
1159
- // at-most-one constraint as entityList/projectionList's
1160
- // validateAtMostOneRowClick, just spanning two fields instead of one.
1161
- const rowClickActionCount = rowActions.filter(isRowClickAction).length;
1162
- const legacyRowClickCount = section.rowClick !== undefined ? 1 : 0;
1163
- if (rowClickActionCount + legacyRowClickCount > 1) {
1164
- throw new Error(`[Feature ${featureName}] ${where} ` +
1165
- `has both a rowClick and ${rowClickActionCount} rowActions marked ` +
1166
- `rowClick:true — at most one may fire on a row-body click.`);
1167
- }
1168
- }
1169
- function validateRelatedListDefaultSort(args, defaultSort) {
1170
- const { featureName, where, section } = args;
1171
- const sortField = defaultSort.field;
1172
- const col = section.columns.find((c) => normalizeListColumn(c).field === sortField);
1173
- if (col === undefined) {
1174
- throw new Error(`[Feature ${featureName}] ${where} ` +
1175
- `defaultSort.field "${sortField}" is not a listed column. Use a listed column as defaultSort.field.`);
1176
- }
1177
- if (normalizeListColumn(col).sortable !== true) {
1178
- throw new Error(`[Feature ${featureName}] ${where} ` +
1179
- `defaultSort.field "${sortField}" is not sortable. Set sortable: true on ` +
1180
- `the column or pick another field.`);
1181
- }
1182
- }
1183
- // Only drawer-kind and navigate actions that set tab are validated here;
1184
- // plain navigate (no tab) and writeHandler toolbarActions have no boot check yet.
1185
- function validateRelatedListToolbarActions(args, toolbarActions) {
1186
- const { featureName, screenId, screenType, screens, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens, } = args;
1187
- for (const action of toolbarActions) {
1188
- if (action.kind === "drawer") {
1189
- validateDrawerTargetAction(featureName, screenId, screenType, "toolbarAction", action, screens);
1190
- }
1191
- if (action.kind === "navigate" && action.tab !== undefined) {
1192
- resolveRowActionNavigateTarget(featureName, screenId, screenType, "toolbarAction", action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
1193
- }
1194
- }
1195
- }
1196
- // Only a navigate action that sets `tab` needs the target resolved (to check
1197
- // the tab id); plain navigates are validated elsewhere. One place for every
1198
- // section-level action site so a new site can't forget the tab check.
1199
- function validateNavigateActionTab(featureName, screenId, screenType, actionLabel, action, lookups) {
1200
- // skip: only navigate actions with an explicit tab need target validation
1201
- if (action.kind !== "navigate" || action.tab === undefined)
1202
- return;
1203
- resolveRowActionNavigateTarget(featureName, screenId, screenType, actionLabel, action, lookups.allScreenQns, lookups.navTargetShortIds, lookups.screensByShortId, lookups.detailForScreens);
1204
- }
17
+ export { readsNavigateParamsAsFormPrefill, resolveScreenTargetQn, rowFieldExtractorKeys, validateActionFieldRefs, validateActionHasIcon, validateDrawerPrefillKeys, validateScreenShortIdCollisions, } from "./screens-actions.js";
18
+ export { validateDashboardCustomPanel, validateDashboardScreenPanel } from "./screens-dashboard.js";
19
+ export { validateColumnRendererForm } from "./screens-lists.js";
1205
20
  export function validateScreens(feature, featureMap, allWriteHandlerQns, allScreenQns, configKeyDefsByQn, screensByShortId, detailForScreens) {
1206
- // navigate-Targets (rowAction/toolbarAction) dürfen cross-feature zeigen —
1207
- // der Runtime-Router (create-app) löst eine bare screenId app-weit über ALLE
1208
- // Features auf (eine deklarative Liste im owning-Feature der Entity navigiert
1209
- // so zu den Custom-Editoren der Consumer-App). Der Validator spiegelt das:
1210
- // same-feature ODER irgendein Feature. redirect/cancelTarget akzeptieren
1211
- // zusätzlich eine voll-qualifizierte Cross-Feature-QN (resolveScreenTargetQn,
1212
- // #1946) — kurze IDs bleiben same-feature wie zuvor.
21
+ // navigate targets (rowAction/toolbarAction) may point cross-feature: the
22
+ // runtime router (create-app) resolves a bare screenId app-wide across ALL
23
+ // features (a declarative list in the entity's owning feature navigates to
24
+ // the consumer app's custom editors this way). The validator mirrors that:
25
+ // same feature OR any feature. redirect/cancelTarget additionally accept a
26
+ // fully qualified cross-feature QN (resolveScreenTargetQn, #1946); short IDs
27
+ // stay same-feature as before.
1213
28
  const navTargetShortIds = screenShortIdsFrom(allScreenQns);
1214
29
  const navigateLookups = {
1215
30
  allScreenQns,
@@ -1258,9 +73,9 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1258
73
  continue;
1259
74
  }
1260
75
  if (screen.type === "projectionList") {
1261
- // Query-getrieben, keine Entity → nur query + columns prüfen (die
1262
- // Column-Felder können nicht gegen eine Entity gecheckt werden; sie
1263
- // werden zur Render-Zeit gegen die Projection-Rows aufgelöst).
76
+ // Query-driven, no entity, so only query + columns are checked (the
77
+ // column fields cannot be checked against an entity; they resolve
78
+ // against the projection rows at render time).
1264
79
  if (!screen.query || typeof screen.query !== "string") {
1265
80
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionList) has empty or non-string query. Set query to a registered query name.`);
1266
81
  }
@@ -1329,9 +144,9 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
1329
144
  continue;
1330
145
  }
1331
146
  if (screen.type === "projectionDetail") {
1332
- // Query-getrieben wie projectionList, aber Single-Row + Layout statt
1333
- // Columns. Kein Entity-Check möglich — Felder werden render-seitig
1334
- // gegen die Query-Response aufgelöst, nicht gegen eine Entity.
147
+ // Query-driven like projectionList, but a single row plus layout instead
148
+ // of columns. No entity check is possible: fields resolve against the
149
+ // query response at render time, not against an entity.
1335
150
  if (!screen.query || typeof screen.query !== "string") {
1336
151
  throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has empty or non-string query. Set query to a registered query name.`);
1337
152
  }
@@ -2059,594 +874,6 @@ export function validateScreens(feature, featureMap, allWriteHandlerQns, allScre
2059
874
  }
2060
875
  }
2061
876
  }
2062
- // Panel-getrieben, keine Entity — Struktur-Checks (eindeutige Panel-Ids,
2063
- // non-empty Queries/Columns/valueField); die Query-Contracts (Stat-Record,
2064
- // Points-Envelope, Paged-Rows) werden zur Render-Zeit aufgelöst.
2065
- function validateDashboardScreen(featureName, screenId, screen, featureMap) {
2066
- if (screen.panels.length === 0) {
2067
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) has an empty panels list — ` +
2068
- `declare at least one panel.`);
2069
- }
2070
- const panelIds = new Set();
2071
- const addPanelId = (id, context) => {
2072
- if (panelIds.has(id)) {
2073
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) has duplicate panel id "${id}" (${context}). Give each panel a unique id.`);
2074
- }
2075
- panelIds.add(id);
2076
- };
2077
- for (const panel of screen.panels) {
2078
- addPanelId(panel.id, panel.kind);
2079
- if (panel.kind === "stat-group") {
2080
- validateDashboardStatGroupPanel(featureName, screenId, panel, addPanelId);
2081
- }
2082
- else if (panel.kind === "custom") {
2083
- validateDashboardCustomPanel(featureName, screenId, panel);
2084
- }
2085
- else if (panel.kind === "screen") {
2086
- validateDashboardScreenPanel(featureName, screenId, panel, featureMap);
2087
- }
2088
- else if (panel.kind === "text") {
2089
- validateDashboardTextPanel(featureName, screenId, panel, featureMap);
2090
- }
2091
- else {
2092
- validateDashboardQueryPanel(featureName, screenId, panel, featureMap);
2093
- }
2094
- }
2095
- if (screen.filter !== undefined) {
2096
- validateDashboardFilterDefinition(featureName, screenId, screen.filter);
2097
- }
2098
- if (screen.timeRange !== undefined) {
2099
- validateDashboardTimeRange(featureName, screenId, screen.timeRange, screen.filter?.id);
2100
- }
2101
- }
2102
- function validateDashboardActions(featureName, screenId, screen, allWriteHandlerQns, lookups) {
2103
- const { allScreenQns, navTargetShortIds, screensByShortId, detailForScreens } = lookups;
2104
- // Header actions run on the bound record like projectionDetail's, minus
2105
- // what needs a drawer host or a record screen to leave.
2106
- for (const action of screen.actions ?? []) {
2107
- const where = `[Feature ${featureName}] Screen "${screenId}" (dashboard) action "${action.id}"`;
2108
- validateActionHasIcon(featureName, screenId, "dashboard", "action", action);
2109
- if (isRowClickAction(action)) {
2110
- throw new Error(`${where} sets rowClick: true — a dashboard has no row to click. Remove rowClick.`);
2111
- }
2112
- if (action.kind === "drawer") {
2113
- throw new Error(`${where} is a drawer action — a dashboard has no drawer host. Use a navigate, ` +
2114
- `writeHandler or clientAction action.`);
2115
- }
2116
- if (action.kind === "navigate") {
2117
- const target = resolveRowActionNavigateTarget(featureName, screenId, "dashboard", "action", action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
2118
- validateRowActionNavigateParams(featureName, screenId, "dashboard", screen.detailFor, action, target);
2119
- }
2120
- else if (action.kind === "clientAction") {
2121
- validateClientActionName(featureName, screenId, "dashboard", "action", action);
2122
- }
2123
- else {
2124
- if (action.redirect !== undefined) {
2125
- throw new Error(`${where} sets redirect — a dashboard action stays on the dashboard and refetches ` +
2126
- `after success. Remove redirect.`);
2127
- }
2128
- if (!allWriteHandlerQns.has(action.handler)) {
2129
- throw new Error(`${where} handler "${action.handler}" is not a registered write-handler. Check the QN ` +
2130
- `spelling (expected "<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`);
2131
- }
2132
- }
2133
- }
2134
- }
2135
- // record / actions / refresh hang together: actions and a field-driven refresh
2136
- // read the record row, and the record id travels in the URL next to the
2137
- // filter and time-range values.
2138
- function validateDashboardRecordBinding(featureName, screenId, screen) {
2139
- const where = `[Feature ${featureName}] Screen "${screenId}" (dashboard)`;
2140
- const { record, refresh } = screen;
2141
- if (record === undefined) {
2142
- if (screen.actions !== undefined) {
2143
- throw new Error(`${where} declares actions without a record — the actions run on the bound record. ` +
2144
- `Add record: { param } or remove actions.`);
2145
- }
2146
- }
2147
- else {
2148
- validateDashboardRecordShape(where, record, [screen.filter?.id, screen.timeRange?.id]);
2149
- }
2150
- // skip: no refresh declared, nothing to validate
2151
- if (refresh === undefined)
2152
- return;
2153
- validateDashboardRefresh(where, refresh, record);
2154
- }
2155
- function validateDashboardRecordShape(where, record, otherUrlParamIds) {
2156
- if (typeof record.param !== "string" || record.param.length === 0) {
2157
- throw new Error(`${where} record.param is empty. Name the URL search param carrying the id.`);
2158
- }
2159
- if (record.payloadKey !== undefined && record.payloadKey.length === 0) {
2160
- throw new Error(`${where} record.payloadKey is empty. Omit it to reuse record.param as the payload key.`);
2161
- }
2162
- if (record.query !== undefined && record.query.length === 0) {
2163
- throw new Error(`${where} record.query is empty. Set a registered query name or omit it.`);
2164
- }
2165
- if (record.titleField !== undefined && isTitleFieldUnusable(record)) {
2166
- throw new Error(`${where} record.titleField needs record.query and a field name — the title is read ` +
2167
- `from the record row.`);
2168
- }
2169
- const payloadKey = record.payloadKey ?? record.param;
2170
- for (const other of otherUrlParamIds) {
2171
- if (other !== undefined && (other === record.param || other === payloadKey)) {
2172
- throw new Error(`${where} record.param/payloadKey "${other}" collides with the filter or timeRange id — ` +
2173
- `all of them live in the URL search params and panel payloads. Use a different name.`);
2174
- }
2175
- }
2176
- }
2177
- function isTitleFieldUnusable(record) {
2178
- return record.query === undefined || record.titleField === "";
2179
- }
2180
- function validateDashboardRefresh(where, refresh, record) {
2181
- if ("seconds" in refresh && "field" in refresh) {
2182
- throw new Error(`${where} refresh sets both seconds and field — set exactly one.`);
2183
- }
2184
- if ("seconds" in refresh) {
2185
- if (!isRefreshSecondsAllowed(refresh.seconds)) {
2186
- throw new Error(`${where} refresh.seconds "${String(refresh.seconds)}" must be a number of at least ` +
2187
- `${DASHBOARD_MIN_REFRESH_SECONDS} — shorter intervals would hammer every panel query.`);
2188
- }
2189
- // skip: seconds validated, field rules below do not apply
2190
- return;
2191
- }
2192
- if (refresh.field.length === 0) {
2193
- throw new Error(`${where} refresh.field is empty. Name the record field holding the seconds.`);
2194
- }
2195
- if (record?.query === undefined) {
2196
- throw new Error(`${where} refresh.field "${refresh.field}" needs record.query to read the seconds from. ` +
2197
- `Add record: { param, query } or use refresh: { seconds }.`);
2198
- }
2199
- }
2200
- function isRefreshSecondsAllowed(seconds) {
2201
- return Number.isFinite(seconds) && seconds >= DASHBOARD_MIN_REFRESH_SECONDS;
2202
- }
2203
- function validateDashboardTimeRange(featureName, screenId, timeRange, filterId) {
2204
- const prefix = `[Feature ${featureName}] Screen "${screenId}" (dashboard) timeRange`;
2205
- if (timeRange.id.length === 0)
2206
- throw new Error(`${prefix} has an empty id. Set a non-empty id.`);
2207
- if (timeRange.id === filterId) {
2208
- 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.`);
2209
- }
2210
- if (timeRange.options.length === 0) {
2211
- throw new Error(`${prefix}.options is empty — declare at least one option.`);
2212
- }
2213
- const values = new Set();
2214
- for (const option of timeRange.options) {
2215
- if (option.value.length === 0 || option.label.length === 0) {
2216
- throw new Error(`${prefix} option needs a non-empty value and label. Give every option both.`);
2217
- }
2218
- if (values.has(option.value)) {
2219
- throw new Error(`${prefix}.options has duplicate value "${option.value}". Use each option value once.`);
2220
- }
2221
- values.add(option.value);
2222
- }
2223
- if (!values.has(timeRange.default)) {
2224
- throw new Error(`${prefix}.default "${timeRange.default}" is not among the options (${[...values].join(", ")}). Set default to one of the options.`);
2225
- }
2226
- }
2227
- const EMBEDDABLE_SCREEN_TYPES = new Set([
2228
- "projectionList",
2229
- "actionForm",
2230
- "secretMint",
2231
- "configEdit",
2232
- "secretsEdit",
2233
- ]);
2234
- function validateEmbeddableScreenRef(context, prop, ref, featureName, featureMap) {
2235
- const targetQn = resolveScreenTargetQn(featureName, ref);
2236
- const [targetFeatureName = "", , targetShortId = ""] = targetQn.split(":");
2237
- const target = featureMap.get(targetFeatureName)?.screens[targetShortId];
2238
- if (target === undefined) {
2239
- throw new Error(`${context} ${prop} "${ref}" does not resolve to a registered screen (checked "${targetQn}"). ` +
2240
- `Use a same-feature short id or a cross-feature QN "<feature>:screen:<id>".`);
2241
- }
2242
- if (!EMBEDDABLE_SCREEN_TYPES.has(target.type)) {
2243
- throw new Error(`${context} embeds "${targetQn}" of type "${target.type}", which can't be embedded — allowed: ` +
2244
- `${[...EMBEDDABLE_SCREEN_TYPES].join(", ")}. Use a custom panel for app components.`);
2245
- }
2246
- // ActionFormBody/SecretMintBody navigate via nav.navigate after submit or
2247
- // cancel even when embedded, which would pull the user off the dashboard.
2248
- if ((target.type === "actionForm" || target.type === "secretMint") &&
2249
- (target.redirect !== undefined ||
2250
- (target.cancelTarget !== undefined && target.cancelTarget !== false))) {
2251
- throw new Error(`${context} embeds "${targetQn}", which sets redirect/cancelTarget — an embedded form would ` +
2252
- `navigate away from the dashboard on submit or cancel. Embed a screen without redirect and ` +
2253
- `with cancelTarget: false (or omitted).`);
2254
- }
2255
- return target;
2256
- }
2257
- export function validateDashboardScreenPanel(featureName, screenId, panel, featureMap) {
2258
- const context = `[Feature ${featureName}] Screen "${screenId}" (dashboard) screen-panel "${panel.id}"`;
2259
- validateEmbeddableScreenRef(context, "screen", panel.screen, featureName, featureMap);
2260
- validateDashboardPanelVisibleWhen(context, panel.visibleWhen);
2261
- // skip: no fallback declared, nothing more to validate
2262
- if (panel.fallback === undefined)
2263
- return;
2264
- if (panel.visibleWhen === undefined) {
2265
- throw new Error(`${context} declares fallback without visibleWhen. Add visibleWhen or remove fallback.`);
2266
- }
2267
- const target = validateEmbeddableScreenRef(context, "fallback", panel.fallback, featureName, featureMap);
2268
- // A gated fallback could hide in turn; the tile would show nothing where it promised a stand-in.
2269
- if (target.visibleWhen !== undefined) {
2270
- throw new Error(`${context} fallback "${panel.fallback}" has its own visibleWhen; a fallback must always render.`);
2271
- }
2272
- }
2273
- function validateDashboardTextPanel(featureName, screenId, panel, featureMap) {
2274
- const context = `[Feature ${featureName}] Screen "${screenId}" (dashboard) text-panel "${panel.id}"`;
2275
- if (panel.blocks.length === 0) {
2276
- throw new Error(`${context} has an empty blocks list. Add at least one block.`);
2277
- }
2278
- validateDashboardPanelSpan(featureName, screenId, panel);
2279
- validateDashboardPanelVisibleWhen(context, panel.visibleWhen);
2280
- for (const block of panel.blocks) {
2281
- if (block.kind === "list" && block.items.length === 0) {
2282
- throw new Error(`${context} has a list block without items. Add items or remove the block.`);
2283
- }
2284
- if (block.kind === "link")
2285
- validateDashboardTextLinkBlock(context, block, featureName, featureMap);
2286
- }
2287
- }
2288
- function validateDashboardTextLinkBlock(context, block, featureName, featureMap) {
2289
- const targetQn = resolveScreenTargetQn(featureName, block.screen);
2290
- const [targetFeatureName = "", , targetShortId = ""] = targetQn.split(":");
2291
- if (featureMap.get(targetFeatureName)?.screens[targetShortId] === undefined) {
2292
- throw new Error(`${context} link block screen "${block.screen}" does not resolve to a registered screen ` +
2293
- `(checked "${targetQn}"). Use a same-feature short id or a cross-feature QN "<feature>:screen:<id>".`);
2294
- }
2295
- if (block.roles?.length === 0) {
2296
- throw new Error(`${context} link block "${block.screen}" has an empty roles list, which would hide it from everyone. ` +
2297
- `List the roles or omit roles.`);
2298
- }
2299
- }
2300
- function validateDashboardPanelVisibleWhen(context, visibleWhen) {
2301
- if (visibleWhen !== undefined && (visibleWhen.query === "" || visibleWhen.field === "")) {
2302
- throw new Error(`${context} visibleWhen needs a non-empty query and field. Set both visibleWhen.query and visibleWhen.field.`);
2303
- }
2304
- }
2305
- function validateDashboardPanelSpan(featureName, screenId, panel) {
2306
- if (panel.span !== undefined && panel.span !== "half" && panel.span !== "full") {
2307
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" has span "${String(panel.span)}" — expected "half" or "full".`);
2308
- }
2309
- }
2310
- function validateDashboardStatGroupPanel(featureName, screenId, panel, addPanelId) {
2311
- const context = `[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-group "${panel.id}"`;
2312
- if (panel.stats.length === 0) {
2313
- throw new Error(`${context} has an empty stats list. Add at least one stat.`);
2314
- }
2315
- if (panel.subtitle !== undefined && panel.label === undefined) {
2316
- 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.`);
2317
- }
2318
- validateDashboardPanelSpan(featureName, screenId, panel);
2319
- validateDashboardPanelVisibleWhen(context, panel.visibleWhen);
2320
- for (const stat of panel.stats) {
2321
- addPanelId(stat.id, "stat-group child");
2322
- if (stat.visibleWhen !== undefined) {
2323
- throw new Error(`${context} child "${stat.id}" sets visibleWhen — gate the whole group instead; a hidden cell would leave a gap in the strip.`);
2324
- }
2325
- if (!stat.query || typeof stat.query !== "string") {
2326
- 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.`);
2327
- }
2328
- if (stat.valueField.length === 0) {
2329
- 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.`);
2330
- }
2331
- validateDashboardValueFormat(featureName, screenId, stat.id, stat.valueFormat, STAT_VALUE_FORMATS);
2332
- }
2333
- }
2334
- function validateFromTodayWindowReach(where, panel) {
2335
- if (panel.initialWindow === "from-today" && panel.ranges === undefined && panel.brush !== true) {
2336
- throw new Error(`${where}.initialWindow "from-today" needs ranges or brush, otherwise data before today is unreachable.`);
2337
- }
2338
- }
2339
- function validateChartScrollableAndUnit(where, panel) {
2340
- if (panel.scrollable !== undefined && typeof panel.scrollable !== "boolean") {
2341
- throw new Error(`${where} has a non-boolean scrollable. Use true or false.`);
2342
- }
2343
- if (panel.scrollable === true && panel.chart !== "stacked-area") {
2344
- throw new Error(`${where} sets scrollable on chart "${panel.chart}" — only "stacked-area" supports it. Remove scrollable or use chart "stacked-area".`);
2345
- }
2346
- if (panel.unit !== undefined && panel.chart !== "timeseries") {
2347
- throw new Error(`${where} sets unit on chart "${panel.chart}" — only "timeseries" supports it. Remove unit or use chart "timeseries".`);
2348
- }
2349
- }
2350
- function validateDashboardChartDisplay(featureName, screenId, panel) {
2351
- validateDashboardValueFormat(featureName, screenId, panel.id, panel.valueFormat, CHART_VALUE_FORMATS);
2352
- const where = `[Feature ${featureName}] Screen "${screenId}" (dashboard) chart-panel "${panel.id}"`;
2353
- validateChartScrollableAndUnit(where, panel);
2354
- validateStackedAreaOnlyProps(where, panel);
2355
- validateStackedAreaEnumProp(where, "dateFormat", panel.dateFormat, ["day", "month"]);
2356
- validateStackedAreaEnumProp(where, "initialWindow", panel.initialWindow, [
2357
- "default-range",
2358
- "from-today",
2359
- ]);
2360
- validateStackedAreaEnumProp(where, "markerLegend", panel.markerLegend, ["list", "legend"]);
2361
- const nonBooleanProp = ["brush", "legendTotals", "markerLabels"].find((prop) => panel[prop] !== undefined && typeof panel[prop] !== "boolean");
2362
- if (nonBooleanProp !== undefined) {
2363
- throw new Error(`${where} has a non-boolean ${nonBooleanProp}. Use true or false.`);
2364
- }
2365
- validateFromTodayWindowReach(where, panel);
2366
- for (const [key, color] of Object.entries(panel.seriesColors ?? {})) {
2367
- if (typeof color !== "string" || color.trim() === "") {
2368
- throw new Error(`${where} seriesColors["${key}"] must be a non-empty CSS color.`);
2369
- }
2370
- }
2371
- for (const [kind, look] of Object.entries(panel.markerKinds ?? {})) {
2372
- validateDashboardChartMarkerKind(`${where} markerKinds["${kind}"]`, look);
2373
- }
2374
- if (panel.ranges !== undefined)
2375
- validateDashboardChartRanges(`${where} ranges`, panel.ranges);
2376
- }
2377
- function validateStackedAreaEnumProp(where, prop, value, allowed) {
2378
- // skip: optional prop, nothing to validate when unset
2379
- if (value === undefined)
2380
- return;
2381
- if (!allowed.includes(value)) {
2382
- throw new Error(`${where}.${prop} "${String(value)}" must be one of ${allowed.join(", ")}.`);
2383
- }
2384
- }
2385
- function validateStackedAreaOnlyProps(where, panel) {
2386
- // skip: stacked-area is the one chart kind that supports these props
2387
- if (panel.chart === "stacked-area")
2388
- return;
2389
- const stackedAreaOnly = [
2390
- "markerKinds",
2391
- "legendTotals",
2392
- "ranges",
2393
- "brush",
2394
- "dateFormat",
2395
- "initialWindow",
2396
- "markerLegend",
2397
- "markerLabels",
2398
- ].filter((prop) => panel[prop] !== undefined);
2399
- if (stackedAreaOnly.length > 0) {
2400
- throw new Error(`${where} sets ${stackedAreaOnly.join(", ")} on chart "${panel.chart}" — only "stacked-area" supports it. Remove these props or use chart "stacked-area".`);
2401
- }
2402
- }
2403
- const DASHBOARD_CHART_TONES = new Set([
2404
- "positive",
2405
- "negative",
2406
- "active",
2407
- "neutral",
2408
- ]);
2409
- function validateDashboardChartMarkerKind(where, look) {
2410
- if (look.tone === undefined && look.color === undefined) {
2411
- throw new Error(`${where} needs a tone or a color. Set tone or color.`);
2412
- }
2413
- if (look.tone !== undefined && !DASHBOARD_CHART_TONES.has(look.tone)) {
2414
- throw new Error(`${where}.tone "${String(look.tone)}" is not one of ${[...DASHBOARD_CHART_TONES].join(", ")}.`);
2415
- }
2416
- if (look.color !== undefined && (typeof look.color !== "string" || look.color.trim() === "")) {
2417
- throw new Error(`${where}.color must be a non-empty CSS color.`);
2418
- }
2419
- if (look.label !== undefined && (typeof look.label !== "string" || look.label.trim() === "")) {
2420
- throw new Error(`${where}.label must be a non-empty i18n key.`);
2421
- }
2422
- }
2423
- function validateDashboardChartRanges(where, ranges) {
2424
- if (ranges.options.length === 0) {
2425
- throw new Error(`${where}.options is empty — declare at least one option.`);
2426
- }
2427
- const values = new Set();
2428
- for (const option of ranges.options) {
2429
- if (option.value.length === 0 || option.label.length === 0) {
2430
- throw new Error(`${where} option needs a non-empty value and label. Give every option both.`);
2431
- }
2432
- if (values.has(option.value)) {
2433
- throw new Error(`${where}.options has duplicate value "${option.value}". Use each option value once.`);
2434
- }
2435
- if (option.months !== undefined && (!Number.isInteger(option.months) || option.months <= 0)) {
2436
- throw new Error(`${where} option "${option.value}" months "${String(option.months)}" must be a positive integer.`);
2437
- }
2438
- values.add(option.value);
2439
- }
2440
- if (!values.has(ranges.default)) {
2441
- throw new Error(`${where}.default "${ranges.default}" is not among the options (${[...values].join(", ")}). Set default to one of the options.`);
2442
- }
2443
- }
2444
- const CURRENCY_CODE_RE = /^[A-Z]{3}$/;
2445
- const MAX_VALUE_FORMAT_FRACTION_DIGITS = 4;
2446
- const STAT_VALUE_FORMATS = [
2447
- "currency",
2448
- "relativeTime",
2449
- ];
2450
- const CHART_VALUE_FORMATS = ["currency"];
2451
- function validateDashboardValueFormat(featureName, screenId, panelId, valueFormat, allowedKinds) {
2452
- // skip: valueFormat is optional, nothing to validate without it
2453
- if (valueFormat === undefined)
2454
- return;
2455
- const where = `[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panelId}" valueFormat`;
2456
- if (!allowedKinds.includes(valueFormat.kind)) {
2457
- throw new Error(`${where}.kind "${String(valueFormat.kind)}" must be ${allowedKinds.map((kind) => `"${kind}"`).join(" or ")}.`);
2458
- }
2459
- // skip: relativeTime carries no options
2460
- if (valueFormat.kind === "relativeTime")
2461
- return;
2462
- if (!CURRENCY_CODE_RE.test(valueFormat.currency)) {
2463
- throw new Error(`${where}.currency "${String(valueFormat.currency)}" must be a 3-letter uppercase ISO currency code.`);
2464
- }
2465
- const digits = valueFormat.fractionDigits;
2466
- if (digits !== undefined &&
2467
- (!Number.isInteger(digits) || digits < 0 || digits > MAX_VALUE_FORMAT_FRACTION_DIGITS)) {
2468
- throw new Error(`${where}.fractionDigits "${String(digits)}" must be an integer from 0 to ${MAX_VALUE_FORMAT_FRACTION_DIGITS}.`);
2469
- }
2470
- }
2471
- export function validateDashboardCustomPanel(featureName, screenId, panel) {
2472
- if (panel.component.react === undefined && panel.component.native === undefined) {
2473
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) custom-panel "${panel.id}" has no component — ` +
2474
- `declare a react/native component marker.`);
2475
- }
2476
- }
2477
- function validateDashboardQueryPanel(featureName, screenId, panel, featureMap) {
2478
- if (!panel.query || typeof panel.query !== "string") {
2479
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" has empty or non-string query. Set query to a registered query name.`);
2480
- }
2481
- validateDashboardPanelSpan(featureName, screenId, panel);
2482
- if (panel.kind === "stat") {
2483
- if (panel.valueField.length === 0) {
2484
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-panel "${panel.id}" has empty valueField. Set valueField to a field of the query result.`);
2485
- }
2486
- validateDashboardValueFormat(featureName, screenId, panel.id, panel.valueFormat, STAT_VALUE_FORMATS);
2487
- }
2488
- validateDashboardPanelVisibleWhen(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}"`, panel.visibleWhen);
2489
- if (panel.kind === "chart")
2490
- validateDashboardChartDisplay(featureName, screenId, panel);
2491
- if (panel.kind === "list") {
2492
- if (panel.columns.length === 0) {
2493
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) list-panel "${panel.id}" has an empty columns list. Add at least one column.`);
2494
- }
2495
- for (const col of panel.columns) {
2496
- const normalized = normalizeListColumn(col);
2497
- validateColumnRendererForm(featureName, screenId, normalized);
2498
- if (normalized.refEntity !== undefined) {
2499
- assertRefTargetRegistered(`[Feature ${featureName}] Screen "${screenId}" (dashboard) list-panel "${panel.id}"`, `column "${normalized.field}" (refEntity)`, normalized.refEntity, featureName, featureMap);
2500
- }
2501
- }
2502
- }
2503
- }
2504
- function validateDashboardFilterDefinition(featureName, screenId, filter) {
2505
- if (filter.id.length === 0) {
2506
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) filter has an empty id. Set a non-empty id.`);
2507
- }
2508
- if (filter.label.length === 0) {
2509
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) filter has an empty label. Set a non-empty label.`);
2510
- }
2511
- const hasOptions = filter.options !== undefined;
2512
- const hasOptionsQuery = filter.optionsQuery !== undefined;
2513
- if (hasOptions === hasOptionsQuery) {
2514
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) filter must set exactly one of ` +
2515
- `options/optionsQuery.`);
2516
- }
2517
- if (hasOptions && (filter.options?.length ?? 0) === 0) {
2518
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) filter.options is empty — ` +
2519
- `declare at least one option or use optionsQuery instead.`);
2520
- }
2521
- if (hasOptionsQuery && filter.optionsQuery?.length === 0) {
2522
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) filter.optionsQuery is empty. Set optionsQuery to a query name or remove it.`);
2523
- }
2524
- }
2525
- // Form-check für ListColumn-Renderer in der PlatformComponent-Form
2526
- // (`{ react: { __component: "Name" } }`). Der Server kennt die client-
2527
- // seitige columnRenderers-Map nicht — also nur prüfen ob die Struktur
2528
- // stimmt: wenn `react` als Object gesetzt ist, MUSS `__component` ein
2529
- // nicht-leerer String sein. Ein client-seitig ausgelassener Key löst
2530
- // nur eine Warnung aus, kein Boot-Fail.
2531
- // Column-level sortable: true only enables relatedList headers. On entityList
2532
- // the entity field's flag wins; on projectionList sortable comes from the
2533
- // query schema and a column can only opt OUT with sortable: false.
2534
- function rejectInertColumnSortable(featureName, screenId, screenType, column) {
2535
- const isInert = screenType === "entityList" ? column.sortable !== undefined : column.sortable === true;
2536
- // skip: the sortable flag is meaningful for this screen type
2537
- if (!isInert)
2538
- return;
2539
- const source = screenType === "entityList"
2540
- ? "set sortable: true on the entity field instead"
2541
- : "sorting is derived from the query's Zod schema; only sortable: false is honored";
2542
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) column "${column.field}" sets sortable — ` +
2543
- `it is ignored here; ${source}. Remove sortable from this column.`);
2544
- }
2545
- export function validateColumnRendererForm(featureName, screenId, column) {
2546
- const renderer = column.renderer;
2547
- // skip: nur die PlatformComponent-Form ({ react: { __component: "..." } })
2548
- // wird strukturell validiert. Funktions-, String-QN- und null/undefined-
2549
- // Renderer sind alle gültige andere Formen — kein Form-Fehler.
2550
- if (renderer === null || typeof renderer !== "object")
2551
- return;
2552
- // @cast-boundary schema-walk — feature-config renderer-shape introspection
2553
- const react = renderer.react;
2554
- // skip: kein react-Branch → entweder native-only oder kein
2555
- // PlatformComponent — beides außerhalb dieses Checks.
2556
- if (react === undefined || react === null)
2557
- return;
2558
- if (typeof react !== "object") {
2559
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" column "${column.field}" has a renderer with ` +
2560
- `a non-object \`react\` branch — expected \`{ react: { __component: "Name" } }\`.`);
2561
- }
2562
- // @cast-boundary schema-walk — feature-config react-branch introspection
2563
- const component = react.__component;
2564
- // skip: ohne __component-Schlüssel ist das keine String-Key-Form
2565
- // (z.B. ein zukünftiger direkter Component-Ref); nicht unsere Domäne.
2566
- if (component === undefined)
2567
- return;
2568
- if (typeof component !== "string" || component.length === 0) {
2569
- throw new Error(`[Feature ${featureName}] Screen "${screenId}" column "${column.field}" has a renderer with ` +
2570
- `\`react.__component\` = ${JSON.stringify(component)} — expected a non-empty string identifying ` +
2571
- `a client-side columnRenderers entry.`);
2572
- }
2573
- }
2574
- // Shared entity-target resolution for reference facets, refEntity columns
2575
- // (projectionList/relatedList) and refEntity fields (projectionDetail).
2576
- function assertRefTargetRegistered(prefix, subject, refTarget, currentFeatureName, featureMap) {
2577
- const target = parseRefTarget(refTarget, currentFeatureName);
2578
- const targetFeature = featureMap.get(target.featureName);
2579
- if (targetFeature === undefined) {
2580
- throw new Error(`${prefix} ${subject} targets entity "${refTarget}", but feature "${target.featureName}" is not ` +
2581
- `mounted — mount it and declare r.requires({ features: ["${target.featureName}"] }).`);
2582
- }
2583
- if (targetFeature.entities?.[target.entityName] === undefined) {
2584
- throw new Error(`${prefix} ${subject} targets entity "${refTarget}", which does not resolve to a ` +
2585
- `registered entity. Known entities in feature "${target.featureName}": ` +
2586
- `${Object.keys(targetFeature.entities ?? {})
2587
- .sort()
2588
- .join(", ") || "(none)"}.`);
2589
- }
2590
- }
2591
- // Facets (fw#2224) — unlike filter, a field inventory IS available here: the
2592
- // declared columns. A facet on a field with no column is almost always a
2593
- // typo (the user never sees the field anywhere), so this is hard-checked
2594
- // rather than just documented. Shared by projectionList and relatedList
2595
- // (fw#2740) — `prefix` carries the caller's own screen/section message lead-in.
2596
- //
2597
- // `type: "reference"` is exempt from the same-name-column rule (fw akte-
2598
- // bedienkonzept-2): it filters by an id field (e.g. "propertyId") but
2599
- // displays a different, human-readable column (e.g. "propertyLabel") — a
2600
- // column named after the filter field would almost never exist, and
2601
- // requiring one anyway would force apps to add a column no design calls
2602
- // for just to satisfy this check. Its own `entity` is mandatory on the type
2603
- // (TS, not just this validator) and is resolved below — that explicit,
2604
- // checked declaration is this facet type's field inventory, filling the
2605
- // same "not just a typo" role the column-name check plays for select/
2606
- // boolean facets. select/boolean facets keep the column requirement
2607
- // unchanged: both filter and display the same field, so a facet with no
2608
- // matching column is still almost always a typo.
2609
- function validateListFacets(prefix, facets, columns, currentFeatureName, featureMap) {
2610
- const columnFieldNames = new Set(columns.map((col) => normalizeListColumn(col).field));
2611
- const seenFacetFields = new Set();
2612
- for (const facet of facets) {
2613
- if (seenFacetFields.has(facet.field)) {
2614
- throw new Error(`${prefix} declares facet "${facet.field}" more than once. Declare each facet field once.`);
2615
- }
2616
- seenFacetFields.add(facet.field);
2617
- if (facet.type !== "reference" && !columnFieldNames.has(facet.field)) {
2618
- throw new Error(`${prefix} facet references field "${facet.field}" which is not a declared column. ` +
2619
- `Known columns: ${[...columnFieldNames].sort().join(", ")}`);
2620
- }
2621
- if (facet.type === "select" && facet.options.length === 0) {
2622
- throw new Error(`${prefix} facet "${facet.field}" (type "select") has an empty options list — ` +
2623
- `declare at least one option.`);
2624
- }
2625
- if (facet.type === "reference") {
2626
- assertRefTargetRegistered(prefix, `facet "${facet.field}" (type "reference")`, facet.entity, currentFeatureName, featureMap);
2627
- assertReferenceListHandlerRegistered(prefix, `facet "${facet.field}" (type "reference")`, facet.entity, currentFeatureName, featureMap);
2628
- }
2629
- }
2630
- }
2631
- // The facet's options are loaded at render time by dispatching the target's
2632
- // list query (renderer useReferenceLookup, incl. its REFERENCE_LOOKUP_SOURCES
2633
- // override); a missing or unregistered handler is swallowed there and leaves
2634
- // a permanently empty dropdown, so it has to fail at boot instead.
2635
- function assertReferenceListHandlerRegistered(prefix, subject, refTarget, currentFeatureName, featureMap) {
2636
- const target = parseRefTarget(refTarget, currentFeatureName);
2637
- const override = REFERENCE_LOOKUP_SOURCES[`${target.featureName}:${target.entityName}`];
2638
- const expectedQn = override?.queryQn ??
2639
- qualifyEntityName(target.featureName, "query", `${target.entityName}:list`);
2640
- for (const feature of featureMap.values()) {
2641
- for (const handlerName of Object.keys(feature.queryHandlers ?? {})) {
2642
- // skip: expected list handler found
2643
- if (qualifyEntityName(feature.name, "query", handlerName) === expectedQn)
2644
- return;
2645
- }
2646
- }
2647
- throw new Error(`${prefix} ${subject} loads its options from query "${expectedQn}", which is not a registered ` +
2648
- `query handler — register the entity's list handler (defineEntityListHandler) or the facet renders an empty dropdown.`);
2649
- }
2650
877
  export function findEntityFeature(entityName, featureMap) {
2651
878
  for (const [name, feature] of featureMap) {
2652
879
  if (feature.entities?.[entityName])