@cosmicdrift/kumiko-framework 0.330.2 → 0.332.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/dist/db/blind-index-cleanup.d.ts +1 -0
- package/dist/db/blind-index-cleanup.js +12 -0
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/queries/event-consumer.d.ts +2 -1
- package/dist/db/queries/event-consumer.js +12 -2
- package/dist/engine/boot-validator/action-wiring.js +3 -0
- package/dist/engine/boot-validator/config-deps.d.ts +1 -0
- package/dist/engine/boot-validator/config-deps.js +24 -0
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +20 -0
- package/dist/engine/boot-validator/index.js +6 -2
- package/dist/engine/boot-validator/projection-list-screens.js +11 -6
- package/dist/engine/boot-validator/query-output-columns.js +23 -2
- package/dist/engine/boot-validator/query-refs.d.ts +2 -1
- package/dist/engine/boot-validator/query-refs.js +24 -1
- package/dist/engine/boot-validator/screens.d.ts +3 -1
- package/dist/engine/boot-validator/screens.js +180 -78
- package/dist/engine/boot-validator/selector-owner-panels.d.ts +2 -0
- package/dist/engine/boot-validator/selector-owner-panels.js +25 -0
- package/dist/engine/boot-validator/url-prefill-fields.js +18 -1
- package/dist/engine/build-app-schema.js +3 -0
- package/dist/engine/build-config-feature-schema.js +40 -6
- package/dist/engine/changeset-changes.js +6 -0
- package/dist/engine/config-helpers.d.ts +2 -0
- package/dist/engine/config-helpers.js +9 -0
- package/dist/engine/entity-handlers.js +1 -1
- package/dist/engine/feature-ui-extensions.d.ts +4 -2
- package/dist/engine/feature-ui-extensions.js +40 -3
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/project-app-schema-for-roles.js +30 -2
- package/dist/engine/registry-facade.js +3 -0
- package/dist/engine/registry-ingest.js +2 -0
- package/dist/engine/registry-state.d.ts +2 -1
- package/dist/engine/registry-state.js +1 -0
- package/dist/engine/schema-builder.d.ts +3 -1
- package/dist/engine/schema-builder.js +5 -6
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/errors/reasons.js +7 -6
- package/dist/i18n/required-surface-keys.js +27 -10
- package/dist/pipeline/dispatch-write.js +26 -2
- package/dist/pipeline/event-consumer-state.js +6 -0
- package/dist/pipeline/event-dispatcher-admin.js +36 -16
- package/dist/pipeline/event-dispatcher-delivery.d.ts +2 -0
- package/dist/pipeline/event-dispatcher-delivery.js +22 -2
- package/dist/pipeline/event-dispatcher.js +1 -0
- package/dist/ui-types/index.d.ts +1 -1
- package/package.json +4 -4
- package/src/changes.json +54 -0
|
@@ -3,3 +3,4 @@ import type { DbRunner } from "./connection.js";
|
|
|
3
3
|
export declare function nullBlindIndexesForSubject(db: DbRunner, features: ReadonlyMap<string, FeatureDefinition>, subjectKey: string): Promise<void>;
|
|
4
4
|
export declare function subjectRowExistsInTenant(db: DbRunner, features: ReadonlyMap<string, FeatureDefinition>, subjectId: string, tenantId: string): Promise<boolean>;
|
|
5
5
|
export declare function recordRowExistsInTenant(db: DbRunner, features: ReadonlyMap<string, FeatureDefinition>, entityName: string, recordId: string, tenantId: string): Promise<boolean>;
|
|
6
|
+
export declare function recordRowOwningTenantId(db: DbRunner, features: ReadonlyMap<string, FeatureDefinition>, entityName: string, recordId: string): Promise<string | undefined>;
|
|
@@ -110,3 +110,15 @@ export async function recordRowExistsInTenant(db, features, entityName, recordId
|
|
|
110
110
|
const rows = await executeRawQueryRead(db, `SELECT 1 FROM ${quoteIdent(tableName)} WHERE id = $1 AND tenant_id = $2 LIMIT 1`, [recordId, tenantId]);
|
|
111
111
|
return rows.length > 0;
|
|
112
112
|
}
|
|
113
|
+
// Owning tenant of a record subject's row, for callers that must evaluate
|
|
114
|
+
// tenant-scoped policy for the row's real owner (not the acting user's tenant).
|
|
115
|
+
export async function recordRowOwningTenantId(db, features, entityName, recordId) {
|
|
116
|
+
const entity = findEntityByExactName(features, entityName);
|
|
117
|
+
if (!entity)
|
|
118
|
+
return undefined;
|
|
119
|
+
const tableName = resolveTableName(entityName, entity, undefined);
|
|
120
|
+
if (!(await tableExists(db, tableName)))
|
|
121
|
+
return undefined;
|
|
122
|
+
const rows = await executeRawQueryRead(db, `SELECT tenant_id FROM ${quoteIdent(tableName)} WHERE id = $1 LIMIT 1`, [recordId]);
|
|
123
|
+
return rows[0]?.tenant_id;
|
|
124
|
+
}
|
package/dist/db/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { assertExistsIn } from "./assert-exists-in.js";
|
|
2
|
-
export { nullBlindIndexesForSubject, recordRowExistsInTenant, subjectRowExistsInTenant, } from "./blind-index-cleanup.js";
|
|
2
|
+
export { nullBlindIndexesForSubject, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "./blind-index-cleanup.js";
|
|
3
3
|
export { collectTableMetas } from "./collect-table-metas.js";
|
|
4
4
|
export { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
|
|
5
5
|
export { seedConfigValues } from "./config-seed.js";
|
package/dist/db/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { assertExistsIn } from "./assert-exists-in.js";
|
|
2
|
-
export { nullBlindIndexesForSubject, recordRowExistsInTenant, subjectRowExistsInTenant, } from "./blind-index-cleanup.js";
|
|
2
|
+
export { nullBlindIndexesForSubject, recordRowExistsInTenant, recordRowOwningTenantId, subjectRowExistsInTenant, } from "./blind-index-cleanup.js";
|
|
3
3
|
export { collectTableMetas } from "./collect-table-metas.js";
|
|
4
4
|
export { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
|
|
5
5
|
export { seedConfigValues } from "./config-seed.js";
|
|
@@ -24,10 +24,11 @@ export type ConsumerDeliveryOutcome = {
|
|
|
24
24
|
readonly deadLettered: boolean;
|
|
25
25
|
readonly processed: number;
|
|
26
26
|
readonly pendingGaps: readonly PendingGapEntry[];
|
|
27
|
+
readonly failedEventId: bigint | null;
|
|
27
28
|
};
|
|
28
29
|
export declare function updateConsumerDeliveryOutcome(db: AnyDb, name: string, instanceId: string, outcome: ConsumerDeliveryOutcome): Promise<void>;
|
|
29
30
|
export declare function updateConsumerStatusReturning(db: AnyDb, name: string, instanceId: string, status: "idle" | "disabled"): Promise<Record<string, unknown> | undefined>;
|
|
30
|
-
export declare function advanceConsumerPastEventReturning(db: AnyDb, name: string, instanceId: string, eventId: bigint): Promise<Record<string, unknown> | undefined>;
|
|
31
|
+
export declare function advanceConsumerPastEventReturning(db: AnyDb, name: string, instanceId: string, eventId: bigint, gapToAdd?: PendingGapEntry | null): Promise<Record<string, unknown> | undefined>;
|
|
31
32
|
export declare function resetConsumerForMspRebuild(db: AnyDb, name: string, instanceId: string): Promise<void>;
|
|
32
33
|
export declare function selectConsumerForUpdate(db: AnyDb, name: string, instanceId: string): Promise<Record<string, unknown> | undefined>;
|
|
33
34
|
export declare function updateConsumerRebuildCursor(db: AnyDb, name: string, instanceId: string, lastProcessedEventId: bigint, pendingGaps: readonly PendingGapEntry[]): Promise<void>;
|
|
@@ -167,6 +167,7 @@ export async function updateConsumerDeliveryOutcome(db, name, instanceId, outcom
|
|
|
167
167
|
"rearm_count" = CASE WHEN $5 THEN 0 ELSE "rearm_count" END,
|
|
168
168
|
-- text param + cast: a JS string bound straight to ::jsonb double-encodes under Bun.SQL
|
|
169
169
|
"pending_gaps" = $6::text::jsonb,
|
|
170
|
+
"last_failed_event_id" = $9,
|
|
170
171
|
"updated_at" = now()
|
|
171
172
|
WHERE "name" = $7 AND "instance_id" = $8`, [
|
|
172
173
|
outcome.cursor,
|
|
@@ -177,6 +178,7 @@ export async function updateConsumerDeliveryOutcome(db, name, instanceId, outcom
|
|
|
177
178
|
JSON.stringify(outcome.pendingGaps),
|
|
178
179
|
name,
|
|
179
180
|
instanceId,
|
|
181
|
+
outcome.failedEventId,
|
|
180
182
|
]);
|
|
181
183
|
}
|
|
182
184
|
export async function updateConsumerStatusReturning(db, name, instanceId, status) {
|
|
@@ -185,16 +187,21 @@ export async function updateConsumerStatusReturning(db, name, instanceId, status
|
|
|
185
187
|
RETURNING *`, [status, name, instanceId]));
|
|
186
188
|
return rows[0];
|
|
187
189
|
}
|
|
188
|
-
export async function advanceConsumerPastEventReturning(db, name, instanceId, eventId
|
|
190
|
+
export async function advanceConsumerPastEventReturning(db, name, instanceId, eventId,
|
|
191
|
+
// Ids skipped over between the old cursor and eventId that were not visible yet.
|
|
192
|
+
gapToAdd = null) {
|
|
189
193
|
const rows = (await asRawClient(db).unsafe(`UPDATE "kumiko_event_consumers" SET
|
|
190
194
|
"last_processed_event_id" = $1,
|
|
191
195
|
"status" = 'idle',
|
|
192
196
|
"attempts" = 0,
|
|
193
197
|
"last_error" = NULL,
|
|
194
198
|
"rearm_count" = 0,
|
|
199
|
+
"last_failed_event_id" = NULL,
|
|
200
|
+
-- text param + cast: a JS string bound straight to ::jsonb double-encodes under Bun.SQL
|
|
201
|
+
"pending_gaps" = CASE WHEN $4::text IS NULL THEN "pending_gaps" ELSE "pending_gaps" || $4::text::jsonb END,
|
|
195
202
|
"updated_at" = now()
|
|
196
203
|
WHERE "name" = $2 AND "instance_id" = $3
|
|
197
|
-
RETURNING *`, [eventId, name, instanceId]));
|
|
204
|
+
RETURNING *`, [eventId, name, instanceId, gapToAdd === null ? null : JSON.stringify([gapToAdd])]));
|
|
198
205
|
return rows[0];
|
|
199
206
|
}
|
|
200
207
|
export async function resetConsumerForMspRebuild(db, name, instanceId) {
|
|
@@ -206,6 +213,7 @@ export async function resetConsumerForMspRebuild(db, name, instanceId) {
|
|
|
206
213
|
"attempts" = 0,
|
|
207
214
|
"last_error" = NULL,
|
|
208
215
|
"pending_gaps" = '[]'::jsonb,
|
|
216
|
+
"last_failed_event_id" = NULL,
|
|
209
217
|
"updated_at" = now()`, [name, instanceId]);
|
|
210
218
|
}
|
|
211
219
|
export async function selectConsumerForUpdate(db, name, instanceId) {
|
|
@@ -220,6 +228,7 @@ export async function updateConsumerRebuildCursor(db, name, instanceId, lastProc
|
|
|
220
228
|
"last_error" = NULL,
|
|
221
229
|
-- text param + cast: a JS string bound straight to ::jsonb double-encodes under Bun.SQL
|
|
222
230
|
"pending_gaps" = $2::text::jsonb,
|
|
231
|
+
"last_failed_event_id" = NULL,
|
|
223
232
|
"updated_at" = now()
|
|
224
233
|
WHERE "name" = $3 AND "instance_id" = $4`, [lastProcessedEventId, JSON.stringify(pendingGaps), name, instanceId]);
|
|
225
234
|
}
|
|
@@ -254,6 +263,7 @@ export async function removePendingGapReturning(db, name, instanceId, newPending
|
|
|
254
263
|
"attempts" = 0,
|
|
255
264
|
"last_error" = NULL,
|
|
256
265
|
"rearm_count" = 0,
|
|
266
|
+
"last_failed_event_id" = NULL,
|
|
257
267
|
"updated_at" = now()
|
|
258
268
|
WHERE "name" = $2 AND "instance_id" = $3
|
|
259
269
|
RETURNING *`, [JSON.stringify(newPendingGaps), name, instanceId]));
|
|
@@ -38,6 +38,9 @@ export function validateActionWiring(feature) {
|
|
|
38
38
|
for (const action of screen.toolbarActions ?? []) {
|
|
39
39
|
validateActionNoFunctions(feature.name, screen.id, "toolbarAction", action);
|
|
40
40
|
}
|
|
41
|
+
if (screen.type === "entityList" && screen.expandableRow !== undefined) {
|
|
42
|
+
validateColumnsNoFunctions(feature.name, screen.id, "entityList", screen.expandableRow.columns);
|
|
43
|
+
}
|
|
41
44
|
}
|
|
42
45
|
}
|
|
43
46
|
// EditFieldSpec's visible/readOnly/required are the same FieldCondition DSL
|
|
@@ -4,6 +4,7 @@ export declare function validateConfigKeyBounds(feature: FeatureDefinition): voi
|
|
|
4
4
|
export declare function validateConfigKeyComputed(feature: FeatureDefinition): void;
|
|
5
5
|
export declare function validateConfigKeyRequired(feature: FeatureDefinition): void;
|
|
6
6
|
export declare function validateConfigKeyAllowPerRequest(feature: FeatureDefinition): void;
|
|
7
|
+
export declare function validateConfigKeyOptionsQuery(feature: FeatureDefinition): void;
|
|
7
8
|
export declare function validateConfigKeyBacking(feature: FeatureDefinition): void;
|
|
8
9
|
export declare function validateConfigKeyPiiEncrypted(feature: FeatureDefinition): void;
|
|
9
10
|
export declare function validateConfigReads(features: readonly FeatureDefinition[], allConfigKeys: ReadonlySet<string>): void;
|
|
@@ -101,6 +101,30 @@ export function validateConfigKeyAllowPerRequest(feature) {
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
|
+
// --- Config key optionsQuery compatibility ---
|
|
105
|
+
export function validateConfigKeyOptionsQuery(feature) {
|
|
106
|
+
for (const [keyName, keyDef] of Object.entries(feature.configKeys)) {
|
|
107
|
+
const prefix = `[Feature ${feature.name}] Config key "${keyName}"`;
|
|
108
|
+
if (keyDef.optionsQuery === undefined) {
|
|
109
|
+
if (keyDef.optionsQueryPayload !== undefined) {
|
|
110
|
+
throw new Error(`${prefix} has optionsQueryPayload without optionsQuery`);
|
|
111
|
+
}
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (keyDef.optionsQuery.length === 0) {
|
|
115
|
+
throw new Error(`${prefix} has an empty optionsQuery`);
|
|
116
|
+
}
|
|
117
|
+
if (keyDef.type !== "select") {
|
|
118
|
+
throw new Error(`${prefix} has optionsQuery but type="${keyDef.type}" — optionsQuery is only valid on select keys`);
|
|
119
|
+
}
|
|
120
|
+
if (keyDef.options !== undefined && keyDef.options.length > 0) {
|
|
121
|
+
throw new Error(`${prefix} declares both options and optionsQuery — pick one`);
|
|
122
|
+
}
|
|
123
|
+
if (keyDef.allowPerRequest) {
|
|
124
|
+
throw new Error(`${prefix} has optionsQuery AND allowPerRequest=true — a per-request value would bypass the option set unchecked`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
104
128
|
// --- Config key storage backing × scope matrix ---
|
|
105
129
|
export function validateConfigKeyBacking(feature) {
|
|
106
130
|
for (const [keyName, keyDef] of Object.entries(feature.configKeys)) {
|
|
@@ -16,6 +16,7 @@ export declare function validateEncryptedFields(feature: FeatureDefinition): boo
|
|
|
16
16
|
export declare function validateFileFields(feature: FeatureDefinition): boolean;
|
|
17
17
|
export declare function validateReferenceFields(feature: FeatureDefinition, featureMap: ReadonlyMap<string, FeatureDefinition>, queryHandlers: ReadonlyMap<string, QueryHandlerDef>): void;
|
|
18
18
|
export declare function validateEmbeddedFields(feature: FeatureDefinition, featureMap: ReadonlyMap<string, FeatureDefinition>, queryHandlers: ReadonlyMap<string, QueryHandlerDef>): void;
|
|
19
|
+
export declare function validateEntitySelectOptionsQuery(feature: FeatureDefinition): void;
|
|
19
20
|
export declare function validateMultiSelectFields(feature: FeatureDefinition): void;
|
|
20
21
|
export declare function validateImageVariants(feature: FeatureDefinition): void;
|
|
21
22
|
export declare function validateTransitions(feature: FeatureDefinition): void;
|
|
@@ -593,6 +593,26 @@ function validateCheckboxDisplayOptions(field, fieldName, entityName) {
|
|
|
593
593
|
throw new Error(`MultiSelect field "${fieldName}" on entity "${entityName}" sets maxRows, which is only valid with display: "checkboxes".`);
|
|
594
594
|
}
|
|
595
595
|
}
|
|
596
|
+
export function validateEntitySelectOptionsQuery(feature) {
|
|
597
|
+
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
598
|
+
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
599
|
+
assertNoSelectOptionsQuery(field, `${entityName}.${fieldName}`, feature.name);
|
|
600
|
+
if (field.type !== "embedded")
|
|
601
|
+
continue;
|
|
602
|
+
for (const [subName, subField] of Object.entries(field.schema ?? {})) {
|
|
603
|
+
assertNoSelectOptionsQuery(subField, `${entityName}.${fieldName}.${subName}`, feature.name);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
function assertNoSelectOptionsQuery(field, path, featureName) {
|
|
609
|
+
// @cast-boundary schema-walk — embedded sub-field types don't declare optionsQuery, an untyped author may still set it
|
|
610
|
+
const { optionsQuery, optionsQueryPayload } = field;
|
|
611
|
+
const declaresOptionsQuery = optionsQuery !== undefined || optionsQueryPayload !== undefined;
|
|
612
|
+
if (field.type === "select" && declaresOptionsQuery) {
|
|
613
|
+
throw new Error(`[Feature ${featureName}] Entity select field "${path}" declares optionsQuery — not supported on entity fields because the stored value would not be validated against the query result. Use static options, or a reference field.`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
596
616
|
export function validateMultiSelectFields(feature) {
|
|
597
617
|
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
598
618
|
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
@@ -7,9 +7,9 @@ import { warnOnUniqueAccessRoles } from "./access-roles.js";
|
|
|
7
7
|
import { validateActionWiring, validateFieldWiring } from "./action-wiring.js";
|
|
8
8
|
import { validateApiExposureMatching, validateExtensionUsages } from "./api-ext.js";
|
|
9
9
|
import { validateFeatureBootChecks } from "./boot-check.js";
|
|
10
|
-
import { validateCircularDeps, validateConfigKeyAllowPerRequest, validateConfigKeyBacking, validateConfigKeyBounds, validateConfigKeyComputed, validateConfigKeyPiiEncrypted, validateConfigKeyRequired, validateConfigReads, warnOnToggleableDependencies, } from "./config-deps.js";
|
|
10
|
+
import { validateCircularDeps, validateConfigKeyAllowPerRequest, validateConfigKeyBacking, validateConfigKeyBounds, validateConfigKeyComputed, validateConfigKeyOptionsQuery, validateConfigKeyPiiEncrypted, validateConfigKeyRequired, validateConfigReads, warnOnToggleableDependencies, } from "./config-deps.js";
|
|
11
11
|
import { collectDetailForScreens } from "./detail-screens.js";
|
|
12
|
-
import { validateDerivedFieldCollisions, validateEmbeddedFields, validateEncryptedFields, validateEntityIndexes, validateExtendSchemaCollisions, validateExtensionPreSaveWiring, validateFileFields, validateHandlerAccess, validateImageVariants, validateLocatedTimestamps, validateMultiSelectFields, validateMultiStreamProjections, validateReferenceFields, validateTransitions, } from "./entity-handler.js";
|
|
12
|
+
import { validateDerivedFieldCollisions, validateEmbeddedFields, validateEncryptedFields, validateEntityIndexes, validateEntitySelectOptionsQuery, validateExtendSchemaCollisions, validateExtensionPreSaveWiring, validateFileFields, validateHandlerAccess, validateImageVariants, validateLocatedTimestamps, validateMultiSelectFields, validateMultiStreamProjections, validateReferenceFields, validateTransitions, } from "./entity-handler.js";
|
|
13
13
|
import { validateEntityListScreens } from "./entity-list-screens.js";
|
|
14
14
|
import { validateGdprStoragePersistence } from "./gdpr-storage.js";
|
|
15
15
|
import { validateGlobalTenancyEntities } from "./global-tenancy.js";
|
|
@@ -24,6 +24,7 @@ import { validateQueryRefs } from "./query-refs.js";
|
|
|
24
24
|
import { validateRecordOwnedSubjects } from "./record-owned.js";
|
|
25
25
|
import { collectScreenQns, collectScreensByShortId, validateScreenShortIdCollisions, validateScreens, } from "./screens.js";
|
|
26
26
|
import { warnOnMissingSecurityBaseline } from "./security-baseline.js";
|
|
27
|
+
import { validateExtensionSelectorPanels } from "./selector-owner-panels.js";
|
|
27
28
|
import { validateTransferGraph } from "./transfer-graph.js";
|
|
28
29
|
import { collectWorkspaceQns, resolveNavAllowlist, validateDefaultWorkspaceUniqueness, validateWorkspaces, } from "./workspaces.js";
|
|
29
30
|
export { validateAppCustomScreenWriteQns } from "./custom-screen-write-qns.js";
|
|
@@ -122,6 +123,7 @@ export function validateBoot(rawFeatures, options) {
|
|
|
122
123
|
validatePiiAndRetention(feature);
|
|
123
124
|
validateRecordOwnedSubjects(feature);
|
|
124
125
|
validateApiExposureMatching(feature, allExposedApis, featureMap);
|
|
126
|
+
validateEntitySelectOptionsQuery(feature);
|
|
125
127
|
validateEmbeddedFields(feature, featureMap, queryHandlerQns);
|
|
126
128
|
validateMultiSelectFields(feature);
|
|
127
129
|
validateImageVariants(feature);
|
|
@@ -139,6 +141,7 @@ export function validateBoot(rawFeatures, options) {
|
|
|
139
141
|
validateConfigKeyComputed(feature);
|
|
140
142
|
validateConfigKeyAllowPerRequest(feature);
|
|
141
143
|
validateConfigKeyBacking(feature);
|
|
144
|
+
validateConfigKeyOptionsQuery(feature);
|
|
142
145
|
validateConfigKeyPiiEncrypted(feature);
|
|
143
146
|
validateOwnershipRules(feature, allClaimKeys, knownRoles);
|
|
144
147
|
validateParentRefs(feature, featureMap);
|
|
@@ -168,6 +171,7 @@ export function validateBoot(rawFeatures, options) {
|
|
|
168
171
|
// misleading "no search parameter in its Zod schema" error instead of
|
|
169
172
|
// the clear typo message below.
|
|
170
173
|
validateQueryRefs(features);
|
|
174
|
+
validateExtensionSelectorPanels(features);
|
|
171
175
|
// Must also run after validateQueryRefs — column/field checks below
|
|
172
176
|
// assume every `query` string already resolves to a registered handler.
|
|
173
177
|
validateQueryOutputColumns(features);
|
|
@@ -200,19 +200,24 @@ function validateRelatedListParentFilter(prefix, section, schema, handler, featu
|
|
|
200
200
|
export function validateRelatedListSectionQueries(features) {
|
|
201
201
|
const queryHandlers = buildQueryHandlerMap(features);
|
|
202
202
|
const featureMap = new Map(features.map((f) => [f.name, f]));
|
|
203
|
+
const validateSectionQuery = (prefix, section) => {
|
|
204
|
+
const handler = queryHandlers.get(section.query);
|
|
205
|
+
const schema = handler?.schema;
|
|
206
|
+
validateRelatedListSearchable(prefix, section, schema);
|
|
207
|
+
validateRelatedListFacets(prefix, section, schema);
|
|
208
|
+
validateRelatedListParentFilter(prefix, section, schema, handler, featureMap);
|
|
209
|
+
};
|
|
203
210
|
for (const feature of features) {
|
|
204
211
|
for (const screen of Object.values(feature.screens)) {
|
|
212
|
+
if (screen.type === "entityList" && screen.expandableRow !== undefined) {
|
|
213
|
+
validateSectionQuery(`[Feature ${feature.name}] Screen "${screen.id}" (entityList) expandableRow "${screen.expandableRow.title}"`, screen.expandableRow);
|
|
214
|
+
}
|
|
205
215
|
if (!isEditLayoutScreen(screen))
|
|
206
216
|
continue;
|
|
207
217
|
for (const section of screen.layout.sections) {
|
|
208
218
|
if (section.kind !== "relatedList")
|
|
209
219
|
continue;
|
|
210
|
-
|
|
211
|
-
const handler = queryHandlers.get(section.query);
|
|
212
|
-
const schema = handler?.schema;
|
|
213
|
-
validateRelatedListSearchable(prefix, section, schema);
|
|
214
|
-
validateRelatedListFacets(prefix, section, schema);
|
|
215
|
-
validateRelatedListParentFilter(prefix, section, schema, handler, featureMap);
|
|
220
|
+
validateSectionQuery(`[Feature ${feature.name}] Screen "${screen.id}" (${screen.type}) relatedList section "${section.title}"`, section);
|
|
216
221
|
}
|
|
217
222
|
}
|
|
218
223
|
}
|
|
@@ -62,8 +62,21 @@ function checkProjectionDetailOutputFields(queryHandlers, featureName, screenId,
|
|
|
62
62
|
};
|
|
63
63
|
if (screen.header !== undefined) {
|
|
64
64
|
checkHeaderField("title", screen.header.title);
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
const subtitle = screen.header.subtitle;
|
|
66
|
+
if (typeof subtitle === "string")
|
|
67
|
+
checkHeaderField("subtitle", subtitle);
|
|
68
|
+
else if (subtitle !== undefined) {
|
|
69
|
+
for (const part of subtitle) {
|
|
70
|
+
if (typeof part === "string") {
|
|
71
|
+
checkHeaderField("subtitle", part);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
checkHeaderField("subtitle", part.field);
|
|
75
|
+
if (part.navigate?.entityId !== undefined) {
|
|
76
|
+
checkHeaderField("subtitle", part.navigate.entityId);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
67
80
|
if (screen.header.status !== undefined)
|
|
68
81
|
checkHeaderField("status", screen.header.status);
|
|
69
82
|
}
|
|
@@ -139,6 +152,14 @@ function checkScreenOutputColumns(queryHandlers, featureName, screenId, screen)
|
|
|
139
152
|
if (screen.type === "projectionList") {
|
|
140
153
|
checkProjectionListOutputColumns(queryHandlers, featureName, screenId, screen);
|
|
141
154
|
}
|
|
155
|
+
else if (screen.type === "entityList") {
|
|
156
|
+
if (screen.expandableRow !== undefined) {
|
|
157
|
+
const rowShape = getZodRowShape(queryHandlers.get(screen.expandableRow.query)?.outputSchema);
|
|
158
|
+
for (const column of screen.expandableRow.columns) {
|
|
159
|
+
checkColumnField(rowShape, column, (field) => `[Feature ${featureName}] Screen "${screenId}" (entityList) expandableRow "${screen.expandableRow?.title}" column "${field}" is not present in query "${screen.expandableRow?.query}"'s outputSchema — check for a typo, or add a "label" to mark it a virtual/computed column.`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
142
163
|
else if (screen.type === "projectionDetail") {
|
|
143
164
|
checkProjectionDetailOutputFields(queryHandlers, featureName, screenId, screen);
|
|
144
165
|
checkEditLayoutOutputColumns(queryHandlers, featureName, screenId, "projectionDetail", screen.layout);
|
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
import type { FeatureDefinition } from "../types/index.js";
|
|
1
|
+
import type { FeatureDefinition, QueryHandlerDef } from "../types/index.js";
|
|
2
|
+
export declare function checkQueryRef(queryHandlers: ReadonlyMap<string, QueryHandlerDef>, query: string, buildPrefix: () => string): void;
|
|
2
3
|
export declare function validateQueryRefs(features: readonly FeatureDefinition[]): void;
|
|
@@ -4,7 +4,7 @@ const NOT_REGISTERED_SUFFIX = "is not a registered query-handler. Check the QN s
|
|
|
4
4
|
function isNonEmptyQueryString(value) {
|
|
5
5
|
return typeof value === "string" && value.length > 0;
|
|
6
6
|
}
|
|
7
|
-
function checkQueryRef(queryHandlers, query, buildPrefix) {
|
|
7
|
+
export function checkQueryRef(queryHandlers, query, buildPrefix) {
|
|
8
8
|
// skip: empty/non-string queries get their own message in screens.ts;
|
|
9
9
|
// a registered query has nothing left to check.
|
|
10
10
|
if (!isNonEmptyQueryString(query) || queryHandlers.has(query))
|
|
@@ -44,10 +44,22 @@ function checkDashboardQueryRefs(queryHandlers, featureName, screenId, screen) {
|
|
|
44
44
|
checkQueryRef(queryHandlers, screen.filter.optionsQuery, () => `[Feature ${featureName}] Screen "${screenId}" (dashboard) filter "${screen.filter?.id}"`);
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
|
+
function checkFormFieldQueryRefs(queryHandlers, featureName, screenId, screenType, fields) {
|
|
48
|
+
for (const [fieldName, field] of Object.entries(fields)) {
|
|
49
|
+
if (field.type !== "select" || field.optionsQuery === undefined)
|
|
50
|
+
continue;
|
|
51
|
+
checkQueryRef(queryHandlers, field.optionsQuery, () => `[Feature ${featureName}] Screen "${screenId}" (${screenType}) select field "${fieldName}" optionsQuery`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
47
54
|
function checkScreenQueryRefs(queryHandlers, featureName, screenId, screen) {
|
|
48
55
|
if (screen.type === "projectionList") {
|
|
49
56
|
checkQueryRef(queryHandlers, screen.query, () => `[Feature ${featureName}] Screen "${screenId}" (projectionList)`);
|
|
50
57
|
}
|
|
58
|
+
else if (screen.type === "entityList") {
|
|
59
|
+
if (screen.expandableRow !== undefined) {
|
|
60
|
+
checkQueryRef(queryHandlers, screen.expandableRow.query, () => `[Feature ${featureName}] Screen "${screenId}" (entityList) expandableRow "${screen.expandableRow?.title}"`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
51
63
|
else if (screen.type === "projectionDetail") {
|
|
52
64
|
checkQueryRef(queryHandlers, screen.query, () => `[Feature ${featureName}] Screen "${screenId}" (projectionDetail)`);
|
|
53
65
|
checkEditLayoutQueryRefs(queryHandlers, featureName, screenId, "projectionDetail", screen.layout);
|
|
@@ -57,6 +69,12 @@ function checkScreenQueryRefs(queryHandlers, featureName, screenId, screen) {
|
|
|
57
69
|
screen.type === "configEdit" ||
|
|
58
70
|
screen.type === "secretMint") {
|
|
59
71
|
checkEditLayoutQueryRefs(queryHandlers, featureName, screenId, screen.type, screen.layout);
|
|
72
|
+
if (screen.type !== "entityEdit") {
|
|
73
|
+
checkFormFieldQueryRefs(queryHandlers, featureName, screenId, screen.type, screen.fields);
|
|
74
|
+
}
|
|
75
|
+
if (screen.type === "secretMint" && screen.confirm !== undefined) {
|
|
76
|
+
checkFormFieldQueryRefs(queryHandlers, featureName, screenId, "secretMint confirm", screen.confirm.fields);
|
|
77
|
+
}
|
|
60
78
|
}
|
|
61
79
|
else if (screen.type === "dashboard") {
|
|
62
80
|
checkDashboardQueryRefs(queryHandlers, featureName, screenId, screen);
|
|
@@ -65,6 +83,11 @@ function checkScreenQueryRefs(queryHandlers, featureName, screenId, screen) {
|
|
|
65
83
|
export function validateQueryRefs(features) {
|
|
66
84
|
const queryHandlers = buildQueryHandlerMap(features);
|
|
67
85
|
for (const feature of features) {
|
|
86
|
+
for (const [keyName, keyDef] of Object.entries(feature.configKeys)) {
|
|
87
|
+
if (keyDef.optionsQuery === undefined)
|
|
88
|
+
continue;
|
|
89
|
+
checkQueryRef(queryHandlers, keyDef.optionsQuery, () => `[Feature ${feature.name}] Config key "${keyName}" optionsQuery`);
|
|
90
|
+
}
|
|
68
91
|
for (const [screenId, screen] of Object.entries(feature.screens)) {
|
|
69
92
|
checkScreenQueryRefs(queryHandlers, feature.name, screenId, screen);
|
|
70
93
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { IconKey } from "@cosmicdrift/kumiko-types/nav-icon";
|
|
2
2
|
import type { FeatureDefinition } from "../types/index.js";
|
|
3
|
-
import type { RelatedListToolbarAction, RowAction, RowFieldExtractor, ScreenDefinition, ToolbarAction } from "../types/screen.js";
|
|
3
|
+
import type { DashboardCustomPanel, DashboardScreenPanel, RelatedListToolbarAction, RowAction, RowFieldExtractor, ScreenDefinition, ToolbarAction } from "../types/screen.js";
|
|
4
4
|
export declare function rowFieldExtractorKeys(params: RowFieldExtractor): readonly string[];
|
|
5
5
|
export declare function readsNavigateParamsAsFormPrefill(target: ScreenDefinition, explicitEntityId: string | undefined, sourceScreenEntity: string | undefined): boolean;
|
|
6
6
|
export declare function validateActionHasIcon(featureName: string, screenId: string, screenType: "projectionDetail" | "entityEdit", context: string, action: {
|
|
@@ -20,6 +20,8 @@ export declare function validateScreens(feature: FeatureDefinition, featureMap:
|
|
|
20
20
|
readonly featureName: string;
|
|
21
21
|
readonly screen: ScreenDefinition;
|
|
22
22
|
}>): void;
|
|
23
|
+
export declare function validateDashboardScreenPanel(featureName: string, screenId: string, panel: DashboardScreenPanel, featureMap: ReadonlyMap<string, FeatureDefinition>): void;
|
|
24
|
+
export declare function validateDashboardCustomPanel(featureName: string, screenId: string, panel: DashboardCustomPanel): void;
|
|
23
25
|
export declare function validateColumnRendererForm(featureName: string, screenId: string, column: {
|
|
24
26
|
readonly field: string;
|
|
25
27
|
readonly renderer?: unknown;
|