@cosmicdrift/kumiko-framework 0.383.1 → 0.385.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 (82) hide show
  1. package/agents/examples/custom-fields-basic.md +13 -3
  2. package/agents/examples/designer-demo.md +6 -2
  3. package/agents/examples/i18n.md +36 -1
  4. package/agents/features.md +4 -0
  5. package/dist/api/auth-rate-limiters.d.ts +8 -0
  6. package/dist/api/auth-rate-limiters.js +105 -0
  7. package/dist/api/auth-routes-context.d.ts +34 -0
  8. package/dist/api/auth-routes-context.js +192 -0
  9. package/dist/api/auth-routes-invite.d.ts +2 -0
  10. package/dist/api/auth-routes-invite.js +171 -0
  11. package/dist/api/auth-routes-login.d.ts +2 -0
  12. package/dist/api/auth-routes-login.js +103 -0
  13. package/dist/api/auth-routes-mfa.d.ts +4 -0
  14. package/dist/api/auth-routes-mfa.js +211 -0
  15. package/dist/api/auth-routes-session.d.ts +2 -0
  16. package/dist/api/auth-routes-session.js +144 -0
  17. package/dist/api/auth-routes-shared.d.ts +33 -0
  18. package/dist/api/auth-routes-shared.js +81 -0
  19. package/dist/api/auth-routes-signup.d.ts +2 -0
  20. package/dist/api/auth-routes-signup.js +75 -0
  21. package/dist/api/auth-routes-token-flows.d.ts +4 -0
  22. package/dist/api/auth-routes-token-flows.js +91 -0
  23. package/dist/api/auth-routes-types.d.ts +76 -0
  24. package/dist/api/auth-routes-types.js +1 -0
  25. package/dist/api/auth-routes.d.ts +5 -85
  26. package/dist/api/auth-routes.js +20 -1095
  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/dialect.js +1 -1
  31. package/dist/db/distinct-on.d.ts +9 -0
  32. package/dist/db/distinct-on.js +28 -0
  33. package/dist/db/event-store-executor-write.js +2 -2
  34. package/dist/db/migrate-runner.js +4 -4
  35. package/dist/db/pg-error.js +0 -12
  36. package/dist/db/queries/event-consumer.js +3 -3
  37. package/dist/db/queries/postgres-array-literal.js +2 -2
  38. package/dist/db/query.js +48 -20
  39. package/dist/db/tenant-db.js +1 -1
  40. package/dist/engine/boot-validator/i18n-keys.d.ts +2 -0
  41. package/dist/engine/boot-validator/i18n-keys.js +15 -1
  42. package/dist/engine/boot-validator/index.d.ts +4 -0
  43. package/dist/engine/boot-validator/index.js +3 -1
  44. package/dist/engine/boot-validator/pii-retention.js +6 -7
  45. package/dist/engine/boot-validator/screens-actions.d.ts +67 -0
  46. package/dist/engine/boot-validator/screens-actions.js +488 -0
  47. package/dist/engine/boot-validator/screens-dashboard.d.ts +15 -0
  48. package/dist/engine/boot-validator/screens-dashboard.js +467 -0
  49. package/dist/engine/boot-validator/screens-forms.d.ts +24 -0
  50. package/dist/engine/boot-validator/screens-forms.js +516 -0
  51. package/dist/engine/boot-validator/screens-lists.d.ts +47 -0
  52. package/dist/engine/boot-validator/screens-lists.js +319 -0
  53. package/dist/engine/boot-validator/screens.d.ts +4 -21
  54. package/dist/engine/boot-validator/screens.js +26 -1799
  55. package/dist/engine/build-app-schema.js +1 -1
  56. package/dist/engine/factories.d.ts +3 -3
  57. package/dist/engine/steps/read-find-one.js +1 -1
  58. package/dist/engine/types/index.d.ts +1 -1
  59. package/dist/event-store/provenance-append.js +1 -1
  60. package/dist/files/file-ref-entity.d.ts +6 -126
  61. package/dist/files/file-ref-table.d.ts +6 -126
  62. package/dist/files/file-routes.js +39 -16
  63. package/dist/i18n/required-surface-keys.d.ts +22 -2
  64. package/dist/i18n/required-surface-keys.js +91 -3
  65. package/dist/jobs/job-runner-helpers.d.ts +45 -0
  66. package/dist/jobs/job-runner-helpers.js +281 -0
  67. package/dist/jobs/job-runner-schedulers.d.ts +23 -0
  68. package/dist/jobs/job-runner-schedulers.js +106 -0
  69. package/dist/jobs/job-runner-types.d.ts +65 -0
  70. package/dist/jobs/job-runner-types.js +2 -0
  71. package/dist/jobs/job-runner.d.ts +4 -75
  72. package/dist/jobs/job-runner.js +7 -384
  73. package/dist/pipeline/dispatch-shared.js +2 -2
  74. package/dist/pipeline/event-dispatcher-admin.d.ts +15 -8
  75. package/dist/pipeline/event-dispatcher-admin.js +28 -7
  76. package/dist/pipeline/event-dispatcher.d.ts +2 -2
  77. package/dist/pipeline/event-dispatcher.js +3 -4
  78. package/dist/pipeline/index.d.ts +2 -2
  79. package/dist/pipeline/index.js +1 -1
  80. package/dist/schema-cli.js +5 -0
  81. package/package.json +3 -3
  82. package/src/changes.json +39 -0
@@ -0,0 +1,467 @@
1
+ import { DASHBOARD_MIN_REFRESH_SECONDS } from "@cosmicdrift/kumiko-types/screen";
2
+ import { normalizeListColumn } from "../screen-helpers.js";
3
+ import { isRowClickAction, resolveRowActionNavigateTarget, resolveScreenTargetQn, validateActionHasIcon, validateClientActionName, validateRowActionNavigateParams, } from "./screens-actions.js";
4
+ import { assertRefTargetRegistered, validateColumnRendererForm } from "./screens-lists.js";
5
+ // Panel-driven, no entity — structural checks (unique panel ids, non-empty
6
+ // queries/columns/valueField); the query contracts (stat record, points
7
+ // envelope, paged rows) are resolved at render time.
8
+ export function validateDashboardScreen(featureName, screenId, screen, featureMap) {
9
+ if (screen.panels.length === 0) {
10
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) has an empty panels list — ` +
11
+ `declare at least one panel.`);
12
+ }
13
+ const panelIds = new Set();
14
+ const addPanelId = (id, context) => {
15
+ if (panelIds.has(id)) {
16
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) has duplicate panel id "${id}" (${context}). Give each panel a unique id.`);
17
+ }
18
+ panelIds.add(id);
19
+ };
20
+ for (const panel of screen.panels) {
21
+ addPanelId(panel.id, panel.kind);
22
+ if (panel.kind === "stat-group") {
23
+ validateDashboardStatGroupPanel(featureName, screenId, panel, addPanelId);
24
+ }
25
+ else if (panel.kind === "custom") {
26
+ validateDashboardCustomPanel(featureName, screenId, panel);
27
+ }
28
+ else if (panel.kind === "screen") {
29
+ validateDashboardScreenPanel(featureName, screenId, panel, featureMap);
30
+ }
31
+ else if (panel.kind === "text") {
32
+ validateDashboardTextPanel(featureName, screenId, panel, featureMap);
33
+ }
34
+ else {
35
+ validateDashboardQueryPanel(featureName, screenId, panel, featureMap);
36
+ }
37
+ }
38
+ if (screen.filter !== undefined) {
39
+ validateDashboardFilterDefinition(featureName, screenId, screen.filter);
40
+ }
41
+ if (screen.timeRange !== undefined) {
42
+ validateDashboardTimeRange(featureName, screenId, screen.timeRange, screen.filter?.id);
43
+ }
44
+ }
45
+ export function validateDashboardActions(featureName, screenId, screen, allWriteHandlerQns, lookups) {
46
+ const { allScreenQns, navTargetShortIds, screensByShortId, detailForScreens } = lookups;
47
+ // Header actions run on the bound record like projectionDetail's, minus
48
+ // what needs a drawer host or a record screen to leave.
49
+ for (const action of screen.actions ?? []) {
50
+ const where = `[Feature ${featureName}] Screen "${screenId}" (dashboard) action "${action.id}"`;
51
+ validateActionHasIcon(featureName, screenId, "dashboard", "action", action);
52
+ if (isRowClickAction(action)) {
53
+ throw new Error(`${where} sets rowClick: true — a dashboard has no row to click. Remove rowClick.`);
54
+ }
55
+ if (action.kind === "drawer") {
56
+ throw new Error(`${where} is a drawer action — a dashboard has no drawer host. Use a navigate, ` +
57
+ `writeHandler or clientAction action.`);
58
+ }
59
+ if (action.kind === "navigate") {
60
+ const target = resolveRowActionNavigateTarget(featureName, screenId, "dashboard", "action", action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
61
+ validateRowActionNavigateParams(featureName, screenId, "dashboard", screen.detailFor, action, target);
62
+ }
63
+ else if (action.kind === "clientAction") {
64
+ validateClientActionName(featureName, screenId, "dashboard", "action", action);
65
+ }
66
+ else {
67
+ if (action.redirect !== undefined) {
68
+ throw new Error(`${where} sets redirect — a dashboard action stays on the dashboard and refetches ` +
69
+ `after success. Remove redirect.`);
70
+ }
71
+ if (!allWriteHandlerQns.has(action.handler)) {
72
+ throw new Error(`${where} handler "${action.handler}" is not a registered write-handler. Check the QN ` +
73
+ `spelling (expected "<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`);
74
+ }
75
+ }
76
+ }
77
+ }
78
+ // record / actions / refresh hang together: actions and a field-driven refresh
79
+ // read the record row, and the record id travels in the URL next to the
80
+ // filter and time-range values.
81
+ export function validateDashboardRecordBinding(featureName, screenId, screen) {
82
+ const where = `[Feature ${featureName}] Screen "${screenId}" (dashboard)`;
83
+ const { record, refresh } = screen;
84
+ if (record === undefined) {
85
+ if (screen.actions !== undefined) {
86
+ throw new Error(`${where} declares actions without a record — the actions run on the bound record. ` +
87
+ `Add record: { param } or remove actions.`);
88
+ }
89
+ }
90
+ else {
91
+ validateDashboardRecordShape(where, record, [screen.filter?.id, screen.timeRange?.id]);
92
+ }
93
+ // skip: no refresh declared, nothing to validate
94
+ if (refresh === undefined)
95
+ return;
96
+ validateDashboardRefresh(where, refresh, record);
97
+ }
98
+ function validateDashboardRecordShape(where, record, otherUrlParamIds) {
99
+ if (typeof record.param !== "string" || record.param.length === 0) {
100
+ throw new Error(`${where} record.param is empty. Name the URL search param carrying the id.`);
101
+ }
102
+ if (record.payloadKey !== undefined && record.payloadKey.length === 0) {
103
+ throw new Error(`${where} record.payloadKey is empty. Omit it to reuse record.param as the payload key.`);
104
+ }
105
+ if (record.query !== undefined && record.query.length === 0) {
106
+ throw new Error(`${where} record.query is empty. Set a registered query name or omit it.`);
107
+ }
108
+ if (record.titleField !== undefined && isTitleFieldUnusable(record)) {
109
+ throw new Error(`${where} record.titleField needs record.query and a field name — the title is read ` +
110
+ `from the record row.`);
111
+ }
112
+ const payloadKey = record.payloadKey ?? record.param;
113
+ for (const other of otherUrlParamIds) {
114
+ if (other !== undefined && (other === record.param || other === payloadKey)) {
115
+ throw new Error(`${where} record.param/payloadKey "${other}" collides with the filter or timeRange id — ` +
116
+ `all of them live in the URL search params and panel payloads. Use a different name.`);
117
+ }
118
+ }
119
+ }
120
+ function isTitleFieldUnusable(record) {
121
+ return record.query === undefined || record.titleField === "";
122
+ }
123
+ function validateDashboardRefresh(where, refresh, record) {
124
+ if ("seconds" in refresh && "field" in refresh) {
125
+ throw new Error(`${where} refresh sets both seconds and field — set exactly one.`);
126
+ }
127
+ if ("seconds" in refresh) {
128
+ if (!isRefreshSecondsAllowed(refresh.seconds)) {
129
+ throw new Error(`${where} refresh.seconds "${String(refresh.seconds)}" must be a number of at least ` +
130
+ `${DASHBOARD_MIN_REFRESH_SECONDS} — shorter intervals would hammer every panel query.`);
131
+ }
132
+ // skip: seconds validated, field rules below do not apply
133
+ return;
134
+ }
135
+ if (refresh.field.length === 0) {
136
+ throw new Error(`${where} refresh.field is empty. Name the record field holding the seconds.`);
137
+ }
138
+ if (record?.query === undefined) {
139
+ throw new Error(`${where} refresh.field "${refresh.field}" needs record.query to read the seconds from. ` +
140
+ `Add record: { param, query } or use refresh: { seconds }.`);
141
+ }
142
+ }
143
+ function isRefreshSecondsAllowed(seconds) {
144
+ return Number.isFinite(seconds) && seconds >= DASHBOARD_MIN_REFRESH_SECONDS;
145
+ }
146
+ function validateDashboardTimeRange(featureName, screenId, timeRange, filterId) {
147
+ const prefix = `[Feature ${featureName}] Screen "${screenId}" (dashboard) timeRange`;
148
+ if (timeRange.id.length === 0)
149
+ throw new Error(`${prefix} has an empty id. Set a non-empty id.`);
150
+ if (timeRange.id === filterId) {
151
+ 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.`);
152
+ }
153
+ if (timeRange.options.length === 0) {
154
+ throw new Error(`${prefix}.options is empty — declare at least one option.`);
155
+ }
156
+ const values = new Set();
157
+ for (const option of timeRange.options) {
158
+ if (option.value.length === 0 || option.label.length === 0) {
159
+ throw new Error(`${prefix} option needs a non-empty value and label. Give every option both.`);
160
+ }
161
+ if (values.has(option.value)) {
162
+ throw new Error(`${prefix}.options has duplicate value "${option.value}". Use each option value once.`);
163
+ }
164
+ values.add(option.value);
165
+ }
166
+ if (!values.has(timeRange.default)) {
167
+ throw new Error(`${prefix}.default "${timeRange.default}" is not among the options (${[...values].join(", ")}). Set default to one of the options.`);
168
+ }
169
+ }
170
+ const EMBEDDABLE_SCREEN_TYPES = new Set([
171
+ "projectionList",
172
+ "actionForm",
173
+ "secretMint",
174
+ "configEdit",
175
+ "secretsEdit",
176
+ ]);
177
+ function validateEmbeddableScreenRef(context, prop, ref, featureName, featureMap) {
178
+ const targetQn = resolveScreenTargetQn(featureName, ref);
179
+ const [targetFeatureName = "", , targetShortId = ""] = targetQn.split(":");
180
+ const target = featureMap.get(targetFeatureName)?.screens[targetShortId];
181
+ if (target === undefined) {
182
+ throw new Error(`${context} ${prop} "${ref}" does not resolve to a registered screen (checked "${targetQn}"). ` +
183
+ `Use a same-feature short id or a cross-feature QN "<feature>:screen:<id>".`);
184
+ }
185
+ if (!EMBEDDABLE_SCREEN_TYPES.has(target.type)) {
186
+ throw new Error(`${context} embeds "${targetQn}" of type "${target.type}", which can't be embedded — allowed: ` +
187
+ `${[...EMBEDDABLE_SCREEN_TYPES].join(", ")}. Use a custom panel for app components.`);
188
+ }
189
+ // ActionFormBody/SecretMintBody navigate via nav.navigate after submit or
190
+ // cancel even when embedded, which would pull the user off the dashboard.
191
+ if ((target.type === "actionForm" || target.type === "secretMint") &&
192
+ (target.redirect !== undefined ||
193
+ (target.cancelTarget !== undefined && target.cancelTarget !== false))) {
194
+ throw new Error(`${context} embeds "${targetQn}", which sets redirect/cancelTarget — an embedded form would ` +
195
+ `navigate away from the dashboard on submit or cancel. Embed a screen without redirect and ` +
196
+ `with cancelTarget: false (or omitted).`);
197
+ }
198
+ return target;
199
+ }
200
+ export function validateDashboardScreenPanel(featureName, screenId, panel, featureMap) {
201
+ const context = `[Feature ${featureName}] Screen "${screenId}" (dashboard) screen-panel "${panel.id}"`;
202
+ validateEmbeddableScreenRef(context, "screen", panel.screen, featureName, featureMap);
203
+ validateDashboardPanelVisibleWhen(context, panel.visibleWhen);
204
+ // skip: no fallback declared, nothing more to validate
205
+ if (panel.fallback === undefined)
206
+ return;
207
+ if (panel.visibleWhen === undefined) {
208
+ throw new Error(`${context} declares fallback without visibleWhen. Add visibleWhen or remove fallback.`);
209
+ }
210
+ const target = validateEmbeddableScreenRef(context, "fallback", panel.fallback, featureName, featureMap);
211
+ // A gated fallback could hide in turn; the tile would show nothing where it promised a stand-in.
212
+ if (target.visibleWhen !== undefined) {
213
+ throw new Error(`${context} fallback "${panel.fallback}" has its own visibleWhen; a fallback must always render.`);
214
+ }
215
+ }
216
+ function validateDashboardTextPanel(featureName, screenId, panel, featureMap) {
217
+ const context = `[Feature ${featureName}] Screen "${screenId}" (dashboard) text-panel "${panel.id}"`;
218
+ if (panel.blocks.length === 0) {
219
+ throw new Error(`${context} has an empty blocks list. Add at least one block.`);
220
+ }
221
+ validateDashboardPanelSpan(featureName, screenId, panel);
222
+ validateDashboardPanelVisibleWhen(context, panel.visibleWhen);
223
+ for (const block of panel.blocks) {
224
+ if (block.kind === "list" && block.items.length === 0) {
225
+ throw new Error(`${context} has a list block without items. Add items or remove the block.`);
226
+ }
227
+ if (block.kind === "link")
228
+ validateDashboardTextLinkBlock(context, block, featureName, featureMap);
229
+ }
230
+ }
231
+ function validateDashboardTextLinkBlock(context, block, featureName, featureMap) {
232
+ const targetQn = resolveScreenTargetQn(featureName, block.screen);
233
+ const [targetFeatureName = "", , targetShortId = ""] = targetQn.split(":");
234
+ if (featureMap.get(targetFeatureName)?.screens[targetShortId] === undefined) {
235
+ throw new Error(`${context} link block screen "${block.screen}" does not resolve to a registered screen ` +
236
+ `(checked "${targetQn}"). Use a same-feature short id or a cross-feature QN "<feature>:screen:<id>".`);
237
+ }
238
+ if (block.roles?.length === 0) {
239
+ throw new Error(`${context} link block "${block.screen}" has an empty roles list, which would hide it from everyone. ` +
240
+ `List the roles or omit roles.`);
241
+ }
242
+ }
243
+ function validateDashboardPanelVisibleWhen(context, visibleWhen) {
244
+ if (visibleWhen !== undefined && (visibleWhen.query === "" || visibleWhen.field === "")) {
245
+ throw new Error(`${context} visibleWhen needs a non-empty query and field. Set both visibleWhen.query and visibleWhen.field.`);
246
+ }
247
+ }
248
+ function validateDashboardPanelSpan(featureName, screenId, panel) {
249
+ if (panel.span !== undefined && panel.span !== "half" && panel.span !== "full") {
250
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" has span "${String(panel.span)}" — expected "half" or "full".`);
251
+ }
252
+ }
253
+ function validateDashboardStatGroupPanel(featureName, screenId, panel, addPanelId) {
254
+ const context = `[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-group "${panel.id}"`;
255
+ if (panel.stats.length === 0) {
256
+ throw new Error(`${context} has an empty stats list. Add at least one stat.`);
257
+ }
258
+ if (panel.subtitle !== undefined && panel.label === undefined) {
259
+ 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.`);
260
+ }
261
+ validateDashboardPanelSpan(featureName, screenId, panel);
262
+ validateDashboardPanelVisibleWhen(context, panel.visibleWhen);
263
+ for (const stat of panel.stats) {
264
+ addPanelId(stat.id, "stat-group child");
265
+ if (stat.visibleWhen !== undefined) {
266
+ throw new Error(`${context} child "${stat.id}" sets visibleWhen — gate the whole group instead; a hidden cell would leave a gap in the strip.`);
267
+ }
268
+ if (!stat.query || typeof stat.query !== "string") {
269
+ 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.`);
270
+ }
271
+ if (stat.valueField.length === 0) {
272
+ 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.`);
273
+ }
274
+ validateDashboardValueFormat(featureName, screenId, stat.id, stat.valueFormat, STAT_VALUE_FORMATS);
275
+ }
276
+ }
277
+ function validateFromTodayWindowReach(where, panel) {
278
+ if (panel.initialWindow === "from-today" && panel.ranges === undefined && panel.brush !== true) {
279
+ throw new Error(`${where}.initialWindow "from-today" needs ranges or brush, otherwise data before today is unreachable.`);
280
+ }
281
+ }
282
+ function validateChartScrollableAndUnit(where, panel) {
283
+ if (panel.scrollable !== undefined && typeof panel.scrollable !== "boolean") {
284
+ throw new Error(`${where} has a non-boolean scrollable. Use true or false.`);
285
+ }
286
+ if (panel.scrollable === true && panel.chart !== "stacked-area") {
287
+ throw new Error(`${where} sets scrollable on chart "${panel.chart}" — only "stacked-area" supports it. Remove scrollable or use chart "stacked-area".`);
288
+ }
289
+ if (panel.unit !== undefined && panel.chart !== "timeseries") {
290
+ throw new Error(`${where} sets unit on chart "${panel.chart}" — only "timeseries" supports it. Remove unit or use chart "timeseries".`);
291
+ }
292
+ }
293
+ function validateDashboardChartDisplay(featureName, screenId, panel) {
294
+ validateDashboardValueFormat(featureName, screenId, panel.id, panel.valueFormat, CHART_VALUE_FORMATS);
295
+ const where = `[Feature ${featureName}] Screen "${screenId}" (dashboard) chart-panel "${panel.id}"`;
296
+ validateChartScrollableAndUnit(where, panel);
297
+ validateStackedAreaOnlyProps(where, panel);
298
+ validateStackedAreaEnumProp(where, "dateFormat", panel.dateFormat, ["day", "month"]);
299
+ validateStackedAreaEnumProp(where, "initialWindow", panel.initialWindow, [
300
+ "default-range",
301
+ "from-today",
302
+ ]);
303
+ validateStackedAreaEnumProp(where, "markerLegend", panel.markerLegend, ["list", "legend"]);
304
+ const nonBooleanProp = ["brush", "legendTotals", "markerLabels"].find((prop) => panel[prop] !== undefined && typeof panel[prop] !== "boolean");
305
+ if (nonBooleanProp !== undefined) {
306
+ throw new Error(`${where} has a non-boolean ${nonBooleanProp}. Use true or false.`);
307
+ }
308
+ validateFromTodayWindowReach(where, panel);
309
+ for (const [key, color] of Object.entries(panel.seriesColors ?? {})) {
310
+ if (typeof color !== "string" || color.trim() === "") {
311
+ throw new Error(`${where} seriesColors["${key}"] must be a non-empty CSS color.`);
312
+ }
313
+ }
314
+ for (const [kind, look] of Object.entries(panel.markerKinds ?? {})) {
315
+ validateDashboardChartMarkerKind(`${where} markerKinds["${kind}"]`, look);
316
+ }
317
+ if (panel.ranges !== undefined)
318
+ validateDashboardChartRanges(`${where} ranges`, panel.ranges);
319
+ }
320
+ function validateStackedAreaEnumProp(where, prop, value, allowed) {
321
+ // skip: optional prop, nothing to validate when unset
322
+ if (value === undefined)
323
+ return;
324
+ if (!allowed.includes(value)) {
325
+ throw new Error(`${where}.${prop} "${String(value)}" must be one of ${allowed.join(", ")}.`);
326
+ }
327
+ }
328
+ function validateStackedAreaOnlyProps(where, panel) {
329
+ // skip: stacked-area is the one chart kind that supports these props
330
+ if (panel.chart === "stacked-area")
331
+ return;
332
+ const stackedAreaOnly = [
333
+ "markerKinds",
334
+ "legendTotals",
335
+ "ranges",
336
+ "brush",
337
+ "dateFormat",
338
+ "initialWindow",
339
+ "markerLegend",
340
+ "markerLabels",
341
+ ].filter((prop) => panel[prop] !== undefined);
342
+ if (stackedAreaOnly.length > 0) {
343
+ throw new Error(`${where} sets ${stackedAreaOnly.join(", ")} on chart "${panel.chart}" — only "stacked-area" supports it. Remove these props or use chart "stacked-area".`);
344
+ }
345
+ }
346
+ const DASHBOARD_CHART_TONES = new Set([
347
+ "positive",
348
+ "negative",
349
+ "active",
350
+ "neutral",
351
+ ]);
352
+ function validateDashboardChartMarkerKind(where, look) {
353
+ if (look.tone === undefined && look.color === undefined) {
354
+ throw new Error(`${where} needs a tone or a color. Set tone or color.`);
355
+ }
356
+ if (look.tone !== undefined && !DASHBOARD_CHART_TONES.has(look.tone)) {
357
+ throw new Error(`${where}.tone "${String(look.tone)}" is not one of ${[...DASHBOARD_CHART_TONES].join(", ")}.`);
358
+ }
359
+ if (look.color !== undefined && (typeof look.color !== "string" || look.color.trim() === "")) {
360
+ throw new Error(`${where}.color must be a non-empty CSS color.`);
361
+ }
362
+ if (look.label !== undefined && (typeof look.label !== "string" || look.label.trim() === "")) {
363
+ throw new Error(`${where}.label must be a non-empty i18n key.`);
364
+ }
365
+ }
366
+ function validateDashboardChartRanges(where, ranges) {
367
+ if (ranges.options.length === 0) {
368
+ throw new Error(`${where}.options is empty — declare at least one option.`);
369
+ }
370
+ const values = new Set();
371
+ for (const option of ranges.options) {
372
+ if (option.value.length === 0 || option.label.length === 0) {
373
+ throw new Error(`${where} option needs a non-empty value and label. Give every option both.`);
374
+ }
375
+ if (values.has(option.value)) {
376
+ throw new Error(`${where}.options has duplicate value "${option.value}". Use each option value once.`);
377
+ }
378
+ if (option.months !== undefined && (!Number.isInteger(option.months) || option.months <= 0)) {
379
+ throw new Error(`${where} option "${option.value}" months "${String(option.months)}" must be a positive integer.`);
380
+ }
381
+ values.add(option.value);
382
+ }
383
+ if (!values.has(ranges.default)) {
384
+ throw new Error(`${where}.default "${ranges.default}" is not among the options (${[...values].join(", ")}). Set default to one of the options.`);
385
+ }
386
+ }
387
+ const CURRENCY_CODE_RE = /^[A-Z]{3}$/;
388
+ const MAX_VALUE_FORMAT_FRACTION_DIGITS = 4;
389
+ const STAT_VALUE_FORMATS = [
390
+ "currency",
391
+ "relativeTime",
392
+ ];
393
+ const CHART_VALUE_FORMATS = ["currency"];
394
+ function validateDashboardValueFormat(featureName, screenId, panelId, valueFormat, allowedKinds) {
395
+ // skip: valueFormat is optional, nothing to validate without it
396
+ if (valueFormat === undefined)
397
+ return;
398
+ const where = `[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panelId}" valueFormat`;
399
+ if (!allowedKinds.includes(valueFormat.kind)) {
400
+ throw new Error(`${where}.kind "${String(valueFormat.kind)}" must be ${allowedKinds.map((kind) => `"${kind}"`).join(" or ")}.`);
401
+ }
402
+ // skip: relativeTime carries no options
403
+ if (valueFormat.kind === "relativeTime")
404
+ return;
405
+ if (!CURRENCY_CODE_RE.test(valueFormat.currency)) {
406
+ throw new Error(`${where}.currency "${String(valueFormat.currency)}" must be a 3-letter uppercase ISO currency code.`);
407
+ }
408
+ const digits = valueFormat.fractionDigits;
409
+ if (digits !== undefined &&
410
+ (!Number.isInteger(digits) || digits < 0 || digits > MAX_VALUE_FORMAT_FRACTION_DIGITS)) {
411
+ throw new Error(`${where}.fractionDigits "${String(digits)}" must be an integer from 0 to ${MAX_VALUE_FORMAT_FRACTION_DIGITS}.`);
412
+ }
413
+ }
414
+ export function validateDashboardCustomPanel(featureName, screenId, panel) {
415
+ if (panel.component.react === undefined && panel.component.native === undefined) {
416
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) custom-panel "${panel.id}" has no component — ` +
417
+ `declare a react/native component marker.`);
418
+ }
419
+ }
420
+ function validateDashboardQueryPanel(featureName, screenId, panel, featureMap) {
421
+ if (!panel.query || typeof panel.query !== "string") {
422
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" has empty or non-string query. Set query to a registered query name.`);
423
+ }
424
+ validateDashboardPanelSpan(featureName, screenId, panel);
425
+ if (panel.kind === "stat") {
426
+ if (panel.valueField.length === 0) {
427
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-panel "${panel.id}" has empty valueField. Set valueField to a field of the query result.`);
428
+ }
429
+ validateDashboardValueFormat(featureName, screenId, panel.id, panel.valueFormat, STAT_VALUE_FORMATS);
430
+ }
431
+ validateDashboardPanelVisibleWhen(`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}"`, panel.visibleWhen);
432
+ if (panel.kind === "chart")
433
+ validateDashboardChartDisplay(featureName, screenId, panel);
434
+ if (panel.kind === "list") {
435
+ if (panel.columns.length === 0) {
436
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) list-panel "${panel.id}" has an empty columns list. Add at least one column.`);
437
+ }
438
+ for (const col of panel.columns) {
439
+ const normalized = normalizeListColumn(col);
440
+ validateColumnRendererForm(featureName, screenId, normalized);
441
+ if (normalized.refEntity !== undefined) {
442
+ assertRefTargetRegistered(`[Feature ${featureName}] Screen "${screenId}" (dashboard) list-panel "${panel.id}"`, `column "${normalized.field}" (refEntity)`, normalized.refEntity, featureName, featureMap);
443
+ }
444
+ }
445
+ }
446
+ }
447
+ function validateDashboardFilterDefinition(featureName, screenId, filter) {
448
+ if (filter.id.length === 0) {
449
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) filter has an empty id. Set a non-empty id.`);
450
+ }
451
+ if (filter.label.length === 0) {
452
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) filter has an empty label. Set a non-empty label.`);
453
+ }
454
+ const hasOptions = filter.options !== undefined;
455
+ const hasOptionsQuery = filter.optionsQuery !== undefined;
456
+ if (hasOptions === hasOptionsQuery) {
457
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) filter must set exactly one of ` +
458
+ `options/optionsQuery.`);
459
+ }
460
+ if (hasOptions && (filter.options?.length ?? 0) === 0) {
461
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) filter.options is empty — ` +
462
+ `declare at least one option or use optionsQuery instead.`);
463
+ }
464
+ if (hasOptionsQuery && filter.optionsQuery?.length === 0) {
465
+ throw new Error(`[Feature ${featureName}] Screen "${screenId}" (dashboard) filter.optionsQuery is empty. Set optionsQuery to a query name or remove it.`);
466
+ }
467
+ }
@@ -0,0 +1,24 @@
1
+ import type { FieldsOrGroupsSection } from "../screen-helpers.ts";
2
+ import type { EntityDefinition, FeatureDefinition, FieldDefinition } from "../types/index.ts";
3
+ import type { ActionFormScreenDefinition, ConfigEditScreenDefinition, EditFieldSpec, EditLayout, SecretMintScreenDefinition } from "../types/screen.ts";
4
+ import type { NavigateTargetLookups } from "./screens-actions.ts";
5
+ export declare function validateEditFieldExtras(featureName: string, screenId: string, entityDef: EntityDefinition, fieldSpec: Exclude<EditFieldSpec, string>): void;
6
+ export declare function validateFieldFormRefs(where: string, fieldSpec: Exclude<EditFieldSpec, string>, formFieldNames: ReadonlySet<string>): void;
7
+ export declare function validateFillActionTarget(where: string, fieldSpec: Exclude<EditFieldSpec, string>, fieldDef: {
8
+ readonly type: string;
9
+ } | undefined): void;
10
+ export declare function validateNoWidgetRequiredField(featureName: string, screenId: string, entityDef: EntityDefinition, fieldSpec: Exclude<EditFieldSpec, string>): void;
11
+ export declare function validateTabSections(featureName: string, screenId: string, screenType: "projectionDetail" | "entityEdit", sections: readonly {
12
+ readonly id?: string;
13
+ readonly title?: string;
14
+ }[]): void;
15
+ export declare function validateWizardLayout(featureName: string, screenId: string, screenType: "entityEdit" | "actionForm" | "configEdit" | "secretMint", layout: EditLayout, featureMap: ReadonlyMap<string, FeatureDefinition>): void;
16
+ export declare function isWriteOnlyField(fdef: FieldDefinition | undefined): boolean;
17
+ export declare function validateConfigEditSecretField(featureName: string, screen: ConfigEditScreenDefinition, fieldName: string, secretQn: string, featureMap: ReadonlyMap<string, FeatureDefinition>): void;
18
+ export declare function rejectWriteOnlyFormField(featureName: string, screenId: string, context: string, fieldName: string, fdef: FieldDefinition): void;
19
+ export declare function validateFormSelectOptions(featureName: string, screenId: string, context: string, fieldName: string, fdef: FieldDefinition, siblingFieldNames: ReadonlySet<string>): void;
20
+ export declare function validateFieldsXorGroups(errorPrefix: string, section: FieldsOrGroupsSection & {
21
+ readonly title?: string;
22
+ }): void;
23
+ export declare function validateInlineFormScreen(feature: FeatureDefinition, screenId: string, screen: ActionFormScreenDefinition | SecretMintScreenDefinition, kind: "actionForm" | "secretMint", allWriteHandlerQns: ReadonlySet<string>, lookups: NavigateTargetLookups, featureMap: ReadonlyMap<string, FeatureDefinition>): void;
24
+ export declare function validateSecretMintConfirm(feature: FeatureDefinition, screenId: string, screen: SecretMintScreenDefinition, allWriteHandlerQns: ReadonlySet<string>): void;