@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.
Files changed (49) hide show
  1. package/dist/db/blind-index-cleanup.d.ts +1 -0
  2. package/dist/db/blind-index-cleanup.js +12 -0
  3. package/dist/db/index.d.ts +1 -1
  4. package/dist/db/index.js +1 -1
  5. package/dist/db/queries/event-consumer.d.ts +2 -1
  6. package/dist/db/queries/event-consumer.js +12 -2
  7. package/dist/engine/boot-validator/action-wiring.js +3 -0
  8. package/dist/engine/boot-validator/config-deps.d.ts +1 -0
  9. package/dist/engine/boot-validator/config-deps.js +24 -0
  10. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  11. package/dist/engine/boot-validator/entity-handler.js +20 -0
  12. package/dist/engine/boot-validator/index.js +6 -2
  13. package/dist/engine/boot-validator/projection-list-screens.js +11 -6
  14. package/dist/engine/boot-validator/query-output-columns.js +23 -2
  15. package/dist/engine/boot-validator/query-refs.d.ts +2 -1
  16. package/dist/engine/boot-validator/query-refs.js +24 -1
  17. package/dist/engine/boot-validator/screens.d.ts +3 -1
  18. package/dist/engine/boot-validator/screens.js +180 -78
  19. package/dist/engine/boot-validator/selector-owner-panels.d.ts +2 -0
  20. package/dist/engine/boot-validator/selector-owner-panels.js +25 -0
  21. package/dist/engine/boot-validator/url-prefill-fields.js +18 -1
  22. package/dist/engine/build-app-schema.js +3 -0
  23. package/dist/engine/build-config-feature-schema.js +40 -6
  24. package/dist/engine/changeset-changes.js +6 -0
  25. package/dist/engine/config-helpers.d.ts +2 -0
  26. package/dist/engine/config-helpers.js +9 -0
  27. package/dist/engine/entity-handlers.js +1 -1
  28. package/dist/engine/feature-ui-extensions.d.ts +4 -2
  29. package/dist/engine/feature-ui-extensions.js +40 -3
  30. package/dist/engine/index.d.ts +1 -1
  31. package/dist/engine/project-app-schema-for-roles.js +30 -2
  32. package/dist/engine/registry-facade.js +3 -0
  33. package/dist/engine/registry-ingest.js +2 -0
  34. package/dist/engine/registry-state.d.ts +2 -1
  35. package/dist/engine/registry-state.js +1 -0
  36. package/dist/engine/schema-builder.d.ts +3 -1
  37. package/dist/engine/schema-builder.js +5 -6
  38. package/dist/engine/types/index.d.ts +2 -2
  39. package/dist/errors/reasons.js +7 -6
  40. package/dist/i18n/required-surface-keys.js +27 -10
  41. package/dist/pipeline/dispatch-write.js +26 -2
  42. package/dist/pipeline/event-consumer-state.js +6 -0
  43. package/dist/pipeline/event-dispatcher-admin.js +36 -16
  44. package/dist/pipeline/event-dispatcher-delivery.d.ts +2 -0
  45. package/dist/pipeline/event-dispatcher-delivery.js +22 -2
  46. package/dist/pipeline/event-dispatcher.js +1 -0
  47. package/dist/ui-types/index.d.ts +1 -1
  48. package/package.json +4 -4
  49. 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
+ }
@@ -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
- const prefix = `[Feature ${feature.name}] Screen "${screen.id}" (${screen.type}) relatedList section "${section.title}"`;
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
- if (screen.header.subtitle !== undefined)
66
- checkHeaderField("subtitle", screen.header.subtitle);
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;