@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.
- 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/api/request-context.d.ts +2 -0
- package/dist/api/request-context.js +16 -7
- 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/cross-tenant-reads.d.ts +8 -0
- package/dist/db/cross-tenant-reads.js +23 -0
- 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/queries/test-stack.d.ts +4 -1
- package/dist/db/queries/test-stack.js +15 -2
- package/dist/db/query.js +19 -19
- package/dist/db/tenant-db.js +1 -1
- package/dist/engine/boot-validator/i18n-keys.d.ts +1 -0
- package/dist/engine/boot-validator/i18n-keys.js +8 -1
- package/dist/engine/boot-validator/index.d.ts +4 -0
- package/dist/engine/boot-validator/index.js +2 -1
- 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/feature-ast/pattern-change-schema.d.ts +8 -0
- package/dist/engine/steps/read-find-one.js +1 -1
- package/dist/event-store/provenance-append.js +1 -1
- package/dist/files/file-routes.js +1 -1
- package/dist/i18n/required-surface-keys.d.ts +16 -0
- package/dist/i18n/required-surface-keys.js +62 -0
- package/dist/internal/stack-support.d.ts +1 -1
- package/dist/internal/stack-support.js +1 -1
- package/dist/jobs/job-runner-helpers.d.ts +44 -0
- package/dist/jobs/job-runner-helpers.js +265 -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 +10 -386
- package/dist/pipeline/dispatch-shared.js +29 -4
- package/dist/pipeline/event-dispatcher.js +2 -3
- package/dist/schema-cli.js +5 -0
- package/package.json +3 -3
- package/src/changes.json +19 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { REFERENCE_LOOKUP_SOURCES } from "../../ui-types/list-row-meta.js";
|
|
2
|
+
import { parseRefTarget } from "../parse-ref-target.js";
|
|
3
|
+
import { qualifyEntityName } from "../qualified-name.js";
|
|
4
|
+
import { explicitListScreenId, normalizeListColumn, resolveNavParentScreen, } from "../screen-helpers.js";
|
|
5
|
+
import { isRowClickAction, rejectRedirectOnListRowAction, resolveRowActionNavigateTarget, validateClientActionName, validateDrawerTargetAction, validateRowActionNavigateParams, } from "./screens-actions.js";
|
|
6
|
+
// validateScreenHasNavArea runs once per screen; the index is built once per
|
|
7
|
+
// featureMap (immutable during boot) instead of re-scanning every feature each call.
|
|
8
|
+
const navAreaIndexByFeatureMap = new WeakMap();
|
|
9
|
+
function navTargetScreenQns(nav) {
|
|
10
|
+
return [nav.screen, nav.createAction?.screen, ...(nav.actions ?? []).map((a) => a.screen)].filter((qn) => qn !== undefined);
|
|
11
|
+
}
|
|
12
|
+
function getNavAreaIndex(featureMap) {
|
|
13
|
+
const cached = navAreaIndexByFeatureMap.get(featureMap);
|
|
14
|
+
if (cached !== undefined)
|
|
15
|
+
return cached;
|
|
16
|
+
const navTargetQns = new Set();
|
|
17
|
+
const allScreens = [];
|
|
18
|
+
let anyNavRegistered = false;
|
|
19
|
+
for (const f of featureMap.values()) {
|
|
20
|
+
allScreens.push(...Object.values(f.screens));
|
|
21
|
+
for (const nav of Object.values(f.navs)) {
|
|
22
|
+
anyNavRegistered = true;
|
|
23
|
+
for (const qn of navTargetScreenQns(nav))
|
|
24
|
+
navTargetQns.add(qn);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const index = { anyNavRegistered, navTargetQns, allScreens };
|
|
28
|
+
navAreaIndexByFeatureMap.set(featureMap, index);
|
|
29
|
+
return index;
|
|
30
|
+
}
|
|
31
|
+
// resolveNavParentScreen falls back to a heuristic when the explicit
|
|
32
|
+
// listScreenId matches no screen, so a typo would silently get the guess.
|
|
33
|
+
export function validateExplicitListScreenExists(featureName, screenId, screen, screensByShortId) {
|
|
34
|
+
const listScreenId = explicitListScreenId(screen);
|
|
35
|
+
// skip: no explicit listScreenId, the nav heuristic applies and there is nothing to validate
|
|
36
|
+
if (listScreenId === undefined)
|
|
37
|
+
return;
|
|
38
|
+
const shortId = listScreenId.slice(listScreenId.lastIndexOf(":") + 1);
|
|
39
|
+
// skip: the explicit listScreenId resolves to a mounted screen
|
|
40
|
+
if (screensByShortId.has(shortId))
|
|
41
|
+
return;
|
|
42
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screen.type}) listScreenId "${listScreenId}" ` +
|
|
43
|
+
`matches no mounted screen — fix the id or mount the list screen.`);
|
|
44
|
+
}
|
|
45
|
+
// Every screen must resolve nav via `nav`, `r.nav()`, a parent list, or
|
|
46
|
+
// `dormant: true`. Skipped when the composed set has no nav entries at all.
|
|
47
|
+
export function validateScreenHasNavArea(feature, screenId, screen, featureMap) {
|
|
48
|
+
// skip: the screen already declares its own nav entry.
|
|
49
|
+
if (screen.nav !== undefined)
|
|
50
|
+
return;
|
|
51
|
+
// skip: explicitly opted out via `dormant: true`.
|
|
52
|
+
if (screen.dormant === true)
|
|
53
|
+
return;
|
|
54
|
+
const { anyNavRegistered, navTargetQns, allScreens } = getNavAreaIndex(featureMap);
|
|
55
|
+
// skip: no nav entries exist anywhere in the composed set — a feature/
|
|
56
|
+
// recipe/test fixture booted without an app shell, not an omission.
|
|
57
|
+
if (!anyNavRegistered)
|
|
58
|
+
return;
|
|
59
|
+
const targetQn = qualifyEntityName(feature.name, "screen", screenId);
|
|
60
|
+
// skip: a standalone r.nav() elsewhere already points at this screen.
|
|
61
|
+
if (navTargetQns.has(targetQn))
|
|
62
|
+
return;
|
|
63
|
+
// skip: resolves to a parent list via listScreenId/rowAction-target/same-entity.
|
|
64
|
+
if (resolveNavParentScreen(allScreens, screen, (s) => s.id) !== undefined)
|
|
65
|
+
return;
|
|
66
|
+
throw new Error(`[Feature ${feature.name}] Screen "${targetQn}" has no nav entry and no resolvable list — ` +
|
|
67
|
+
`declare listScreenId (or a rowAction/toolbarAction navigate target from a list screen), add ` +
|
|
68
|
+
`a standalone r.nav() pointing at it, or set dormant: true if it's intentionally reachable ` +
|
|
69
|
+
`only via a direct link/redirect or a consuming app's own r.nav().`);
|
|
70
|
+
}
|
|
71
|
+
// Shared by projectionDetail relatedList sections and entityList.expandableRow:
|
|
72
|
+
// both render a relatedList whose rows come from an arbitrary query.
|
|
73
|
+
export function validateRelatedListSection(args) {
|
|
74
|
+
const { featureName, featureMap, where, section } = args;
|
|
75
|
+
if (!section.query || typeof section.query !== "string") {
|
|
76
|
+
throw new Error(`[Feature ${featureName}] ${where} has empty or non-string query. Set query to a registered query name.`);
|
|
77
|
+
}
|
|
78
|
+
if (section.entity !== undefined) {
|
|
79
|
+
assertRefTargetRegistered(`[Feature ${featureName}] ${where}`, "entity", section.entity, featureName, featureMap);
|
|
80
|
+
}
|
|
81
|
+
for (const col of section.columns) {
|
|
82
|
+
const normalizedCol = normalizeListColumn(col);
|
|
83
|
+
if (normalizedCol.refEntity !== undefined) {
|
|
84
|
+
assertRefTargetRegistered(`[Feature ${featureName}] ${where}`, `column "${normalizedCol.field}" (refEntity)`, normalizedCol.refEntity, featureName, featureMap);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (section.rowClick !== undefined) {
|
|
88
|
+
const targetEntity = section.rowClick.entity;
|
|
89
|
+
const hasDetailScreen = [...featureMap.values()].some((f) => Object.values(f.screens).some((s) => s.detailFor === targetEntity));
|
|
90
|
+
if (!hasDetailScreen) {
|
|
91
|
+
throw new Error(`[Feature ${featureName}] ${where} ` +
|
|
92
|
+
`rowClick targets entity "${targetEntity}", but no screen declares ` +
|
|
93
|
+
`detailFor: "${targetEntity}". Add detailFor: "${targetEntity}" to the screen that shows it.`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (section.rowActions !== undefined)
|
|
97
|
+
validateRelatedListRowActions(args, section.rowActions);
|
|
98
|
+
if (section.defaultSort !== undefined)
|
|
99
|
+
validateRelatedListDefaultSort(args, section.defaultSort);
|
|
100
|
+
if (section.facets !== undefined) {
|
|
101
|
+
validateListFacets(`[Feature ${featureName}] ${where}`, section.facets, section.columns, featureName, featureMap);
|
|
102
|
+
}
|
|
103
|
+
if (section.toolbarActions !== undefined) {
|
|
104
|
+
validateRelatedListToolbarActions(args, section.toolbarActions);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// An empty list has no row, so params resolve against an empty record.
|
|
108
|
+
function rejectEmptyStateActionParams(ctx, actionLabel, action) {
|
|
109
|
+
const params = action.kind === "navigate" || action.kind === "drawer" ? action.params : undefined;
|
|
110
|
+
if (params !== undefined) {
|
|
111
|
+
throw new Error(`[Feature ${ctx.featureName}] ${ctx.where} ${actionLabel} "${action.id}" sets params — an empty list ` +
|
|
112
|
+
`has no row to read them from, so they would resolve against an empty record. Remove the params.`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Target checks shared by list/related-list rowActions and emptyState.action:
|
|
116
|
+
// navigate target + params, drawer target, write-handler registration.
|
|
117
|
+
export function validateListActionTargets(ctx, actionLabel, action) {
|
|
118
|
+
const { featureName, screenId, screenType } = ctx;
|
|
119
|
+
rejectRedirectOnListRowAction(featureName, screenId, screenType, action);
|
|
120
|
+
if (actionLabel === "emptyState action")
|
|
121
|
+
rejectEmptyStateActionParams(ctx, actionLabel, action);
|
|
122
|
+
if (action.kind === "navigate") {
|
|
123
|
+
const target = resolveRowActionNavigateTarget(featureName, screenId, screenType, actionLabel, action, ctx.allScreenQns, ctx.navTargetShortIds, ctx.screensByShortId, ctx.detailForScreens);
|
|
124
|
+
validateRowActionNavigateParams(featureName, screenId, screenType, ctx.screenEntity, action, target, actionLabel);
|
|
125
|
+
}
|
|
126
|
+
else if (action.kind === "drawer") {
|
|
127
|
+
validateDrawerTargetAction(featureName, screenId, screenType, actionLabel, action, ctx.screens);
|
|
128
|
+
}
|
|
129
|
+
else if (action.kind === "clientAction") {
|
|
130
|
+
validateClientActionName(featureName, screenId, screenType, actionLabel, action);
|
|
131
|
+
}
|
|
132
|
+
else if (!ctx.allWriteHandlerQns.has(action.handler)) {
|
|
133
|
+
throw new Error(`[Feature ${featureName}] ${ctx.where} ` +
|
|
134
|
+
`${actionLabel} "${action.id}" handler "${action.handler}" ` +
|
|
135
|
+
`is not a registered write-handler. Check the QN spelling (expected ` +
|
|
136
|
+
`"<feature>:write:<short>") and that the handler is declared via r.writeHandler(...).`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function validateRelatedListRowActions(args, rowActions) {
|
|
140
|
+
const { featureName, screenId, screenType, where, section, screens, allWriteHandlerQns, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens, } = args;
|
|
141
|
+
for (const action of rowActions) {
|
|
142
|
+
validateListActionTargets({
|
|
143
|
+
featureName,
|
|
144
|
+
screenId,
|
|
145
|
+
screenType,
|
|
146
|
+
screenEntity: undefined,
|
|
147
|
+
where,
|
|
148
|
+
screens,
|
|
149
|
+
allWriteHandlerQns,
|
|
150
|
+
allScreenQns,
|
|
151
|
+
navTargetShortIds,
|
|
152
|
+
screensByShortId,
|
|
153
|
+
detailForScreens,
|
|
154
|
+
}, "rowAction", action);
|
|
155
|
+
}
|
|
156
|
+
// section.rowClick (legacy, navigate-only) and a rowActions entry
|
|
157
|
+
// marked rowClick:true both claim the row-body click — same
|
|
158
|
+
// at-most-one constraint as entityList/projectionList's
|
|
159
|
+
// validateAtMostOneRowClick, just spanning two fields instead of one.
|
|
160
|
+
const rowClickActionCount = rowActions.filter(isRowClickAction).length;
|
|
161
|
+
const legacyRowClickCount = section.rowClick !== undefined ? 1 : 0;
|
|
162
|
+
if (rowClickActionCount + legacyRowClickCount > 1) {
|
|
163
|
+
throw new Error(`[Feature ${featureName}] ${where} ` +
|
|
164
|
+
`has both a rowClick and ${rowClickActionCount} rowActions marked ` +
|
|
165
|
+
`rowClick:true — at most one may fire on a row-body click.`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function validateRelatedListDefaultSort(args, defaultSort) {
|
|
169
|
+
const { featureName, where, section } = args;
|
|
170
|
+
const sortField = defaultSort.field;
|
|
171
|
+
const col = section.columns.find((c) => normalizeListColumn(c).field === sortField);
|
|
172
|
+
if (col === undefined) {
|
|
173
|
+
throw new Error(`[Feature ${featureName}] ${where} ` +
|
|
174
|
+
`defaultSort.field "${sortField}" is not a listed column. Use a listed column as defaultSort.field.`);
|
|
175
|
+
}
|
|
176
|
+
if (normalizeListColumn(col).sortable !== true) {
|
|
177
|
+
throw new Error(`[Feature ${featureName}] ${where} ` +
|
|
178
|
+
`defaultSort.field "${sortField}" is not sortable. Set sortable: true on ` +
|
|
179
|
+
`the column or pick another field.`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// Only drawer-kind and navigate actions that set tab are validated here;
|
|
183
|
+
// plain navigate (no tab) and writeHandler toolbarActions have no boot check yet.
|
|
184
|
+
function validateRelatedListToolbarActions(args, toolbarActions) {
|
|
185
|
+
const { featureName, screenId, screenType, screens, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens, } = args;
|
|
186
|
+
for (const action of toolbarActions) {
|
|
187
|
+
if (action.kind === "drawer") {
|
|
188
|
+
validateDrawerTargetAction(featureName, screenId, screenType, "toolbarAction", action, screens);
|
|
189
|
+
}
|
|
190
|
+
if (action.kind === "navigate" && action.tab !== undefined) {
|
|
191
|
+
resolveRowActionNavigateTarget(featureName, screenId, screenType, "toolbarAction", action, allScreenQns, navTargetShortIds, screensByShortId, detailForScreens);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// Column-level sortable: true only enables relatedList headers. On entityList
|
|
196
|
+
// the entity field's flag wins; on projectionList sortable comes from the
|
|
197
|
+
// query schema and a column can only opt OUT with sortable: false.
|
|
198
|
+
export function rejectInertColumnSortable(featureName, screenId, screenType, column) {
|
|
199
|
+
const isInert = screenType === "entityList" ? column.sortable !== undefined : column.sortable === true;
|
|
200
|
+
// skip: the sortable flag is meaningful for this screen type
|
|
201
|
+
if (!isInert)
|
|
202
|
+
return;
|
|
203
|
+
const source = screenType === "entityList"
|
|
204
|
+
? "set sortable: true on the entity field instead"
|
|
205
|
+
: "sorting is derived from the query's Zod schema; only sortable: false is honored";
|
|
206
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" (${screenType}) column "${column.field}" sets sortable — ` +
|
|
207
|
+
`it is ignored here; ${source}. Remove sortable from this column.`);
|
|
208
|
+
}
|
|
209
|
+
// Form check for ListColumn renderers in the PlatformComponent form
|
|
210
|
+
// (`{ react: { __component: "Name" } }`). The server does not know the
|
|
211
|
+
// client-side columnRenderers map, so it only checks that the structure is
|
|
212
|
+
// right: if `react` is set as an object, `__component` MUST be a non-empty
|
|
213
|
+
// string. A key omitted on the client only triggers a warning, not a boot
|
|
214
|
+
// failure.
|
|
215
|
+
export function validateColumnRendererForm(featureName, screenId, column) {
|
|
216
|
+
const renderer = column.renderer;
|
|
217
|
+
// skip: only the PlatformComponent form ({ react: { __component: "..." } })
|
|
218
|
+
// is validated structurally. Function, string-QN and null/undefined
|
|
219
|
+
// renderers are all valid other forms — not a form error.
|
|
220
|
+
if (renderer === null || typeof renderer !== "object")
|
|
221
|
+
return;
|
|
222
|
+
// @cast-boundary schema-walk — feature-config renderer-shape introspection
|
|
223
|
+
const react = renderer.react;
|
|
224
|
+
// skip: no react branch → either native-only or not a PlatformComponent —
|
|
225
|
+
// both outside this check.
|
|
226
|
+
if (react === undefined || react === null)
|
|
227
|
+
return;
|
|
228
|
+
if (typeof react !== "object") {
|
|
229
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" column "${column.field}" has a renderer with ` +
|
|
230
|
+
`a non-object \`react\` branch — expected \`{ react: { __component: "Name" } }\`.`);
|
|
231
|
+
}
|
|
232
|
+
// @cast-boundary schema-walk — feature-config react-branch introspection
|
|
233
|
+
const component = react.__component;
|
|
234
|
+
// skip: without a __component key this is not the string-key form
|
|
235
|
+
// (e.g. a future direct component ref); not our domain.
|
|
236
|
+
if (component === undefined)
|
|
237
|
+
return;
|
|
238
|
+
if (typeof component !== "string" || component.length === 0) {
|
|
239
|
+
throw new Error(`[Feature ${featureName}] Screen "${screenId}" column "${column.field}" has a renderer with ` +
|
|
240
|
+
`\`react.__component\` = ${JSON.stringify(component)} — expected a non-empty string identifying ` +
|
|
241
|
+
`a client-side columnRenderers entry.`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// Shared entity-target resolution for reference facets, refEntity columns
|
|
245
|
+
// (projectionList/relatedList) and refEntity fields (projectionDetail).
|
|
246
|
+
export function assertRefTargetRegistered(prefix, subject, refTarget, currentFeatureName, featureMap) {
|
|
247
|
+
const target = parseRefTarget(refTarget, currentFeatureName);
|
|
248
|
+
const targetFeature = featureMap.get(target.featureName);
|
|
249
|
+
if (targetFeature === undefined) {
|
|
250
|
+
throw new Error(`${prefix} ${subject} targets entity "${refTarget}", but feature "${target.featureName}" is not ` +
|
|
251
|
+
`mounted — mount it and declare r.requires({ features: ["${target.featureName}"] }).`);
|
|
252
|
+
}
|
|
253
|
+
if (targetFeature.entities?.[target.entityName] === undefined) {
|
|
254
|
+
throw new Error(`${prefix} ${subject} targets entity "${refTarget}", which does not resolve to a ` +
|
|
255
|
+
`registered entity. Known entities in feature "${target.featureName}": ` +
|
|
256
|
+
`${Object.keys(targetFeature.entities ?? {})
|
|
257
|
+
.sort()
|
|
258
|
+
.join(", ") || "(none)"}.`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// Facets (fw#2224) — unlike filter, a field inventory IS available here: the
|
|
262
|
+
// declared columns. A facet on a field with no column is almost always a
|
|
263
|
+
// typo (the user never sees the field anywhere), so this is hard-checked
|
|
264
|
+
// rather than just documented. Shared by projectionList and relatedList
|
|
265
|
+
// (fw#2740) — `prefix` carries the caller's own screen/section message lead-in.
|
|
266
|
+
//
|
|
267
|
+
// `type: "reference"` is exempt from the same-name-column rule (fw akte-
|
|
268
|
+
// bedienkonzept-2): it filters by an id field (e.g. "propertyId") but
|
|
269
|
+
// displays a different, human-readable column (e.g. "propertyLabel") — a
|
|
270
|
+
// column named after the filter field would almost never exist, and
|
|
271
|
+
// requiring one anyway would force apps to add a column no design calls
|
|
272
|
+
// for just to satisfy this check. Its own `entity` is mandatory on the type
|
|
273
|
+
// (TS, not just this validator) and is resolved below — that explicit,
|
|
274
|
+
// checked declaration is this facet type's field inventory, filling the
|
|
275
|
+
// same "not just a typo" role the column-name check plays for select/
|
|
276
|
+
// boolean facets. select/boolean facets keep the column requirement
|
|
277
|
+
// unchanged: both filter and display the same field, so a facet with no
|
|
278
|
+
// matching column is still almost always a typo.
|
|
279
|
+
export function validateListFacets(prefix, facets, columns, currentFeatureName, featureMap) {
|
|
280
|
+
const columnFieldNames = new Set(columns.map((col) => normalizeListColumn(col).field));
|
|
281
|
+
const seenFacetFields = new Set();
|
|
282
|
+
for (const facet of facets) {
|
|
283
|
+
if (seenFacetFields.has(facet.field)) {
|
|
284
|
+
throw new Error(`${prefix} declares facet "${facet.field}" more than once. Declare each facet field once.`);
|
|
285
|
+
}
|
|
286
|
+
seenFacetFields.add(facet.field);
|
|
287
|
+
if (facet.type !== "reference" && !columnFieldNames.has(facet.field)) {
|
|
288
|
+
throw new Error(`${prefix} facet references field "${facet.field}" which is not a declared column. ` +
|
|
289
|
+
`Known columns: ${[...columnFieldNames].sort().join(", ")}`);
|
|
290
|
+
}
|
|
291
|
+
if (facet.type === "select" && facet.options.length === 0) {
|
|
292
|
+
throw new Error(`${prefix} facet "${facet.field}" (type "select") has an empty options list — ` +
|
|
293
|
+
`declare at least one option.`);
|
|
294
|
+
}
|
|
295
|
+
if (facet.type === "reference") {
|
|
296
|
+
assertRefTargetRegistered(prefix, `facet "${facet.field}" (type "reference")`, facet.entity, currentFeatureName, featureMap);
|
|
297
|
+
assertReferenceListHandlerRegistered(prefix, `facet "${facet.field}" (type "reference")`, facet.entity, currentFeatureName, featureMap);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// The facet's options are loaded at render time by dispatching the target's
|
|
302
|
+
// list query (renderer useReferenceLookup, incl. its REFERENCE_LOOKUP_SOURCES
|
|
303
|
+
// override); a missing or unregistered handler is swallowed there and leaves
|
|
304
|
+
// a permanently empty dropdown, so it has to fail at boot instead.
|
|
305
|
+
function assertReferenceListHandlerRegistered(prefix, subject, refTarget, currentFeatureName, featureMap) {
|
|
306
|
+
const target = parseRefTarget(refTarget, currentFeatureName);
|
|
307
|
+
const override = REFERENCE_LOOKUP_SOURCES[`${target.featureName}:${target.entityName}`];
|
|
308
|
+
const expectedQn = override?.queryQn ??
|
|
309
|
+
qualifyEntityName(target.featureName, "query", `${target.entityName}:list`);
|
|
310
|
+
for (const feature of featureMap.values()) {
|
|
311
|
+
for (const handlerName of Object.keys(feature.queryHandlers ?? {})) {
|
|
312
|
+
// skip: expected list handler found
|
|
313
|
+
if (qualifyEntityName(feature.name, "query", handlerName) === expectedQn)
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
throw new Error(`${prefix} ${subject} loads its options from query "${expectedQn}", which is not a registered ` +
|
|
318
|
+
`query handler — register the entity's list handler (defineEntityListHandler) or the facet renders an empty dropdown.`);
|
|
319
|
+
}
|
|
@@ -1,19 +1,8 @@
|
|
|
1
|
-
import type { IconKey } from "@cosmicdrift/kumiko-types/nav-icon";
|
|
2
1
|
import type { ConfigKeyDefinition, FeatureDefinition } from "../types/index.ts";
|
|
3
|
-
import type {
|
|
4
|
-
export
|
|
5
|
-
export
|
|
6
|
-
export
|
|
7
|
-
readonly id: string;
|
|
8
|
-
readonly icon?: IconKey;
|
|
9
|
-
}): void;
|
|
10
|
-
export declare function validateActionFieldRefs(featureName: string, screenId: string, actionKind: "rowAction" | "toolbarAction", actionId: string, action: RowAction | ToolbarAction | RelatedListToolbarAction, fieldNames: ReadonlySet<string>, rowMeta: ReadonlySet<string>): void;
|
|
11
|
-
export declare function validateScreenShortIdCollisions(screensByShortId: ReadonlyMap<string, ReadonlyArray<{
|
|
12
|
-
readonly featureName: string;
|
|
13
|
-
readonly screen: ScreenDefinition;
|
|
14
|
-
}>>): void;
|
|
15
|
-
export declare function resolveScreenTargetQn(featureName: string, target: string): string;
|
|
16
|
-
export declare function validateDrawerPrefillKeys(where: string, targetLabel: "drawer-target" | "navigate-target", targetScreenId: string, target: ActionFormScreenDefinition | SecretMintScreenDefinition, params: RowFieldExtractor): void;
|
|
2
|
+
import type { ScreenDefinition } from "../types/screen.ts";
|
|
3
|
+
export { readsNavigateParamsAsFormPrefill, resolveScreenTargetQn, rowFieldExtractorKeys, validateActionFieldRefs, validateActionHasIcon, validateDrawerPrefillKeys, validateScreenShortIdCollisions, } from "./screens-actions.ts";
|
|
4
|
+
export { validateDashboardCustomPanel, validateDashboardScreenPanel } from "./screens-dashboard.ts";
|
|
5
|
+
export { validateColumnRendererForm } from "./screens-lists.ts";
|
|
17
6
|
export declare function validateScreens(feature: FeatureDefinition, featureMap: ReadonlyMap<string, FeatureDefinition>, allWriteHandlerQns: ReadonlySet<string>, allScreenQns: ReadonlySet<string>, configKeyDefsByQn: ReadonlyMap<string, ConfigKeyDefinition>, screensByShortId: ReadonlyMap<string, ReadonlyArray<{
|
|
18
7
|
readonly featureName: string;
|
|
19
8
|
readonly screen: ScreenDefinition;
|
|
@@ -21,12 +10,6 @@ export declare function validateScreens(feature: FeatureDefinition, featureMap:
|
|
|
21
10
|
readonly featureName: string;
|
|
22
11
|
readonly screen: ScreenDefinition;
|
|
23
12
|
}>): void;
|
|
24
|
-
export declare function validateDashboardScreenPanel(featureName: string, screenId: string, panel: DashboardScreenPanel, featureMap: ReadonlyMap<string, FeatureDefinition>): void;
|
|
25
|
-
export declare function validateDashboardCustomPanel(featureName: string, screenId: string, panel: DashboardCustomPanel): void;
|
|
26
|
-
export declare function validateColumnRendererForm(featureName: string, screenId: string, column: {
|
|
27
|
-
readonly field: string;
|
|
28
|
-
readonly renderer?: unknown;
|
|
29
|
-
}): void;
|
|
30
13
|
export declare function findEntityFeature(entityName: string, featureMap: ReadonlyMap<string, FeatureDefinition>): string | undefined;
|
|
31
14
|
export declare function buildUnknownFieldMessage(featureName: string, screenId: string, fieldName: string, entityName: string, knownFields: ReadonlySet<string>): string;
|
|
32
15
|
export declare function collectScreenQns(features: readonly FeatureDefinition[]): Set<string>;
|