@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.
- package/agents/examples/custom-fields-basic.md +13 -3
- package/agents/examples/designer-demo.md +6 -2
- package/agents/examples/i18n.md +36 -1
- package/agents/features.md +4 -0
- package/dist/api/auth-rate-limiters.d.ts +8 -0
- package/dist/api/auth-rate-limiters.js +105 -0
- package/dist/api/auth-routes-context.d.ts +34 -0
- package/dist/api/auth-routes-context.js +192 -0
- package/dist/api/auth-routes-invite.d.ts +2 -0
- package/dist/api/auth-routes-invite.js +171 -0
- package/dist/api/auth-routes-login.d.ts +2 -0
- package/dist/api/auth-routes-login.js +103 -0
- package/dist/api/auth-routes-mfa.d.ts +4 -0
- package/dist/api/auth-routes-mfa.js +211 -0
- package/dist/api/auth-routes-session.d.ts +2 -0
- package/dist/api/auth-routes-session.js +144 -0
- package/dist/api/auth-routes-shared.d.ts +33 -0
- package/dist/api/auth-routes-shared.js +81 -0
- package/dist/api/auth-routes-signup.d.ts +2 -0
- package/dist/api/auth-routes-signup.js +75 -0
- package/dist/api/auth-routes-token-flows.d.ts +4 -0
- package/dist/api/auth-routes-token-flows.js +91 -0
- package/dist/api/auth-routes-types.d.ts +76 -0
- package/dist/api/auth-routes-types.js +1 -0
- package/dist/api/auth-routes.d.ts +5 -85
- package/dist/api/auth-routes.js +20 -1095
- package/dist/config/index.d.ts +1 -1
- package/dist/crypto/pii-ciphertext-format.js +1 -1
- package/dist/db/blind-index-cleanup.js +1 -1
- package/dist/db/dialect.js +1 -1
- package/dist/db/distinct-on.d.ts +9 -0
- package/dist/db/distinct-on.js +28 -0
- package/dist/db/event-store-executor-write.js +2 -2
- package/dist/db/migrate-runner.js +4 -4
- package/dist/db/pg-error.js +0 -12
- package/dist/db/queries/event-consumer.js +3 -3
- package/dist/db/queries/postgres-array-literal.js +2 -2
- package/dist/db/query.js +48 -20
- package/dist/db/tenant-db.js +1 -1
- package/dist/engine/boot-validator/i18n-keys.d.ts +2 -0
- package/dist/engine/boot-validator/i18n-keys.js +15 -1
- package/dist/engine/boot-validator/index.d.ts +4 -0
- package/dist/engine/boot-validator/index.js +3 -1
- package/dist/engine/boot-validator/pii-retention.js +6 -7
- package/dist/engine/boot-validator/screens-actions.d.ts +67 -0
- package/dist/engine/boot-validator/screens-actions.js +488 -0
- package/dist/engine/boot-validator/screens-dashboard.d.ts +15 -0
- package/dist/engine/boot-validator/screens-dashboard.js +467 -0
- package/dist/engine/boot-validator/screens-forms.d.ts +24 -0
- package/dist/engine/boot-validator/screens-forms.js +516 -0
- package/dist/engine/boot-validator/screens-lists.d.ts +47 -0
- package/dist/engine/boot-validator/screens-lists.js +319 -0
- package/dist/engine/boot-validator/screens.d.ts +4 -21
- package/dist/engine/boot-validator/screens.js +26 -1799
- package/dist/engine/build-app-schema.js +1 -1
- package/dist/engine/factories.d.ts +3 -3
- package/dist/engine/steps/read-find-one.js +1 -1
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/event-store/provenance-append.js +1 -1
- package/dist/files/file-ref-entity.d.ts +6 -126
- package/dist/files/file-ref-table.d.ts +6 -126
- package/dist/files/file-routes.js +39 -16
- package/dist/i18n/required-surface-keys.d.ts +22 -2
- package/dist/i18n/required-surface-keys.js +91 -3
- package/dist/jobs/job-runner-helpers.d.ts +45 -0
- package/dist/jobs/job-runner-helpers.js +281 -0
- package/dist/jobs/job-runner-schedulers.d.ts +23 -0
- package/dist/jobs/job-runner-schedulers.js +106 -0
- package/dist/jobs/job-runner-types.d.ts +65 -0
- package/dist/jobs/job-runner-types.js +2 -0
- package/dist/jobs/job-runner.d.ts +4 -75
- package/dist/jobs/job-runner.js +7 -384
- package/dist/pipeline/dispatch-shared.js +2 -2
- package/dist/pipeline/event-dispatcher-admin.d.ts +15 -8
- package/dist/pipeline/event-dispatcher-admin.js +28 -7
- package/dist/pipeline/event-dispatcher.d.ts +2 -2
- package/dist/pipeline/event-dispatcher.js +3 -4
- package/dist/pipeline/index.d.ts +2 -2
- package/dist/pipeline/index.js +1 -1
- package/dist/schema-cli.js +5 -0
- package/package.json +3 -3
- package/src/changes.json +39 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
|
|
2
|
+
import { NAV_ICON_KEYS } from "@cosmicdrift/kumiko-types/nav-icon";
|
|
3
|
+
import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
4
|
+
import { isKebabSegment, isValidQn, qualifyEntityName } from "../qualified-name.js";
|
|
5
|
+
import { isFieldsEditSection, normalizeEditField, sectionFieldSpecs } from "../screen-helpers.js";
|
|
6
|
+
export function isRowClickAction(action) {
|
|
7
|
+
return (action.kind === "navigate" || action.kind === "drawer") && action.rowClick === true;
|
|
8
|
+
}
|
|
9
|
+
// entityList and projectionList both allow a rowAction to double as the
|
|
10
|
+
// row-body click target (rowClick: true, fw#1708/#2164) — at most one per
|
|
11
|
+
// screen, or the renderer can't tell which one should fire.
|
|
12
|
+
export function validateAtMostOneRowClick(featureName, screenId, screenType, rowActions) {
|
|
13
|
+
const rowClickActions = rowActions.filter(isRowClickAction);
|
|
14
|
+
if (rowClickActions.length > 1) {
|
|
15
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has ${rowClickActions.length} ` +
|
|
16
|
+
"rowActions marked rowClick:true — at most one may fire on a row-body click. Mark at most one rowAction with rowClick: true.");
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function rowFieldExtractorKeys(params) {
|
|
20
|
+
return "pick" in params ? params.pick : Object.keys(params.map);
|
|
21
|
+
}
|
|
22
|
+
// Mirrors the renderer's navigate: an explicit entityId, or a same-entity
|
|
23
|
+
// entityEdit target (row["id"] auto-fill), opens UPDATE mode, which ignores params.
|
|
24
|
+
export function readsNavigateParamsAsFormPrefill(target, explicitEntityId, sourceScreenEntity) {
|
|
25
|
+
if (target.type === "actionForm" || target.type === "secretMint")
|
|
26
|
+
return true;
|
|
27
|
+
if (target.type !== "entityEdit")
|
|
28
|
+
return false;
|
|
29
|
+
if (explicitEntityId !== undefined)
|
|
30
|
+
return false;
|
|
31
|
+
return sourceScreenEntity === undefined || target.entity !== sourceScreenEntity;
|
|
32
|
+
}
|
|
33
|
+
// Tier 2.7e navigate rowAction → target-screen params validity. Shared by
|
|
34
|
+
// entityList and projectionList (framework#1708) — projectionList has no
|
|
35
|
+
// `screen.entity`, so there's no same-entity row["id"] auto-fill case: any
|
|
36
|
+
// entityEdit target without an explicit entityId reaches create there.
|
|
37
|
+
//
|
|
38
|
+
// dashboard targets (framework#1708 follow-up, commit 4e0d6cb26) also read
|
|
39
|
+
// URL search params — but only for the single `filter.id` a dashboard
|
|
40
|
+
// declares (useFilterParams in dashboard-body.tsx seeds its value from
|
|
41
|
+
// `nav.searchParams[filter.id]`). A dashboard with no `filter` has nowhere
|
|
42
|
+
// for the value to land, and a params extractor whose keys don't include
|
|
43
|
+
// the filter's id would silently miss it — both are boot errors instead of
|
|
44
|
+
// a silently-empty dashboard.
|
|
45
|
+
export function validateRowActionNavigateParams(featureName, screenId, screenType, screenEntity, action, target, actionLabel = "rowAction") {
|
|
46
|
+
// skip: not a navigate-with-params action — nothing to validate here.
|
|
47
|
+
if (action.kind !== "navigate" || action.params === undefined)
|
|
48
|
+
return;
|
|
49
|
+
// skip: unresolvable/custom target already reported (or exempt) elsewhere.
|
|
50
|
+
if (target === undefined || target.screen.type === "custom")
|
|
51
|
+
return;
|
|
52
|
+
// skip: entityList/projectionList targets also read URL search params (Tier
|
|
53
|
+
// 2.7c filter-prefill, see use-list-url-state.ts: `<screenId>.q/.sort/
|
|
54
|
+
// .dir/.page/.f.<field>`), not just actionForm/entityEdit-create.
|
|
55
|
+
if (target.screen.type === "entityList" || target.screen.type === "projectionList")
|
|
56
|
+
return;
|
|
57
|
+
if (target.screen.type === "dashboard") {
|
|
58
|
+
const targetDescriptor = action.screen !== undefined ? `"${action.screen}"` : `entity "${action.entity}"`;
|
|
59
|
+
const { filter, record, timeRange } = target.screen;
|
|
60
|
+
const readIds = [filter?.id, record?.param, timeRange?.id].filter((id) => id !== undefined);
|
|
61
|
+
if (readIds.length === 0) {
|
|
62
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" sets ` +
|
|
63
|
+
`params on navigate-target ${targetDescriptor} (dashboard) — target dashboard declares no ` +
|
|
64
|
+
`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 ` +
|
|
65
|
+
`target dashboard so it has somewhere to read the value from.`);
|
|
66
|
+
}
|
|
67
|
+
const extractedKeys = rowFieldExtractorKeys(action.params);
|
|
68
|
+
if (!readIds.some((id) => extractedKeys.includes(id))) {
|
|
69
|
+
const readDescriptor = filter !== undefined && readIds.length === 1
|
|
70
|
+
? `filter id is "${filter.id}"`
|
|
71
|
+
: `URL params are [${readIds.join(", ")}]`;
|
|
72
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" sets ` +
|
|
73
|
+
`params [${extractedKeys.join(", ")}] on navigate-target ${targetDescriptor} (dashboard) whose ` +
|
|
74
|
+
`${readDescriptor} — none of the extracted keys match, so the value would stay ` +
|
|
75
|
+
`unset. Fix the params extractor to produce one of these keys, or remove it.`);
|
|
76
|
+
}
|
|
77
|
+
// skip: a read id is present and the extractor's keys cover it — valid dashboard deep-link.
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (!readsNavigateParamsAsFormPrefill(target.screen, action.entityId, screenEntity)) {
|
|
81
|
+
const isEntityEditUpdate = target.screen.type === "entityEdit";
|
|
82
|
+
const reason = isEntityEditUpdate
|
|
83
|
+
? `resolves to UPDATE mode (${action.entityId !== undefined
|
|
84
|
+
? `explicit entityId "${action.entityId}"`
|
|
85
|
+
: `same entity "${screenEntity}" auto-fills row["id"]`})`
|
|
86
|
+
: `screen type "${target.screen.type}"`;
|
|
87
|
+
const targetDescriptor = action.screen !== undefined ? `"${action.screen}"` : `entity "${action.entity}"`;
|
|
88
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
|
|
89
|
+
`sets params on navigate-target ${targetDescriptor} which ${reason} — only actionForm, ` +
|
|
90
|
+
`secretMint and entityEdit-create targets read URL search params as initial values. Remove ` +
|
|
91
|
+
`the params extractor or retarget to an actionForm / secretMint / cross-entity ` +
|
|
92
|
+
`entityEdit-create screen.`);
|
|
93
|
+
}
|
|
94
|
+
const formTarget = target.screen;
|
|
95
|
+
if ((formTarget.type === "actionForm" || formTarget.type === "secretMint") &&
|
|
96
|
+
formTarget.submitPrefilled === true) {
|
|
97
|
+
validateDrawerPrefillKeys(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}"`, "navigate-target", action.screen ?? `entity "${action.entity}"`, formTarget, action.params);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// every action rendered in a Card title row (screen-level
|
|
101
|
+
// `actions`, section-level `actions`, `emptyState.action`) must draw an
|
|
102
|
+
// icon — declared or id-derived (resolveActionIcon, same resolver the
|
|
103
|
+
// renderer uses). Uncovered here: entityList/projectionList rowActions —
|
|
104
|
+
// those render inside a DataTable row, not a Card title row, so they keep
|
|
105
|
+
// today's "icon optional" behavior.
|
|
106
|
+
//
|
|
107
|
+
// Registration check (not just "resolved is not undefined"): action.icon is
|
|
108
|
+
// typed IconKey, a closed union — but that only protects call sites this
|
|
109
|
+
// repo's own `tsc --build` actually covers (samples and external consumers
|
|
110
|
+
// aren't in that project graph). The renderer's actionIconFor
|
|
111
|
+
// (renderer/src/web/primitives/index.tsx) already falls back to "no icon"
|
|
112
|
+
// for an unregistered key instead of crashing — this check makes the same
|
|
113
|
+
// gap fail loudly at boot instead of silently at render.
|
|
114
|
+
function isRegisteredIcon(icon) {
|
|
115
|
+
return isOneOf(NAV_ICON_KEYS, icon);
|
|
116
|
+
}
|
|
117
|
+
export function validateActionHasIcon(featureName, screenId, screenType, context, action) {
|
|
118
|
+
const resolved = resolveActionIcon(action.id, action.icon);
|
|
119
|
+
if (resolved === undefined || !isRegisteredIcon(resolved)) {
|
|
120
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${context} "${action.id}" has no ` +
|
|
121
|
+
`resolvable icon — set icon to a key registered in NAV_ICONS, or use an id that resolves one of ` +
|
|
122
|
+
`its kebab segments via the default icon map (resolveActionIcon).`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// Tier 2.7e-3: pin an action's declarative field references against the entity
|
|
126
|
+
// fields — a typo in pick/map source fields or visible.field would otherwise
|
|
127
|
+
// silently yield `undefined` in the payload or permanently wrong visibility
|
|
128
|
+
// (same "typo only surfaces on click" class as navigate/handler). Exported for
|
|
129
|
+
// query-output-columns.ts, which reuses it against a projectionDetail's
|
|
130
|
+
// outputSchema-derived record shape instead of an entity's field map
|
|
131
|
+
// (relatedList toolbarActions' visible/params).
|
|
132
|
+
export function validateActionFieldRefs(featureName, screenId, actionKind, actionId, action, fieldNames, rowMeta) {
|
|
133
|
+
// ToolbarAction.payload is a STATIC record (no row context) — only real
|
|
134
|
+
// pick/map extractors are checked against the field names.
|
|
135
|
+
const isExtractor = (v) => typeof v === "object" && v !== null && ("pick" in v || "map" in v);
|
|
136
|
+
const payload = "payload" in action && isExtractor(action.payload) ? action.payload : undefined;
|
|
137
|
+
const params = "params" in action && isExtractor(action.params) ? action.params : undefined;
|
|
138
|
+
const visible = "visible" in action ? action.visible : undefined;
|
|
139
|
+
const entityId = "entityId" in action ? action.entityId : undefined;
|
|
140
|
+
const known = () => [...fieldNames].sort().join(", ") || "(none)";
|
|
141
|
+
const checkExtractor = (label, extractor) => {
|
|
142
|
+
// skip: the extractor is an optional action slot — without it there are
|
|
143
|
+
// no field references to validate.
|
|
144
|
+
if (extractor === undefined) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const sources = "pick" in extractor ? extractor.pick : Object.values(extractor.map);
|
|
148
|
+
for (const source of sources) {
|
|
149
|
+
if (rowMeta.has(source))
|
|
150
|
+
continue;
|
|
151
|
+
if (!fieldNames.has(source)) {
|
|
152
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" ${actionKind} "${actionId}" ` +
|
|
153
|
+
`${label} references unknown field "${source}". Known fields: ${known()}.`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
checkExtractor("payload", payload);
|
|
158
|
+
checkExtractor("params", params);
|
|
159
|
+
if (visible !== undefined &&
|
|
160
|
+
typeof visible !== "boolean" &&
|
|
161
|
+
!rowMeta.has(visible.field) &&
|
|
162
|
+
!fieldNames.has(visible.field)) {
|
|
163
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" ${actionKind} "${actionId}" ` +
|
|
164
|
+
`visible.field references unknown field "${visible.field}". Known fields: ${known()}.`);
|
|
165
|
+
}
|
|
166
|
+
if (entityId !== undefined && entityId !== "id" && !fieldNames.has(entityId)) {
|
|
167
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" ${actionKind} "${actionId}" ` +
|
|
168
|
+
`entityId references unknown field "${entityId}". Known fields: ${known()}.`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// Two features registering the same short screen-id is a silent routing
|
|
172
|
+
// footgun: create-app.tsx's runtime router resolves a bare navigate-target
|
|
173
|
+
// id by scanning features[] and taking the first match — the collision
|
|
174
|
+
// never surfaces as an error, the second feature's screen is just
|
|
175
|
+
// unreachable by that id (and always the same one that loses, in whatever
|
|
176
|
+
// order the app composed its features).
|
|
177
|
+
export function validateScreenShortIdCollisions(screensByShortId) {
|
|
178
|
+
for (const [shortId, entries] of screensByShortId) {
|
|
179
|
+
const featureNames = new Set(entries.map((e) => e.featureName));
|
|
180
|
+
if (featureNames.size > 1) {
|
|
181
|
+
throw new Error(`Screen short-id "${shortId}" is registered by ${featureNames.size} features ` +
|
|
182
|
+
`(${[...featureNames].join(", ")}) — the runtime router resolves a bare navigate-target ` +
|
|
183
|
+
`id by taking the first match, so all but one of these screens would be unreachable by ` +
|
|
184
|
+
`that id. Give each screen a distinct id.`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// redirect/cancelTarget accept either a same-feature short id (unchanged
|
|
189
|
+
// behavior, qualified against the owning feature) or a fully-qualified
|
|
190
|
+
// cross-feature screen QN (`<feature>:screen:<id>`) given verbatim — a
|
|
191
|
+
// short id can never itself be a valid QN (QN_SEGMENT forbids colons), so
|
|
192
|
+
// the two forms don't collide.
|
|
193
|
+
export function resolveScreenTargetQn(featureName, target) {
|
|
194
|
+
return isValidQn(target) ? target : qualifyEntityName(featureName, "screen", target);
|
|
195
|
+
}
|
|
196
|
+
export function validateScreenNavTarget(featureName, screenId, screenKind, fieldName, value, allScreenQns, screens) {
|
|
197
|
+
const candidateQn = resolveScreenTargetQn(featureName, value);
|
|
198
|
+
if (!allScreenQns.has(candidateQn)) {
|
|
199
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${fieldName} "${value}" ` +
|
|
200
|
+
`does not resolve to a registered screen (checked "${candidateQn}"). Known screens ` +
|
|
201
|
+
`in this feature: ${[...Object.keys(screens)].sort().join(", ") || "(none)"}.`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// kind:"drawer" resolves same-feature only — unlike navigate/redirect,
|
|
205
|
+
// which the runtime router resolves app-wide (see the comment on
|
|
206
|
+
// validateScreens below), the drawer mounts the target inline using this
|
|
207
|
+
// feature's schema, so a cross-feature reference could never actually
|
|
208
|
+
// render. Two distinct failure messages: dangling reference vs. wrong
|
|
209
|
+
// screen type. Shared by ToolbarAction (fw#2225) and RowAction (fw#2710)
|
|
210
|
+
// drawer variants — same target-resolution rule, only the label in the
|
|
211
|
+
// error message ("toolbarAction" vs. "rowAction" vs. "action") differs.
|
|
212
|
+
export function validateDrawerTargetAction(featureName, screenId, screenKind, actionLabel, action, screens) {
|
|
213
|
+
const target = screens[action.screen];
|
|
214
|
+
if (target === undefined) {
|
|
215
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}" ` +
|
|
216
|
+
`drawer-target "${action.screen}" does not resolve to a registered screen in this feature. ` +
|
|
217
|
+
`kind:"drawer" only resolves same-feature screens (unlike kind:"navigate", which can target ` +
|
|
218
|
+
`screens in any feature) — the drawer mounts the target inline using this feature's schema. ` +
|
|
219
|
+
`Known screens in this feature: ${[...Object.keys(screens)].sort().join(", ") || "(none)"}.`);
|
|
220
|
+
}
|
|
221
|
+
// secretMint is intentionally excluded — a one-time secret reveal belongs on
|
|
222
|
+
// its own page, not layered in a Drawer above a list.
|
|
223
|
+
if (target.type !== "actionForm") {
|
|
224
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}" ` +
|
|
225
|
+
`drawer-target "${action.screen}" is a "${target.type}" screen, not an actionForm. ` +
|
|
226
|
+
`kind:"drawer" mounts an actionForm inside a Drawer widget — point "screen" at an ` +
|
|
227
|
+
`actionForm screen, or use kind:"navigate" for a full-page target.`);
|
|
228
|
+
}
|
|
229
|
+
// The renderer (mergeSearchParamsIntoInitial) silently drops a prefill key
|
|
230
|
+
// when the target doesn't declare the field, marks it sensitive/password,
|
|
231
|
+
// or doesn't render it in its layout — each would only show up as an empty
|
|
232
|
+
// field at click time.
|
|
233
|
+
// skip: no params extractor — nothing to check against the target's fields.
|
|
234
|
+
if (action.params === undefined)
|
|
235
|
+
return;
|
|
236
|
+
validateDrawerPrefillKeys(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}"`, "drawer-target", action.screen, target, action.params);
|
|
237
|
+
}
|
|
238
|
+
function renderedEditFieldNames(sections) {
|
|
239
|
+
const names = new Set();
|
|
240
|
+
for (const section of sections) {
|
|
241
|
+
if (!isFieldsEditSection(section))
|
|
242
|
+
continue;
|
|
243
|
+
for (const spec of sectionFieldSpecs(section))
|
|
244
|
+
names.add(normalizeEditField(spec).field);
|
|
245
|
+
}
|
|
246
|
+
return names;
|
|
247
|
+
}
|
|
248
|
+
export function isSensitiveOrPasswordField(field) {
|
|
249
|
+
return (field !== undefined &&
|
|
250
|
+
(("sensitive" in field && field.sensitive === true) ||
|
|
251
|
+
("writeOnly" in field && field.writeOnly === true) ||
|
|
252
|
+
("format" in field && field.format === "password")));
|
|
253
|
+
}
|
|
254
|
+
export function validateDrawerPrefillKeys(where, targetLabel, targetScreenId, target, params) {
|
|
255
|
+
const renderedFieldNames = renderedEditFieldNames(target.layout.sections);
|
|
256
|
+
for (const fieldName of rowFieldExtractorKeys(params)) {
|
|
257
|
+
// A `load` form reads only the record id from these params, never a value.
|
|
258
|
+
if (fieldName === "id" && target.type === "actionForm" && target.load !== undefined)
|
|
259
|
+
continue;
|
|
260
|
+
if (!Object.hasOwn(target.fields, fieldName)) {
|
|
261
|
+
throw new Error(`${where} params prefills "${fieldName}", which ${targetLabel} "${targetScreenId}" does not declare as a ` +
|
|
262
|
+
`field — the renderer would drop it and leave the form empty. Target fields: ` +
|
|
263
|
+
`${Object.keys(target.fields).sort().join(", ") || "(none)"}.`);
|
|
264
|
+
}
|
|
265
|
+
if (isSensitiveOrPasswordField(target.fields[fieldName])) {
|
|
266
|
+
throw new Error(`${where} params prefills "${fieldName}", which ${targetLabel} "${targetScreenId}" marks as ` +
|
|
267
|
+
`sensitive or password — the renderer never prefills those fields and would leave the form empty. Remove that param or prefill a non-sensitive field.`);
|
|
268
|
+
}
|
|
269
|
+
if (!renderedFieldNames.has(fieldName)) {
|
|
270
|
+
throw new Error(`${where} params prefills "${fieldName}", which ${targetLabel} "${targetScreenId}" does not render in ` +
|
|
271
|
+
`its layout — the renderer only prefills fields the layout shows and would drop it.`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// The target can be any screen, so this checks the resolved target's
|
|
276
|
+
// sections, not the host's.
|
|
277
|
+
function validateRowActionNavigateTab(featureName, screenId, screenType, actionLabel, action, target) {
|
|
278
|
+
// skip: no tab declared — nothing to check against the target's sections
|
|
279
|
+
if (action.tab === undefined)
|
|
280
|
+
return;
|
|
281
|
+
if (target === undefined) {
|
|
282
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
|
|
283
|
+
`sets tab "${action.tab}", but its navigate-target could not be resolved to a screen. Point the navigate-target at a registered screen.`);
|
|
284
|
+
}
|
|
285
|
+
if (target.screen.type !== "projectionDetail" || target.screen.layout.mode !== "tabs") {
|
|
286
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
|
|
287
|
+
`sets tab "${action.tab}", but navigate-target screen "${target.screen.id}" is not a ` +
|
|
288
|
+
`projectionDetail with layout.mode "tabs" — only those read the tab param. Remove the tab or navigate to a projectionDetail screen with layout.mode "tabs".`);
|
|
289
|
+
}
|
|
290
|
+
const sectionIds = target.screen.layout.sections
|
|
291
|
+
.map((section) => section.id)
|
|
292
|
+
.filter((id) => id !== undefined);
|
|
293
|
+
if (!sectionIds.includes(action.tab)) {
|
|
294
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
|
|
295
|
+
`navigates to tab "${action.tab}", which is not a section id on target screen ` +
|
|
296
|
+
`"${target.screen.id}". Available: ${sectionIds.join(", ") || "(none)"}.`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
export function resolveRowActionNavigateTarget(featureName, screenId, screenType, actionLabel, action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens) {
|
|
300
|
+
if (action.entity !== undefined) {
|
|
301
|
+
if (action.screen !== undefined) {
|
|
302
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
|
|
303
|
+
`sets both "screen" and "entity" — exactly one navigate-target form is allowed.`);
|
|
304
|
+
}
|
|
305
|
+
if (screenType !== "entityList" && action.entityId === undefined) {
|
|
306
|
+
// entityList rows are always a real entity record, so row["id"] is a
|
|
307
|
+
// safe implicit default. projectionList/projectionDetail rows come from
|
|
308
|
+
// an arbitrary query projection with no guaranteed "id" field — an
|
|
309
|
+
// entity-target there needs an explicit entityId, or navigation silently
|
|
310
|
+
// opens the detail screen with no entity context at runtime.
|
|
311
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
|
|
312
|
+
`navigate-target entity "${action.entity}" needs an explicit "entityId" — ${screenType} rows ` +
|
|
313
|
+
`come from a query projection with no guaranteed "id" field.`);
|
|
314
|
+
}
|
|
315
|
+
const detail = detailForScreens.get(action.entity);
|
|
316
|
+
if (detail === undefined) {
|
|
317
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
|
|
318
|
+
`navigate-target entity "${action.entity}" has no screen declaring ` +
|
|
319
|
+
`detailFor: "${action.entity}". Add detailFor: "${action.entity}" to a screen or navigate to a screen directly.`);
|
|
320
|
+
}
|
|
321
|
+
validateRowActionNavigateTab(featureName, screenId, screenType, actionLabel, action, detail);
|
|
322
|
+
return detail;
|
|
323
|
+
}
|
|
324
|
+
if (action.screen === undefined) {
|
|
325
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
|
|
326
|
+
`sets neither "screen" nor "entity" — exactly one navigate-target form is required.`);
|
|
327
|
+
}
|
|
328
|
+
const candidateQn = qualifyEntityName(featureName, "screen", action.screen);
|
|
329
|
+
if (!allScreenQns.has(candidateQn) && !navTargetShortIds.has(action.screen)) {
|
|
330
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
|
|
331
|
+
`navigate-target "${action.screen}" does not resolve to a registered screen in any feature. Use "<feature>:screen:<id>" of an existing screen.`);
|
|
332
|
+
}
|
|
333
|
+
const target = screensByShortId.get(action.screen)?.[0];
|
|
334
|
+
validateRowActionNavigateTab(featureName, screenId, screenType, actionLabel, action, target);
|
|
335
|
+
return target;
|
|
336
|
+
}
|
|
337
|
+
// redirect is either a short screen id (same-feature, e.g. "item-list") or
|
|
338
|
+
// a fully-qualified cross-feature QN (`<feature>:screen:<id>`) — the
|
|
339
|
+
// renderer strips the latter to the short id (lastSegment) when
|
|
340
|
+
// navigating, which the nav-router resolves app-wide (#1946). The object
|
|
341
|
+
// form (fw#2670) carries the same target under `screen` plus the payload
|
|
342
|
+
// field `idFrom`. Shared by actionForm/secretMint (validateInlineFormNavTargets)
|
|
343
|
+
// and entityEdit — same rule, same error message, one place to keep them in sync.
|
|
344
|
+
export function validateRedirectTarget(feature, screenId, screenKind, redirect, lookups,
|
|
345
|
+
// Property name used in error messages (`successLinks[0]` for a success link).
|
|
346
|
+
propertyPath = "redirect") {
|
|
347
|
+
const { allScreenQns } = lookups;
|
|
348
|
+
const redirectTarget = typeof redirect === "string" ? redirect : redirect.screen;
|
|
349
|
+
if (typeof redirect !== "string" &&
|
|
350
|
+
(typeof redirect.idFrom !== "string" || redirect.idFrom.trim() === "")) {
|
|
351
|
+
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screenKind}) ${propertyPath}.idFrom is empty or not a string — ` +
|
|
352
|
+
`name the success-payload field carrying the navigation id, or use the plain string ` +
|
|
353
|
+
`redirect form to navigate with the handler's own "id".`);
|
|
354
|
+
}
|
|
355
|
+
// The renderer reads idFrom verbatim (Object.hasOwn), so stray whitespace
|
|
356
|
+
// never matches and the navigation silently lands without an entityId.
|
|
357
|
+
if (typeof redirect !== "string" && redirect.idFrom !== redirect.idFrom.trim()) {
|
|
358
|
+
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screenKind}) ${propertyPath}.idFrom "${redirect.idFrom}" ` +
|
|
359
|
+
`has leading/trailing whitespace — it would never match a payload field. Remove the whitespace from idFrom.`);
|
|
360
|
+
}
|
|
361
|
+
validateScreenNavTarget(feature.name, screenId, screenKind, propertyPath, redirectTarget, allScreenQns, feature.screens);
|
|
362
|
+
// idFrom only means something to a target that carries a record id; the
|
|
363
|
+
// renderer drops it silently otherwise. Cross-feature QN targets can't be
|
|
364
|
+
// typed from here and stay unchecked.
|
|
365
|
+
const sameFeatureTarget = feature.screens[redirectTarget];
|
|
366
|
+
if (typeof redirect !== "string" &&
|
|
367
|
+
sameFeatureTarget !== undefined &&
|
|
368
|
+
sameFeatureTarget.type !== "entityEdit" &&
|
|
369
|
+
sameFeatureTarget.type !== "projectionDetail") {
|
|
370
|
+
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screenKind}) ${propertyPath}.idFrom is set but target screen ` +
|
|
371
|
+
`"${redirectTarget}" (${sameFeatureTarget.type}) carries no id — use the plain string form.`);
|
|
372
|
+
}
|
|
373
|
+
if (typeof redirect !== "string" && redirect.tab !== undefined) {
|
|
374
|
+
validateRowActionNavigateTab(feature.name, screenId, screenKind, propertyPath, { kind: "navigate", id: redirectTarget, label: redirectTarget, tab: redirect.tab }, findNavTargetEntry(feature, redirectTarget, lookups));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
function findNavTargetEntry(feature, target, lookups) {
|
|
378
|
+
const targetQn = resolveScreenTargetQn(feature.name, target);
|
|
379
|
+
return lookups.screensByShortId
|
|
380
|
+
.get(targetQn.slice(targetQn.lastIndexOf(":") + 1))
|
|
381
|
+
?.find((entry) => qualifyEntityName(entry.featureName, "screen", entry.screen.id) === targetQn);
|
|
382
|
+
}
|
|
383
|
+
// A success link without idFrom navigates without an id, so its target must not
|
|
384
|
+
// be a record screen (entityEdit/projectionDetail need one).
|
|
385
|
+
export function validateIdlessSuccessLink(feature, screenId, screenKind, link, lookups, propertyPath) {
|
|
386
|
+
validateScreenNavTarget(feature.name, screenId, screenKind, propertyPath, link.screen, lookups.allScreenQns, feature.screens);
|
|
387
|
+
const target = findNavTargetEntry(feature, link.screen, lookups);
|
|
388
|
+
const targetType = target?.screen.type;
|
|
389
|
+
if (targetType === "entityEdit" || targetType === "projectionDetail") {
|
|
390
|
+
throw new Error(`[Feature ${feature.name}] Screen "${screenId}" (${screenKind}) ${propertyPath} targets "${link.screen}" ` +
|
|
391
|
+
`(${targetType}), a record screen — set ${propertyPath}.idFrom to the success-payload field carrying the record id.`);
|
|
392
|
+
}
|
|
393
|
+
if (link.tab !== undefined) {
|
|
394
|
+
validateRowActionNavigateTab(feature.name, screenId, screenKind, propertyPath, { kind: "navigate", id: link.screen, label: link.screen, tab: link.tab }, target);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
// A clientAction names a handler a client feature registers
|
|
398
|
+
// (ClientFeatureDefinition.clientActions). Nothing server-side can confirm the
|
|
399
|
+
// registration, so the shape is the only boot-time check.
|
|
400
|
+
export function validateClientActionName(featureName, screenId, screenKind, actionLabel, action) {
|
|
401
|
+
const segments = action.action.split(":");
|
|
402
|
+
const [scope, name] = segments;
|
|
403
|
+
const wellFormed = segments.length === 2 &&
|
|
404
|
+
scope !== undefined &&
|
|
405
|
+
name !== undefined &&
|
|
406
|
+
isKebabSegment(scope) &&
|
|
407
|
+
isKebabSegment(name);
|
|
408
|
+
// skip: well-formed name, nothing to reject
|
|
409
|
+
if (wellFormed)
|
|
410
|
+
return;
|
|
411
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}" ` +
|
|
412
|
+
`action "${action.action}" is not a client action name — expected "<feature>:<kebab-name>", ` +
|
|
413
|
+
`e.g. "auth-email-password:request-password-reset".`);
|
|
414
|
+
}
|
|
415
|
+
// redirect is only honored on record actions; on list/related-list row
|
|
416
|
+
// actions it would be silently dropped, so the author would believe it works.
|
|
417
|
+
export function rejectRedirectOnListRowAction(featureName, screenId, screenKind, action) {
|
|
418
|
+
if (action.kind !== "navigate" &&
|
|
419
|
+
action.kind !== "drawer" &&
|
|
420
|
+
action.kind !== "clientAction" &&
|
|
421
|
+
action.redirect !== undefined) {
|
|
422
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenKind}) rowAction "${action.id}" sets redirect — ` +
|
|
423
|
+
`redirect is only honored on record actions (projectionDetail/entityEdit header and section ` +
|
|
424
|
+
`actions) and is ignored on list row actions. Remove it.`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
// A writeHandler record action's redirect (projectionDetail/entityEdit
|
|
428
|
+
// header and section actions) follows the entityEdit.redirect rule.
|
|
429
|
+
export function validateRecordActionRedirect(feature, screenId, screenKind, action, lookups) {
|
|
430
|
+
if (action.kind !== "navigate" &&
|
|
431
|
+
action.kind !== "drawer" &&
|
|
432
|
+
action.kind !== "clientAction" &&
|
|
433
|
+
action.redirect !== undefined) {
|
|
434
|
+
validateRedirectTarget(feature, screenId, screenKind, action.redirect, lookups);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
// "edit" is the default edit action, which only exists when detailFor names
|
|
438
|
+
// the entity; a typo would otherwise silently fall back to the default precedence.
|
|
439
|
+
export function validatePrimaryActionTarget(featureName, screenId, screen) {
|
|
440
|
+
// skip: no declared primaryAction, the default header precedence applies
|
|
441
|
+
if (screen.primaryAction === undefined)
|
|
442
|
+
return;
|
|
443
|
+
const declaredIds = (screen.actions ?? []).map((action) => action.id);
|
|
444
|
+
const editIsAvailable = screen.detailFor !== undefined;
|
|
445
|
+
const targetExists = declaredIds.includes(screen.primaryAction) ||
|
|
446
|
+
(editIsAvailable && screen.primaryAction === "edit");
|
|
447
|
+
if (!targetExists) {
|
|
448
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (projectionDetail) primaryAction "${screen.primaryAction}" ` +
|
|
449
|
+
`matches no action — use one of the declared action ids (${declaredIds.join(", ") || "none declared"})` +
|
|
450
|
+
`${editIsAvailable ? ' or "edit"' : ' ("edit" needs detailFor on this screen)'}.`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
export function validateScreenVisibleWhen(feature, screenId, screen, allScreenQns, featureMap) {
|
|
454
|
+
const { visibleWhen, fallback } = screen;
|
|
455
|
+
const context = `[Feature ${feature.name}] Screen "${screenId}" (${screen.type})`;
|
|
456
|
+
if (visibleWhen === undefined && fallback !== undefined) {
|
|
457
|
+
throw new Error(`${context} declares fallback without visibleWhen. Add visibleWhen or remove fallback.`);
|
|
458
|
+
}
|
|
459
|
+
if (visibleWhen !== undefined && (visibleWhen.query === "" || visibleWhen.field === "")) {
|
|
460
|
+
throw new Error(`${context} visibleWhen needs a non-empty query and field. Set both visibleWhen.query and visibleWhen.field.`);
|
|
461
|
+
}
|
|
462
|
+
if (fallback !== undefined) {
|
|
463
|
+
validateScreenFallback(feature, screenId, screen.type, fallback, allScreenQns, featureMap);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
function validateScreenFallback(feature, screenId, screenType, fallback, allScreenQns, featureMap) {
|
|
467
|
+
const context = `[Feature ${feature.name}] Screen "${screenId}" (${screenType})`;
|
|
468
|
+
validateScreenNavTarget(feature.name, screenId, screenType, "fallback", fallback, allScreenQns, feature.screens);
|
|
469
|
+
const fallbackQn = resolveScreenTargetQn(feature.name, fallback);
|
|
470
|
+
if (fallbackQn === qualifyEntityName(feature.name, "screen", screenId)) {
|
|
471
|
+
throw new Error(`${context} fallback points at the screen itself. Point fallback at a different screen.`);
|
|
472
|
+
}
|
|
473
|
+
// A gated fallback could hide in turn and chain or cycle (A→B→A); one hop keeps the
|
|
474
|
+
// runtime gate trivially terminating.
|
|
475
|
+
const [fallbackFeatureName = "", , fallbackShortId = ""] = fallbackQn.split(":");
|
|
476
|
+
if (featureMap.get(fallbackFeatureName)?.screens[fallbackShortId]?.visibleWhen !== undefined) {
|
|
477
|
+
throw new Error(`${context} fallback "${fallback}" has its own visibleWhen; a fallback must always render.`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
// Only a navigate action that sets `tab` needs the target resolved (to check
|
|
481
|
+
// the tab id); plain navigates are validated elsewhere. One place for every
|
|
482
|
+
// section-level action site so a new site can't forget the tab check.
|
|
483
|
+
export function validateNavigateActionTab(featureName, screenId, screenType, actionLabel, action, lookups) {
|
|
484
|
+
// skip: only navigate actions with an explicit tab need target validation
|
|
485
|
+
if (action.kind !== "navigate" || action.tab === undefined)
|
|
486
|
+
return;
|
|
487
|
+
resolveRowActionNavigateTarget(featureName, screenId, screenType, actionLabel, action, lookups.allScreenQns, lookups.navTargetShortIds, lookups.screensByShortId, lookups.detailForScreens);
|
|
488
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { FeatureDefinition } from "../types/index.ts";
|
|
2
|
+
import type { DashboardCustomPanel, DashboardScreenDefinition, DashboardScreenPanel } from "../types/screen.ts";
|
|
3
|
+
import { resolveRowActionNavigateTarget } from "./screens-actions.ts";
|
|
4
|
+
export declare function validateDashboardScreen(featureName: string, screenId: string, screen: DashboardScreenDefinition, featureMap: ReadonlyMap<string, FeatureDefinition>): void;
|
|
5
|
+
type DashboardActionNavLookups = {
|
|
6
|
+
readonly allScreenQns: Parameters<typeof resolveRowActionNavigateTarget>[5];
|
|
7
|
+
readonly navTargetShortIds: Parameters<typeof resolveRowActionNavigateTarget>[6];
|
|
8
|
+
readonly screensByShortId: Parameters<typeof resolveRowActionNavigateTarget>[7];
|
|
9
|
+
readonly detailForScreens: Parameters<typeof resolveRowActionNavigateTarget>[8];
|
|
10
|
+
};
|
|
11
|
+
export declare function validateDashboardActions(featureName: string, screenId: string, screen: DashboardScreenDefinition, allWriteHandlerQns: ReadonlySet<string>, lookups: DashboardActionNavLookups): void;
|
|
12
|
+
export declare function validateDashboardRecordBinding(featureName: string, screenId: string, screen: DashboardScreenDefinition): void;
|
|
13
|
+
export declare function validateDashboardScreenPanel(featureName: string, screenId: string, panel: DashboardScreenPanel, featureMap: ReadonlyMap<string, FeatureDefinition>): void;
|
|
14
|
+
export declare function validateDashboardCustomPanel(featureName: string, screenId: string, panel: DashboardCustomPanel): void;
|
|
15
|
+
export {};
|