@cosmicdrift/kumiko-framework 0.372.0 → 0.374.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 (38) hide show
  1. package/agents/examples/rate-limiting.md +65 -1
  2. package/agents/widgets.md +1 -0
  3. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  4. package/dist/engine/boot-validator/entity-handler.js +47 -16
  5. package/dist/engine/boot-validator/projection-list-screens.js +28 -37
  6. package/dist/engine/boot-validator/query-refs.js +26 -8
  7. package/dist/engine/boot-validator/screens.d.ts +1 -1
  8. package/dist/engine/boot-validator/screens.js +327 -41
  9. package/dist/engine/boot-validator/url-prefill-fields.js +3 -0
  10. package/dist/engine/build-app-schema.js +7 -3
  11. package/dist/engine/entity-list-searchability.d.ts +5 -0
  12. package/dist/engine/entity-list-searchability.js +35 -0
  13. package/dist/engine/feature-ast/extractors/handlers.js +1 -1
  14. package/dist/engine/feature-ast/extractors/hooks.js +8 -8
  15. package/dist/engine/feature-ast/pattern-change-schema.d.ts +150 -6
  16. package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
  17. package/dist/engine/feature-ui-extensions.js +5 -2
  18. package/dist/engine/index.d.ts +2 -1
  19. package/dist/engine/index.js +1 -0
  20. package/dist/engine/paged-list.d.ts +29 -0
  21. package/dist/engine/paged-list.js +56 -0
  22. package/dist/engine/project-app-schema-for-roles.js +37 -6
  23. package/dist/engine/types/index.d.ts +3 -3
  24. package/dist/engine/types/index.js +1 -1
  25. package/dist/files/bind-pending-file-refs.d.ts +31 -1
  26. package/dist/files/bind-pending-file-refs.js +80 -6
  27. package/dist/files/index.d.ts +2 -0
  28. package/dist/files/index.js +1 -0
  29. package/dist/files/load-save-file-refs.js +3 -1
  30. package/dist/i18n/required-surface-keys.js +50 -2
  31. package/dist/pipeline/dispatch-shared.js +55 -35
  32. package/dist/rate-limit/index.d.ts +1 -1
  33. package/dist/rate-limit/resolver.d.ts +1 -1
  34. package/dist/rate-limit/resolver.js +104 -2
  35. package/dist/rate-limit/unlimited-resolver.js +1 -0
  36. package/dist/ui-types/index.d.ts +1 -1
  37. package/package.json +5 -5
  38. package/src/changes.json +74 -0
@@ -13,8 +13,29 @@ End-to-end rate limiting: L1 global-IP + L2 auth + L3 handler opt-in.
13
13
  ## What it shows
14
14
 
15
15
  - Handler `rateLimit: { per, limit, windowSeconds }`
16
+ - Several buckets on one handler: `rateLimit: [{ per: "user", ... }, { per: "tenant+handler", ... }]`
16
17
  - How L1/L2 from `buildServer` stack with L3
17
18
 
19
+ ## Combined buckets
20
+
21
+ Pass a list to enforce several budgets on one handler, for example a per-caller and a per-tenant cap:
22
+
23
+ ```ts illustration
24
+ r.writeHandler({
25
+ // ...
26
+ rateLimit: [
27
+ { per: "user", limit: 2, windowSeconds: 3600 },
28
+ { per: "tenant+handler", limit: 3, windowSeconds: 3600 },
29
+ ],
30
+ });
31
+ ```
32
+
33
+ Each bucket applies on its own. The check is all-or-nothing: if any bucket is exhausted the call
34
+ answers 429 `rate_limited` for that bucket (the one with the longest wait when several are
35
+ exhausted) and no bucket is charged. Each `per` may appear once; an empty list fails at boot.
36
+ An anonymous handler needs every bucket to be ip-keyed, and `{ disabled: true, reason }`
37
+ stays a single declaration.
38
+
18
39
  ## Per-payload limits
19
40
 
20
41
  A public write handler that mails an address typed by the caller is also limited per
@@ -67,7 +88,8 @@ bun test
67
88
  // in each layer's test.
68
89
 
69
90
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
70
- import { TestUsers } from "@cosmicdrift/kumiko-framework/seeding";
91
+ import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
92
+ import { createTestUser, TestUsers } from "@cosmicdrift/kumiko-framework/seeding";
71
93
  import { setupTestStack, type TestStack } from "@cosmicdrift/kumiko-testing/stack";
72
94
  import { createRateLimitShowcaseFeature } from "../feature";
73
95
 
@@ -130,6 +152,35 @@ describe("L3 — handler opt-in (per-user budget on expensive-search)", () => {
130
152
  });
131
153
  });
132
154
 
155
+ describe("L3 — combined buckets (per-user and per-tenant budget on submit-report)", () => {
156
+ const submitReport = "rl-showcase:write:submit-report";
157
+
158
+ async function rejectedBucket(user: SessionUser): Promise<string | undefined> {
159
+ const res = await stack.http.write(submitReport, {}, user);
160
+ expect(res.status).toBe(429);
161
+ const body = (await res.json()) as { error: { details?: { bucket?: string } } };
162
+ return body.error.details?.bucket;
163
+ }
164
+
165
+ test("the user bucket rejects first; the tenant bucket only counts accepted requests", async () => {
166
+ await stack.http.writeOk(submitReport, {}, admin);
167
+ await stack.http.writeOk(submitReport, {}, admin);
168
+ expect(await rejectedBucket(admin)).toBe("user");
169
+
170
+ // The rejected third call did not touch the tenant bucket: one of its 3 tokens is left.
171
+ await stack.http.writeOk(submitReport, {}, TestUsers.user);
172
+ expect(await rejectedBucket(TestUsers.user)).toBe("tenant+handler");
173
+ });
174
+
175
+ test("a caller without Admin or User role is rejected with access_denied before any bucket is charged", async () => {
176
+ const outsider = createTestUser({ id: 9, tenantId: admin.tenantId, roles: ["Guest"] });
177
+ const res = await stack.http.write(submitReport, {}, outsider);
178
+ expect(res.status).toBe(403);
179
+ const body = (await res.json()) as { error: { code: string } };
180
+ expect(body.error.code).toBe("access_denied");
181
+ });
182
+ });
183
+
133
184
  describe("L1 — global IP middleware (covers every /api/* request)", () => {
134
185
  test("21st request from same IP returns 429 with l1:ip bucket", async () => {
135
186
  // L1 sits ahead of auth + every /api/* route. Target an authless,
@@ -205,6 +256,19 @@ export function createRateLimitShowcaseFeature(): FeatureDefinition {
205
256
  access: { roles: ["Admin", "User"] },
206
257
  rateLimit: { per: "user", limit: 3, windowSeconds: 60 },
207
258
  });
259
+
260
+ // Combined budget: each caller gets 2 submits per hour and the tenant 3 per hour in
261
+ // total. Both buckets are checked together; a request one bucket rejects charges neither.
262
+ r.writeHandler({
263
+ name: "submit-report",
264
+ schema: z.object({}),
265
+ handler: async () => ({ isSuccess: true as const, data: {} }),
266
+ access: { roles: ["Admin", "User"] },
267
+ rateLimit: [
268
+ { per: "user", limit: 2, windowSeconds: 3600 },
269
+ { per: "tenant+handler", limit: 3, windowSeconds: 3600 },
270
+ ],
271
+ });
208
272
  });
209
273
  }
210
274
  ```
package/agents/widgets.md CHANGED
@@ -118,6 +118,7 @@ Value exports of `@cosmicdrift/kumiko-renderer-web`.
118
118
  | `useDraft` | Formular-State für Rechner-Screens: ein Draft-Objekt + `patch` für Teil-Updates + `field(name)` das die `{ id, name, value, onChange }`-Props fertig für die Feld-Widgets (NumberField/MoneyField/PercentField) liefert. |
119
119
  | `useForm` | |
120
120
  | `useIsNarrowViewport` | |
121
+ | `useIsViewportBelow` | |
121
122
  | `useLiveEvents` | Hook für useQuery-like Consumer. |
122
123
  | `useLocale` | Liefert den aktuellen LocaleResolver und abonniert automatisch Locale-Änderungen — der aufrufende Component re-rendert sobald die Sprache gewechselt wird. |
123
124
  | `useNav` | |
@@ -10,6 +10,7 @@ export declare function validateAdditionalRateLimits(featureName: string, handle
10
10
  export declare function validatePerCommandTransaction(featureName: string, handlerName: string, handler: FeatureDefinition["writeHandlers"][string]): void;
11
11
  export declare function validateTenantlessAnonymous(featureName: string, handlerName: string, handler: FeatureDefinition["writeHandlers"][string]): void;
12
12
  export declare function validateRateLimitDisabledReason(featureName: string, kind: "write" | "query" | "stream", handlerName: string, rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"]): void;
13
+ export declare function validateRateLimitBucketList(featureName: string, kind: "write" | "query" | "stream", handlerName: string, rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"]): void;
13
14
  export declare function validateNoAllRoleInHandlerAccess(featureName: string, kind: "write" | "query" | "stream", handlerName: string, access: NonNullable<FeatureDefinition["writeHandlers"][string]["access"]>): void;
14
15
  export declare function validateAnonymousRateLimit(featureName: string, kind: "write" | "query" | "stream", handlerName: string, access: NonNullable<FeatureDefinition["writeHandlers"][string]["access"]>, rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"]): void;
15
16
  export declare function validateMultiStreamProjections(feature: FeatureDefinition): void;
@@ -1,5 +1,5 @@
1
1
  import { SELECT_OPTION_TONES } from "@cosmicdrift/kumiko-types/fields";
2
- import { isRateLimitDisabled } from "@cosmicdrift/kumiko-types/handlers";
2
+ import { declaredRateLimitOptions, isRateLimitDisabled } from "@cosmicdrift/kumiko-types/handlers";
3
3
  import { ZodObject } from "zod";
4
4
  import { VARIANT_NAME_PATTERN } from "../../derivatives/variant-key.js";
5
5
  import { access as accessPresets } from "../config-helpers.js";
@@ -133,6 +133,7 @@ export function validateHandlerAccess(feature) {
133
133
  `Set { roles: [...] } for role-based access, or { openToAll: { reason: "..." } } for any authenticated user.`);
134
134
  }
135
135
  validateNoAllRoleInHandlerAccess(feature.name, kind, name, handler.access);
136
+ validateRateLimitBucketList(feature.name, kind, name, handler.rateLimit);
136
137
  validateAnonymousRateLimit(feature.name, kind, name, handler.access, handler.rateLimit);
137
138
  validateRateLimitDisabledReason(feature.name, kind, name, handler.rateLimit);
138
139
  }
@@ -158,8 +159,17 @@ export function validateAdditionalRateLimits(featureName, handlerName, handler)
158
159
  }
159
160
  validateAnonymousHasIpBucket(where, handler);
160
161
  const shape = handler.schema instanceof ZodObject ? handler.schema.shape : undefined;
162
+ const seenFields = new Set();
161
163
  for (const entry of additionalRateLimits) {
162
164
  validatePayloadBucket(where, shape, entry);
165
+ const field = entry.per.payloadField;
166
+ // Two entries on one field hash the same value into the same Redis key, so enforceAll
167
+ // would deduct only one of the costs.
168
+ if (seenFields.has(field)) {
169
+ throw new Error(`${where} with payloadField "${field}" more than once — both entries would share one ` +
170
+ `Redis bucket per value. Keep one entry per payloadField.`);
171
+ }
172
+ seenFields.add(field);
163
173
  }
164
174
  }
165
175
  function validateAnonymousHasIpBucket(where, handler) {
@@ -168,9 +178,7 @@ function validateAnonymousHasIpBucket(where, handler) {
168
178
  // skip: only anonymous callers need an ip bucket
169
179
  if (!allowsAnonymous)
170
180
  return;
171
- const hasIpBucket = rateLimit !== undefined &&
172
- !isRateLimitDisabled(rateLimit) &&
173
- IP_BUCKETED_RATE_LIMIT_PER.has(rateLimit.per);
181
+ const hasIpBucket = declaredRateLimitOptions(rateLimit).some((option) => IP_BUCKETED_RATE_LIMIT_PER.has(option.per));
174
182
  if (!hasIpBucket) {
175
183
  throw new Error(`${where} and allows anonymous callers, but has no ip-keyed rateLimit ` +
176
184
  `(per: "ip" or "ip+handler", not disabled). Payload buckets only complement the IP bucket. Set rateLimit: { per: "ip", ... } on the handler.`);
@@ -217,13 +225,16 @@ export function validateTenantlessAnonymous(featureName, handlerName, handler) {
217
225
  throw new Error(`${where} but its access is not exactly { roles: ["anonymous"] } — a tenantless request ` +
218
226
  `carries no principal beyond the anonymous visitor. Set access to { roles: ["anonymous"] } or remove tenantlessAnonymous.`);
219
227
  }
220
- if (!handler.rateLimit || isRateLimitDisabled(handler.rateLimit)) {
228
+ const options = declaredRateLimitOptions(handler.rateLimit);
229
+ if (options.length === 0) {
221
230
  throw new Error(`${where} without a rateLimit — a handler reachable on hosts that resolve no tenant ` +
222
231
  `needs a real upper bound (rateLimit: { per: "ip", ... }); { disabled: true } does not count.`);
223
232
  }
224
- if (!IP_SCOPED_RATE_LIMIT_PER.includes(handler.rateLimit.per)) {
225
- throw new Error(`${where} with rateLimit.per "${handler.rateLimit.per}" — every anonymous visitor shares the same ` +
226
- `tenant/user bucket, so one caller could exhaust it for all. Use ${IP_SCOPED_RATE_LIMIT_PER.map((per) => `"${per}"`).join(" or ")}.`);
233
+ for (const { per } of options) {
234
+ if (IP_SCOPED_RATE_LIMIT_PER.includes(per))
235
+ continue;
236
+ throw new Error(`${where} with rateLimit.per "${per}" — every anonymous visitor shares the same ` +
237
+ `tenant/user bucket, so one caller could exhaust it for all. Use ${IP_SCOPED_RATE_LIMIT_PER.map((allowed) => `"${allowed}"`).join(" or ")}.`);
227
238
  }
228
239
  }
229
240
  export function validateRateLimitDisabledReason(featureName, kind, handlerName, rateLimit) {
@@ -236,6 +247,23 @@ export function validateRateLimitDisabledReason(featureName, kind, handlerName,
236
247
  `be rate-limited.`);
237
248
  }
238
249
  }
250
+ export function validateRateLimitBucketList(featureName, kind, handlerName, rateLimit) {
251
+ // skip: a single option or a disabled declaration has no bucket list to check
252
+ if (!Array.isArray(rateLimit))
253
+ return;
254
+ const where = `${kind} handler "${featureName}:${kind}:${handlerName}"`;
255
+ if (rateLimit.length === 0) {
256
+ throw new Error(`${where} declares rateLimit as an empty array — declare at least one bucket or drop the option.`);
257
+ }
258
+ const seen = new Set();
259
+ for (const { per } of rateLimit) {
260
+ if (seen.has(per)) {
261
+ throw new Error(`${where} declares rateLimit with per "${per}" more than once — both entries would share one ` +
262
+ `Redis bucket with conflicting limits. Keep one entry per "per".`);
263
+ }
264
+ seen.add(per);
265
+ }
266
+ }
239
267
  // No session ever carries the role "all" — `roles: ["all"]` is unreachable dead config, not a wildcard.
240
268
  export function validateNoAllRoleInHandlerAccess(featureName, kind, handlerName, access) {
241
269
  // skip: openToAll has no roles list to check
@@ -260,19 +288,22 @@ export function validateAnonymousRateLimit(featureName, kind, handlerName, acces
260
288
  // skip: disabled declarations carry no `.per` to bucket on
261
289
  if (isRateLimitDisabled(rateLimit))
262
290
  return;
263
- if (!rateLimit) {
291
+ const options = declaredRateLimitOptions(rateLimit);
292
+ if (options.length === 0) {
264
293
  throw new Error(`${kind} handler "${featureName}:${kind}:${handlerName}" allows anonymous callers but declares no ` +
265
294
  `rateLimit — an anonymous, internet-facing endpoint needs an upper bound. Set rateLimit: ` +
266
295
  `{ per: "ip", limit: ..., windowSeconds: ... }, or the documented exception ` +
267
296
  `rateLimit: { disabled: true, reason: "..." }.`);
268
297
  }
269
- // skip: rate-limit is already keyed on something safe (ip / tenant)
270
- if (!USER_BUCKETED_RATE_LIMIT_PER.has(rateLimit.per))
271
- return;
272
- throw new Error(`${kind} handler "${featureName}:${kind}:${handlerName}" allows anonymous callers but uses ` +
273
- `rateLimit.per="${rateLimit.per}" — every anonymous request shares user.id="anonymous", ` +
274
- `so this bucket would be a single global tap any caller could drain. ` +
275
- `Use rateLimit.per="ip" or "ip+handler" for anonymous endpoints.`);
298
+ for (const { per } of options) {
299
+ // skip: this bucket is keyed on something safe (ip / tenant)
300
+ if (!USER_BUCKETED_RATE_LIMIT_PER.has(per))
301
+ continue;
302
+ throw new Error(`${kind} handler "${featureName}:${kind}:${handlerName}" allows anonymous callers but uses ` +
303
+ `rateLimit.per="${per}" — every anonymous request shares user.id="anonymous", ` +
304
+ `so this bucket would be a single global tap any caller could drain. ` +
305
+ `Use rateLimit.per="ip" or "ip+handler" for anonymous endpoints.`);
306
+ }
276
307
  }
277
308
  // --- MultiStreamProjection delivery-invariant ---
278
309
  //
@@ -1,5 +1,5 @@
1
- import { ENTITY_CONVENTION_QUERY_BRAND } from "@cosmicdrift/kumiko-types/handlers";
2
- import { parseQn, QnTypes, qualifyEntityName } from "../qualified-name.js";
1
+ import { isListQueryWithoutSearchableFields, resolveEntityBehindListQuery, } from "../entity-list-searchability.js";
2
+ import { QnTypes, qualifyEntityName } from "../qualified-name.js";
3
3
  import { isEditLayoutScreen } from "../screen-helpers.js";
4
4
  import { relatedListGroupHeaderLabel, relatedListGroupKey, } from "../types/index.js";
5
5
  import { SEARCHABLE_FALSE_WHITELIST } from "./entity-list-screens.js";
@@ -28,28 +28,38 @@ function schemaAccepts(schema, key) {
28
28
  const shape = getZodObjectShape(schema);
29
29
  return shape !== undefined && key in shape;
30
30
  }
31
- function validateOneProjectionListScreen(feature, screen, queryHandlers) {
31
+ // sortable/paginated are derived by buildAppSchema from the query's Zod
32
+ // schema (fw#2165) — there is no separate wire type from the author-facing
33
+ // ProjectionListScreenDefinition, so hand-authoring them would otherwise be
34
+ // silently overwritten with no signal to the author. Reject outright.
35
+ function rejectHandAuthoredDerivedFlags(prefix, screen) {
36
+ if (screen.sortable !== undefined) {
37
+ throw new Error(`${prefix}: sortable is derived from the query's Zod schema, don't set it`);
38
+ }
39
+ if (screen.paginated !== undefined) {
40
+ throw new Error(`${prefix}: paginated is derived from the query's Zod schema, don't set it`);
41
+ }
42
+ }
43
+ function validateOneProjectionListScreen(feature, screen, queryHandlers, featureMap) {
32
44
  const prefix = `[projectionList] Feature "${feature.name}" screen "${screen.id}"`;
33
- const schema = queryHandlers.get(screen.query)?.schema;
45
+ const handler = queryHandlers.get(screen.query);
46
+ const schema = handler?.schema;
47
+ // buildAppSchema derives searchable: false for these, so no whitelist entry is needed.
48
+ const searchDerivedOff = isListQueryWithoutSearchableFields(screen.query, handler, (name) => findEntity(featureMap, name));
34
49
  if (screen.searchable === true && !schemaAccepts(schema, "search")) {
35
50
  throw new Error(`${prefix}: searchable: true but query "${screen.query}" has no "search" parameter in its Zod schema. Add a "search" field to the query schema or remove searchable: true.`);
36
51
  }
52
+ if (screen.searchable === true && searchDerivedOff) {
53
+ throw new Error(`${prefix}: searchable: true but query "${screen.query}" lists an entity with no searchable field, so a search would never match. Mark a field searchable: true on the entity or remove searchable: true.`);
54
+ }
37
55
  if (screen.searchable === false &&
38
56
  schemaAccepts(schema, "search") &&
57
+ !searchDerivedOff &&
39
58
  !SEARCHABLE_FALSE_WHITELIST.has(screen.id)) {
40
59
  throw new Error(`${prefix}: query "${screen.query}" accepts "search" but searchable: false disables it — remove searchable: false or add "${screen.id}" to SEARCHABLE_FALSE_WHITELIST`);
41
60
  }
42
- // sortable/paginated are derived by buildAppSchema from the query's Zod
43
- // schema (fw#2165) — there is no separate wire type from the author-facing
44
- // ProjectionListScreenDefinition, so hand-authoring them would otherwise be
45
- // silently overwritten with no signal to the author. Reject outright.
46
- if (screen.sortable !== undefined) {
47
- throw new Error(`${prefix}: sortable is derived from the query's Zod schema, don't set it`);
48
- }
49
- if (screen.paginated !== undefined) {
50
- throw new Error(`${prefix}: paginated is derived from the query's Zod schema, don't set it`);
51
- }
52
- const searchActive = screen.searchable !== false && schemaAccepts(schema, "search");
61
+ rejectHandAuthoredDerivedFlags(prefix, screen);
62
+ const searchActive = screen.searchable !== false && !searchDerivedOff && schemaAccepts(schema, "search");
53
63
  const sortActive = schemaAccepts(schema, "sort");
54
64
  if ((searchActive || sortActive) && screen.defaultSort === undefined) {
55
65
  throw new Error(`${prefix}: defaultSort required when search or sort is active. Set defaultSort: { field, direction }.`);
@@ -132,11 +142,12 @@ function validateProjectionListFacetsSchemaAcceptance(prefix, screen, schema) {
132
142
  }
133
143
  export function validateProjectionListScreens(features) {
134
144
  const queryHandlers = buildQueryHandlerMap(features);
145
+ const featureMap = new Map(features.map((f) => [f.name, f]));
135
146
  for (const feature of features) {
136
147
  for (const screen of Object.values(feature.screens)) {
137
148
  if (screen.type !== "projectionList")
138
149
  continue;
139
- validateOneProjectionListScreen(feature, screen, queryHandlers);
150
+ validateOneProjectionListScreen(feature, screen, queryHandlers, featureMap);
140
151
  }
141
152
  }
142
153
  }
@@ -155,26 +166,6 @@ function validateRelatedListFacets(prefix, section, schema) {
155
166
  return;
156
167
  throw new Error(`${prefix}: declares facets but query "${section.query}" has no "filters" parameter in its Zod schema — add filters: z.array(z.object({ field: z.string(), op: z.literal("in"), value: z.unknown() })).optional() (or reuse entityListSchema's shape) to the handler's schema.`);
157
168
  }
158
- // parentFilter resolves an entity field map to
159
- // check `field` against ONLY when the query is one of the entity-convention
160
- // factories (defineEntityListHandler et al., branded by
161
- // ENTITY_CONVENTION_QUERY_BRAND) — a hand-written handler carries no entity,
162
- // same "capability absent, no throw" policy as schemaAccepts above. The
163
- // entity name comes from the handler's own `<entityName>:list` naming
164
- // convention (parseHandlerName in entity-handlers.ts), recovered here from
165
- // the query's fully qualified name.
166
- function resolveEntityBehindQuery(queryQn, handler, featureMap) {
167
- if (handler === undefined || handler[ENTITY_CONVENTION_QUERY_BRAND] !== true)
168
- return undefined;
169
- let entityName;
170
- try {
171
- entityName = parseQn(queryQn).name.split(":")[0] ?? "";
172
- }
173
- catch {
174
- return undefined;
175
- }
176
- return entityName === "" ? undefined : findEntity(featureMap, entityName);
177
- }
178
169
  // parentFilter lets a relatedList reuse the
179
170
  // generic `<entity>:list` query instead of a bespoke child-rows handler —
180
171
  // same 422 footgun as facets/filter above, plus its own mutual-exclusivity
@@ -193,7 +184,7 @@ function validateRelatedListParentFilter(prefix, section, schema, handler, featu
193
184
  `Zod schema — add filter: z.object({ field: z.string(), op: z.enum(["eq","ne","lt","gt","lte","gte","in"]), ` +
194
185
  `value: z.unknown() }).optional() (or reuse entityListSchema's shape) to the handler's schema.`);
195
186
  }
196
- const entity = resolveEntityBehindQuery(section.query, handler, featureMap);
187
+ const entity = resolveEntityBehindListQuery(section.query, handler, (name) => findEntity(featureMap, name));
197
188
  if (entity !== undefined && entity.fields[section.parentFilter.field] === undefined) {
198
189
  throw new Error(`${prefix}: parentFilter.field "${section.parentFilter.field}" is not a declared field on the ` +
199
190
  `entity behind query "${section.query}". Known fields: ${Object.keys(entity.fields).sort().join(", ")}.`);
@@ -1,3 +1,4 @@
1
+ import { isFieldsEditSection, normalizeEditField, sectionFieldSpecs } from "../screen-helpers.js";
1
2
  import { buildQueryHandlerMap } from "./projection-list-screens.js";
2
3
  const NOT_REGISTERED_SUFFIX = "is not a registered query-handler. Check the QN spelling (expected " +
3
4
  '"<feature>:query:<short>") and that the handler is declared via r.queryHandler(...).';
@@ -29,19 +30,30 @@ function checkVisibleWhenQueryRef(queryHandlers, query, buildPrefix) {
29
30
  // types) keeps this collector correct if that restriction is ever lifted.
30
31
  function checkEditLayoutQueryRefs(queryHandlers, featureName, screenId, screenType, layout) {
31
32
  for (const section of layout.sections) {
33
+ if (isFieldsEditSection(section)) {
34
+ for (const fieldSpec of sectionFieldSpecs(section)) {
35
+ const { field, fillAction } = normalizeEditField(fieldSpec);
36
+ if (fillAction === undefined)
37
+ continue;
38
+ checkQueryRef(queryHandlers, fillAction.query, () => `[Feature ${featureName}] Screen "${screenId}" (${screenType}) field "${field}" fillAction`);
39
+ }
40
+ }
32
41
  if (section.kind !== "relatedList")
33
42
  continue;
34
43
  checkQueryRef(queryHandlers, section.query, () => `[Feature ${featureName}] Screen "${screenId}" (${screenType}) relatedList section "${section.title}"`);
35
44
  }
36
45
  }
37
46
  function checkDashboardQueryRefs(queryHandlers, featureName, screenId, screen) {
47
+ if (screen.record?.query !== undefined) {
48
+ checkQueryRef(queryHandlers, screen.record.query, () => `[Feature ${featureName}] Screen "${screenId}" (dashboard) record`);
49
+ }
38
50
  for (const panel of screen.panels) {
39
51
  if (panel.kind === "custom")
40
52
  continue;
41
53
  if (panel.visibleWhen !== undefined) {
42
54
  checkVisibleWhenQueryRef(queryHandlers, panel.visibleWhen.query, () => `[Feature ${featureName}] Screen "${screenId}" (dashboard) ${panel.kind === "screen" ? "screen-panel" : "panel"} "${panel.id}" visibleWhen`);
43
55
  }
44
- if (panel.kind === "screen")
56
+ if (panel.kind === "screen" || panel.kind === "text")
45
57
  continue;
46
58
  if (panel.kind === "stat-group") {
47
59
  for (const stat of panel.stats) {
@@ -70,6 +82,18 @@ function checkFormFieldQueryRefs(queryHandlers, featureName, screenId, screenTyp
70
82
  }
71
83
  }
72
84
  }
85
+ function checkEditFormScreenQueryRefs(queryHandlers, featureName, screenId, screen) {
86
+ checkEditLayoutQueryRefs(queryHandlers, featureName, screenId, screen.type, screen.layout);
87
+ if (screen.type === "actionForm" && screen.load !== undefined) {
88
+ checkQueryRef(queryHandlers, screen.load.query, () => `[Feature ${featureName}] Screen "${screenId}" (actionForm) load`);
89
+ }
90
+ if (screen.type !== "entityEdit") {
91
+ checkFormFieldQueryRefs(queryHandlers, featureName, screenId, screen.type, screen.fields);
92
+ }
93
+ if (screen.type === "secretMint" && screen.confirm !== undefined) {
94
+ checkFormFieldQueryRefs(queryHandlers, featureName, screenId, "secretMint confirm", screen.confirm.fields);
95
+ }
96
+ }
73
97
  function checkScreenQueryRefs(queryHandlers, featureName, screenId, screen) {
74
98
  if (screen.visibleWhen !== undefined) {
75
99
  checkVisibleWhenQueryRef(queryHandlers, screen.visibleWhen.query, () => `[Feature ${featureName}] Screen "${screenId}" (${screen.type}) visibleWhen`);
@@ -90,13 +114,7 @@ function checkScreenQueryRefs(queryHandlers, featureName, screenId, screen) {
90
114
  screen.type === "actionForm" ||
91
115
  screen.type === "configEdit" ||
92
116
  screen.type === "secretMint") {
93
- checkEditLayoutQueryRefs(queryHandlers, featureName, screenId, screen.type, screen.layout);
94
- if (screen.type !== "entityEdit") {
95
- checkFormFieldQueryRefs(queryHandlers, featureName, screenId, screen.type, screen.fields);
96
- }
97
- if (screen.type === "secretMint" && screen.confirm !== undefined) {
98
- checkFormFieldQueryRefs(queryHandlers, featureName, screenId, "secretMint confirm", screen.confirm.fields);
99
- }
117
+ checkEditFormScreenQueryRefs(queryHandlers, featureName, screenId, screen);
100
118
  }
101
119
  else if (screen.type === "dashboard") {
102
120
  checkDashboardQueryRefs(queryHandlers, featureName, screenId, screen);
@@ -3,7 +3,7 @@ import type { ConfigKeyDefinition, FeatureDefinition } from "../types/index.ts";
3
3
  import type { ActionFormScreenDefinition, DashboardCustomPanel, DashboardScreenPanel, RelatedListToolbarAction, RowAction, RowFieldExtractor, ScreenDefinition, SecretMintScreenDefinition, ToolbarAction } from "../types/screen.ts";
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
- export declare function validateActionHasIcon(featureName: string, screenId: string, screenType: "projectionDetail" | "entityEdit" | "entityList" | "projectionList", context: string, action: {
6
+ export declare function validateActionHasIcon(featureName: string, screenId: string, screenType: "projectionDetail" | "entityEdit" | "entityList" | "projectionList" | "dashboard", context: string, action: {
7
7
  readonly id: string;
8
8
  readonly icon?: IconKey;
9
9
  }): void;