@cosmicdrift/kumiko-framework 0.208.3 → 0.209.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.208.3",
3
+ "version": "0.209.1",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -190,7 +190,7 @@
190
190
  "./package.json": "./package.json"
191
191
  },
192
192
  "dependencies": {
193
- "@cosmicdrift/kumiko-types": "0.208.3",
193
+ "@cosmicdrift/kumiko-types": "0.209.1",
194
194
  "bullmq": "^5.76.7",
195
195
  "bun-types": "^1.3.13",
196
196
  "hono": "^4.13.1",
@@ -206,7 +206,7 @@
206
206
  "zod": "^4.4.3"
207
207
  },
208
208
  "devDependencies": {
209
- "@cosmicdrift/kumiko-dispatcher-live": "0.208.3",
209
+ "@cosmicdrift/kumiko-dispatcher-live": "0.209.1",
210
210
  "bun-types": "^1.3.13",
211
211
  "pino-pretty": "^13.1.3"
212
212
  },
@@ -141,7 +141,7 @@ export async function backfillEventPiiEncryption(
141
141
  result.erasedFields += outcome.erased;
142
142
  if (!options.dryRun) {
143
143
  await raw.unsafe(`UPDATE "kumiko_events" SET "payload" = $1::jsonb WHERE "id" = $2`, [
144
- JSON.stringify(outcome.payload),
144
+ outcome.payload,
145
145
  row.id,
146
146
  ]);
147
147
  }
@@ -238,4 +238,208 @@ describe("validateBoot — projectionList screens", () => {
238
238
  );
239
239
  });
240
240
  });
241
+
242
+ // fw#2224: filter/facets are new on ProjectionListScreenDefinition — no
243
+ // entity to check field-existence against, so validation is either
244
+ // structural (filter) or checked against the declared columns (facets),
245
+ // plus the same "does the bound query's schema actually accept this
246
+ // param" check fw#2165 already does for search/sort.
247
+ describe("projectionList filter + facets (fw#2224)", () => {
248
+ test('filter.op "in" requires an array value', () => {
249
+ const feature = defineFeature("ledger", (r) => {
250
+ r.queryHandler(
251
+ "schedule:list",
252
+ z.object({ filter: z.unknown().optional() }),
253
+ async () => ({ rows: [], nextCursor: null }),
254
+ { access: { openToAll: true } },
255
+ );
256
+ r.screen({
257
+ id: "schedule-list",
258
+ type: "projectionList",
259
+ query: "ledger:query:schedule:list",
260
+ columns: ["description"],
261
+ filter: { field: "status", op: "in", value: "not-an-array" },
262
+ });
263
+ r.translations({
264
+ keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
265
+ });
266
+ });
267
+ expect(() => validateBoot([feature])).toThrow(/filter\.op "in" requires/);
268
+ });
269
+
270
+ test('filter declared but the query schema has no "filter" parameter', () => {
271
+ const feature = defineFeature("ledger", (r) => {
272
+ r.queryHandler(
273
+ "schedule:list",
274
+ z.object({}),
275
+ async () => ({ rows: [], nextCursor: null }),
276
+ {
277
+ access: { openToAll: true },
278
+ },
279
+ );
280
+ r.screen({
281
+ id: "schedule-list",
282
+ type: "projectionList",
283
+ query: "ledger:query:schedule:list",
284
+ columns: ["description"],
285
+ filter: { field: "status", op: "eq", value: "active" },
286
+ });
287
+ r.translations({
288
+ keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
289
+ });
290
+ });
291
+ expect(() => validateBoot([feature])).toThrow(/no "filter" parameter/);
292
+ });
293
+
294
+ test("a facet referencing a field that isn't a declared column is rejected", () => {
295
+ const feature = defineFeature("ledger", (r) => {
296
+ r.queryHandler(
297
+ "schedule:list",
298
+ z.object({ filters: z.unknown().optional() }),
299
+ async () => ({ rows: [], nextCursor: null }),
300
+ { access: { openToAll: true } },
301
+ );
302
+ r.screen({
303
+ id: "schedule-list",
304
+ type: "projectionList",
305
+ query: "ledger:query:schedule:list",
306
+ columns: ["description"],
307
+ facets: [
308
+ {
309
+ field: "status",
310
+ type: "select",
311
+ label: "Status",
312
+ options: [{ value: "active", label: "Active" }],
313
+ },
314
+ ],
315
+ });
316
+ r.translations({
317
+ keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
318
+ });
319
+ });
320
+ expect(() => validateBoot([feature])).toThrow(/not a declared column/);
321
+ });
322
+
323
+ test("duplicate facet fields are rejected", () => {
324
+ const feature = defineFeature("ledger", (r) => {
325
+ r.queryHandler(
326
+ "schedule:list",
327
+ z.object({ filters: z.unknown().optional() }),
328
+ async () => ({ rows: [], nextCursor: null }),
329
+ { access: { openToAll: true } },
330
+ );
331
+ r.screen({
332
+ id: "schedule-list",
333
+ type: "projectionList",
334
+ query: "ledger:query:schedule:list",
335
+ columns: ["status"],
336
+ facets: [
337
+ {
338
+ field: "status",
339
+ type: "boolean",
340
+ label: "Status",
341
+ trueLabel: "On",
342
+ falseLabel: "Off",
343
+ },
344
+ {
345
+ field: "status",
346
+ type: "boolean",
347
+ label: "Status",
348
+ trueLabel: "On",
349
+ falseLabel: "Off",
350
+ },
351
+ ],
352
+ });
353
+ r.translations({
354
+ keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
355
+ });
356
+ });
357
+ expect(() => validateBoot([feature])).toThrow(/more than once/);
358
+ });
359
+
360
+ test("a select facet with an empty options list is rejected", () => {
361
+ const feature = defineFeature("ledger", (r) => {
362
+ r.queryHandler(
363
+ "schedule:list",
364
+ z.object({ filters: z.unknown().optional() }),
365
+ async () => ({ rows: [], nextCursor: null }),
366
+ { access: { openToAll: true } },
367
+ );
368
+ r.screen({
369
+ id: "schedule-list",
370
+ type: "projectionList",
371
+ query: "ledger:query:schedule:list",
372
+ columns: ["status"],
373
+ facets: [{ field: "status", type: "select", label: "Status", options: [] }],
374
+ });
375
+ r.translations({
376
+ keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
377
+ });
378
+ });
379
+ expect(() => validateBoot([feature])).toThrow(/empty options list/);
380
+ });
381
+
382
+ test('facets declared but the query schema has no "filters" parameter', () => {
383
+ const feature = defineFeature("ledger", (r) => {
384
+ r.queryHandler(
385
+ "schedule:list",
386
+ z.object({}),
387
+ async () => ({ rows: [], nextCursor: null }),
388
+ {
389
+ access: { openToAll: true },
390
+ },
391
+ );
392
+ r.screen({
393
+ id: "schedule-list",
394
+ type: "projectionList",
395
+ query: "ledger:query:schedule:list",
396
+ columns: ["status"],
397
+ facets: [
398
+ {
399
+ field: "status",
400
+ type: "boolean",
401
+ label: "Status",
402
+ trueLabel: "On",
403
+ falseLabel: "Off",
404
+ },
405
+ ],
406
+ });
407
+ r.translations({
408
+ keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
409
+ });
410
+ });
411
+ expect(() => validateBoot([feature])).toThrow(/no "filters" parameter/);
412
+ });
413
+
414
+ test("a valid filter + facets declaration on a schema that accepts both passes boot", () => {
415
+ const feature = defineFeature("ledger", (r) => {
416
+ r.queryHandler(
417
+ "schedule:list",
418
+ z.object({ filter: z.unknown().optional(), filters: z.unknown().optional() }),
419
+ async () => ({ rows: [], nextCursor: null }),
420
+ { access: { openToAll: true } },
421
+ );
422
+ r.screen({
423
+ id: "schedule-list",
424
+ type: "projectionList",
425
+ query: "ledger:query:schedule:list",
426
+ columns: ["status"],
427
+ filter: { field: "tier", op: "eq", value: "gold" },
428
+ facets: [
429
+ {
430
+ field: "status",
431
+ type: "boolean",
432
+ label: "Status",
433
+ trueLabel: "On",
434
+ falseLabel: "Off",
435
+ },
436
+ ],
437
+ });
438
+ r.translations({
439
+ keys: { "screen:schedule-list.title": { de: "Liste", en: "List" } },
440
+ });
441
+ });
442
+ expect(() => validateBoot([feature])).not.toThrow();
443
+ });
444
+ });
241
445
  });
@@ -3511,6 +3511,103 @@ describe("boot-validator", () => {
3511
3511
  });
3512
3512
  });
3513
3513
 
3514
+ // --- toolbarAction drawer (fw#2225) ---
3515
+ describe("entityList toolbarAction drawer (fw#2225)", () => {
3516
+ function makeFeature(opts: {
3517
+ readonly targetId?: string;
3518
+ readonly targetType?: "actionForm" | "entityList";
3519
+ }) {
3520
+ const targetId = opts.targetId ?? "restock-form";
3521
+ return defineFeature("shop", (r) => {
3522
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
3523
+ r.screen({
3524
+ id: "product-list",
3525
+ type: "entityList",
3526
+ entity: "product",
3527
+ columns: ["name"],
3528
+ toolbarActions: [
3529
+ { kind: "drawer", id: "open-drawer", label: "actions.restock", screen: targetId },
3530
+ ],
3531
+ });
3532
+ if (opts.targetType === "entityList") {
3533
+ r.screen({
3534
+ id: targetId,
3535
+ type: "entityList",
3536
+ entity: "product",
3537
+ columns: ["name"],
3538
+ });
3539
+ return;
3540
+ }
3541
+ if (opts.targetType === "actionForm") {
3542
+ r.writeHandler(
3543
+ "restock",
3544
+ z.object({ qty: z.number() }),
3545
+ async () => ({ isSuccess: true as const, data: null }),
3546
+ { access: { roles: ["Admin"] } },
3547
+ );
3548
+ r.screen({
3549
+ id: targetId,
3550
+ type: "actionForm",
3551
+ handler: "shop:write:restock",
3552
+ fields: { qty: { type: "number" } } as never,
3553
+ layout: { sections: [{ fields: ["qty"] }] },
3554
+ });
3555
+ }
3556
+ });
3557
+ }
3558
+
3559
+ test("drawer-target → registered actionForm → no throw", () => {
3560
+ expect(() => validateBoot([makeFeature({ targetType: "actionForm" })])).not.toThrow();
3561
+ });
3562
+
3563
+ test("drawer-target → unknown → throw with a clear message", () => {
3564
+ expect(() => validateBoot([makeFeature({ targetId: "ghost-form" })])).toThrow(
3565
+ /toolbarAction "open-drawer" drawer-target "ghost-form" does not resolve to a registered screen/,
3566
+ );
3567
+ });
3568
+
3569
+ test("drawer-target → screen exists but is not an actionForm → throw with a clear message", () => {
3570
+ expect(() =>
3571
+ validateBoot([makeFeature({ targetId: "product-list-2", targetType: "entityList" })]),
3572
+ ).toThrow(
3573
+ /toolbarAction "open-drawer" drawer-target "product-list-2" is a "entityList" screen, not an actionForm/,
3574
+ );
3575
+ });
3576
+
3577
+ test("drawer-target → actionForm in ANOTHER feature → throw (drawer resolves same-feature only)", () => {
3578
+ const list = defineFeature("shop", (r) => {
3579
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
3580
+ r.screen({
3581
+ id: "product-list",
3582
+ type: "entityList",
3583
+ entity: "product",
3584
+ columns: ["name"],
3585
+ toolbarActions: [
3586
+ { kind: "drawer", id: "open-drawer", label: "actions.restock", screen: "restock-form" },
3587
+ ],
3588
+ });
3589
+ });
3590
+ const consumer = defineFeature("app", (r) => {
3591
+ r.writeHandler(
3592
+ "restock",
3593
+ z.object({ qty: z.number() }),
3594
+ async () => ({ isSuccess: true as const, data: null }),
3595
+ { access: { roles: ["Admin"] } },
3596
+ );
3597
+ r.screen({
3598
+ id: "restock-form",
3599
+ type: "actionForm",
3600
+ handler: "app:write:restock",
3601
+ fields: { qty: { type: "number" } } as never,
3602
+ layout: { sections: [{ fields: ["qty"] }] },
3603
+ });
3604
+ });
3605
+ expect(() => validateBoot([list, consumer])).toThrow(
3606
+ /toolbarAction "open-drawer" drawer-target "restock-form" does not resolve to a registered screen in this feature/,
3607
+ );
3608
+ });
3609
+ });
3610
+
3514
3611
  // --- defaultSort funktioniert für ALLE Field-Types die sortable
3515
3612
  // unterstützen (Tier 2.6b Field-Erweiterung) ---
3516
3613
  // Vor Tier 2.6b war `sortable` nur auf TextFieldDef. Erweitert auf
@@ -0,0 +1,110 @@
1
+ // fw#2224: a projectionList screen can now declare `filter`/`facets`, but
2
+ // the wire contract that actually matters is the payload the query handler
3
+ // receives — `screen.filter`/facet-toggles reach the server as
4
+ // payload.filter/payload.filters (see kumiko-screen.tsx's ProjectionListBody
5
+ // queryPayload). This proves the server side of that contract over real
6
+ // HTTP: a query handler whose Zod schema accepts filter/filters (here the
7
+ // entity-list auto-CRUD handler, entityListSchema) genuinely narrows the
8
+ // returned rows — not just "the field is present in the payload".
9
+ //
10
+ // The bound query is a real entity-list handler (r.crud), the same one a
11
+ // projectionList screen would point `query` at to reuse another feature's
12
+ // list — see the registered "member-list" projectionList screen below,
13
+ // which pins that this is a realistic, boot-valid setup, not just a
14
+ // hand-rolled test handler.
15
+
16
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
17
+ import { asRawClient } from "../../db/query";
18
+ import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
19
+ import { defineFeature } from "../define-feature";
20
+ import { createEntity, createTextField } from "../factories";
21
+
22
+ const memberEntity = createEntity({
23
+ table: "pl_filter_members",
24
+ fields: {
25
+ name: createTextField({ required: true }),
26
+ status: createTextField({ required: true, filterable: true }),
27
+ },
28
+ });
29
+
30
+ const LIST_QN = "roster:query:member:list";
31
+
32
+ const rosterFeature = defineFeature("roster", (r) => {
33
+ r.crud("member", memberEntity, {
34
+ write: { access: { roles: ["Admin"] } },
35
+ read: { access: { openToAll: true } },
36
+ });
37
+ r.screen({
38
+ id: "member-list",
39
+ type: "projectionList",
40
+ query: LIST_QN,
41
+ columns: ["name", "status"],
42
+ filter: { field: "status", op: "eq", value: "active" },
43
+ });
44
+ r.translations({
45
+ keys: { "screen:member-list.title": { de: "Mitglieder", en: "Members" } },
46
+ });
47
+ });
48
+
49
+ describe("projectionList filter — real query narrows real rows (fw#2224)", () => {
50
+ let stack: TestStack;
51
+
52
+ beforeAll(async () => {
53
+ stack = await setupTestStack({ features: [rosterFeature] });
54
+ await unsafeCreateEntityTable(stack.db, memberEntity);
55
+ });
56
+
57
+ afterAll(async () => {
58
+ await stack.cleanup();
59
+ });
60
+
61
+ beforeEach(async () => {
62
+ await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
63
+ await asRawClient(stack.db).unsafe('DELETE FROM "pl_filter_members"');
64
+ });
65
+
66
+ async function seed(): Promise<void> {
67
+ const CREATE = "roster:write:member:create";
68
+ await stack.http.write(CREATE, { name: "Ada", status: "active" }, TestUsers.admin);
69
+ await stack.http.write(CREATE, { name: "Grace", status: "active" }, TestUsers.admin);
70
+ await stack.http.write(CREATE, { name: "Bob", status: "inactive" }, TestUsers.admin);
71
+ }
72
+
73
+ test("screen.filter's shape (payload.filter, op:eq) returns only matching rows", async () => {
74
+ await seed();
75
+ const result = await stack.http.queryOk<{
76
+ readonly rows: readonly { readonly name: string; readonly status: string }[];
77
+ }>(
78
+ LIST_QN,
79
+ { limit: 50, filter: { field: "status", op: "eq", value: "active" } },
80
+ TestUsers.admin,
81
+ );
82
+
83
+ expect(result.rows).toHaveLength(2);
84
+ expect(result.rows.map((r) => r.name).sort()).toEqual(["Ada", "Grace"]);
85
+ expect(result.rows.every((r) => r.status === "active")).toBe(true);
86
+ });
87
+
88
+ test("a facet's shape (payload.filters, op:in) returns only matching rows", async () => {
89
+ await seed();
90
+ const result = await stack.http.queryOk<{
91
+ readonly rows: readonly { readonly name: string; readonly status: string }[];
92
+ }>(
93
+ LIST_QN,
94
+ { limit: 50, filters: [{ field: "status", op: "in", value: ["inactive"] }] },
95
+ TestUsers.admin,
96
+ );
97
+
98
+ expect(result.rows).toHaveLength(1);
99
+ expect(result.rows[0]?.name).toBe("Bob");
100
+ });
101
+
102
+ test("no filter — all rows return (control: proves the filter tests above actually narrow something)", async () => {
103
+ await seed();
104
+ const result = await stack.http.queryOk<{
105
+ readonly rows: readonly { readonly name: string; readonly status: string }[];
106
+ }>(LIST_QN, { limit: 50 }, TestUsers.admin);
107
+
108
+ expect(result.rows).toHaveLength(3);
109
+ });
110
+ });
@@ -71,6 +71,45 @@ function validateOneProjectionListScreen(
71
71
  if ((searchActive || sortActive) && screen.defaultSort === undefined) {
72
72
  throw new Error(`${prefix}: defaultSort required when search or sort is active`);
73
73
  }
74
+
75
+ validateProjectionListFilterSchemaAcceptance(prefix, screen, schema);
76
+ validateProjectionListFacetsSchemaAcceptance(prefix, screen, schema);
77
+ }
78
+
79
+ // filter (fw#2224) sends `payload.filter` — same 422 footgun as facets below
80
+ // if the handler's schema doesn't accept it.
81
+ function validateProjectionListFilterSchemaAcceptance(
82
+ prefix: string,
83
+ screen: ProjectionListScreenDefinition,
84
+ schema: QueryHandlerDef["schema"] | undefined,
85
+ ): void {
86
+ // skip: no filter declared, or the schema already accepts it — nothing to reject.
87
+ if (screen.filter === undefined || schemaAccepts(schema, "filter")) return;
88
+ throw new Error(
89
+ `${prefix}: declares filter but query "${screen.query}" has no "filter" parameter in its Zod ` +
90
+ `schema — add filter: z.object({ field: z.string(), op: z.enum(["eq","ne","lt","gt","in"]), ` +
91
+ `value: z.unknown() }).optional() (or reuse entityListSchema's shape) to the handler's schema.`,
92
+ );
93
+ }
94
+
95
+ // facets (fw#2224) send `payload.filters` — same failure mode as search/sort
96
+ // (fw#2165): definePagedQueryHandler doesn't auto-merge params into the
97
+ // handler's own Zod schema, so a declared facet would 422 on every query
98
+ // unless the author added `filters` themselves.
99
+ function validateProjectionListFacetsSchemaAcceptance(
100
+ prefix: string,
101
+ screen: ProjectionListScreenDefinition,
102
+ schema: QueryHandlerDef["schema"] | undefined,
103
+ ): void {
104
+ // skip: no facets declared — nothing to reject.
105
+ if (screen.facets === undefined || screen.facets.length === 0) return;
106
+ // skip: the schema already accepts filters — nothing to reject.
107
+ if (schemaAccepts(schema, "filters")) return;
108
+ throw new Error(
109
+ `${prefix}: declares facets but query "${screen.query}" has no "filters" parameter in its ` +
110
+ `Zod schema — add filters: z.array(z.object({ field: z.string(), op: z.literal("in"), ` +
111
+ `value: z.unknown() })).optional() (or reuse entityListSchema's shape) to the handler's schema.`,
112
+ );
74
113
  }
75
114
 
76
115
  export function validateProjectionListScreens(features: readonly FeatureDefinition[]): void {
@@ -303,6 +303,39 @@ function validateScreenNavTarget(
303
303
  }
304
304
  }
305
305
 
306
+ // kind:"drawer" resolves same-feature only — unlike navigate/redirect,
307
+ // which the runtime router resolves app-wide (see the comment on
308
+ // validateScreens below), the drawer mounts the target inline using this
309
+ // feature's schema, so a cross-feature reference could never actually
310
+ // render. Two distinct failure messages: dangling reference vs. wrong
311
+ // screen type (fw#2225).
312
+ function validateToolbarDrawerAction(
313
+ featureName: string,
314
+ screenId: string,
315
+ screenKind: string,
316
+ action: Extract<ToolbarAction, { kind: "drawer" }>,
317
+ screens: FeatureDefinition["screens"],
318
+ ): void {
319
+ const target = screens[action.screen];
320
+ if (target === undefined) {
321
+ throw new Error(
322
+ `[Feature ${featureName}] Screen "${screenId}" (${screenKind}) toolbarAction "${action.id}" ` +
323
+ `drawer-target "${action.screen}" does not resolve to a registered screen in this feature. ` +
324
+ `kind:"drawer" only resolves same-feature screens (unlike kind:"navigate", which can target ` +
325
+ `screens in any feature) — the drawer mounts the target inline using this feature's schema. ` +
326
+ `Known screens in this feature: ${[...Object.keys(screens)].sort().join(", ") || "(none)"}.`,
327
+ );
328
+ }
329
+ if (target.type !== "actionForm") {
330
+ throw new Error(
331
+ `[Feature ${featureName}] Screen "${screenId}" (${screenKind}) toolbarAction "${action.id}" ` +
332
+ `drawer-target "${action.screen}" is a "${target.type}" screen, not an actionForm. ` +
333
+ `kind:"drawer" mounts an actionForm inside a Drawer widget — point "screen" at an ` +
334
+ `actionForm screen, or use kind:"navigate" for a full-page target.`,
335
+ );
336
+ }
337
+ }
338
+
306
339
  export function validateScreens(
307
340
  feature: FeatureDefinition,
308
341
  featureMap: ReadonlyMap<string, FeatureDefinition>,
@@ -351,6 +384,52 @@ export function validateScreens(
351
384
  for (const col of screen.columns) {
352
385
  validateColumnRendererForm(feature.name, screenId, normalizeListColumn(col));
353
386
  }
387
+ // Screen filter (fw#2224) — field existence can't be checked without
388
+ // an entity (columns aren't a complete field inventory of the
389
+ // underlying query), so only pin the structure: "in" requires an
390
+ // array. Field validity is documented in the PR body.
391
+ if (
392
+ screen.filter !== undefined &&
393
+ screen.filter.op === "in" &&
394
+ !Array.isArray(screen.filter.value)
395
+ ) {
396
+ throw new Error(
397
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionList) filter.op "in" requires ` +
398
+ `filter.value to be a readonly array.`,
399
+ );
400
+ }
401
+ // Facets (fw#2224) — unlike filter, a field inventory IS available
402
+ // here: the declared columns. A facet on a field with no column is
403
+ // almost always a typo (the user never sees the field anywhere), so
404
+ // this is hard-checked rather than just documented.
405
+ if (screen.facets !== undefined) {
406
+ const columnFieldNames = new Set(
407
+ screen.columns.map((col) => normalizeListColumn(col).field),
408
+ );
409
+ const seenFacetFields = new Set<string>();
410
+ for (const facet of screen.facets) {
411
+ if (seenFacetFields.has(facet.field)) {
412
+ throw new Error(
413
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionList) declares facet ` +
414
+ `"${facet.field}" more than once.`,
415
+ );
416
+ }
417
+ seenFacetFields.add(facet.field);
418
+ if (!columnFieldNames.has(facet.field)) {
419
+ throw new Error(
420
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionList) facet references field ` +
421
+ `"${facet.field}" which is not a declared column. Known columns: ` +
422
+ `${[...columnFieldNames].sort().join(", ")}`,
423
+ );
424
+ }
425
+ if (facet.type === "select" && facet.options.length === 0) {
426
+ throw new Error(
427
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionList) facet "${facet.field}" ` +
428
+ `(type "select") has an empty options list — declare at least one option.`,
429
+ );
430
+ }
431
+ }
432
+ }
354
433
  if (screen.rowActions !== undefined) {
355
434
  for (const action of screen.rowActions) {
356
435
  if (action.kind === "navigate") {
@@ -374,6 +453,22 @@ export function validateScreens(
374
453
  }
375
454
  validateAtMostOneRowClick(feature.name, screenId, "projectionList", screen.rowActions);
376
455
  }
456
+ // Only drawer-kind is validated here — navigate/writeHandler toolbarActions
457
+ // on projectionList have no boot check yet (pre-existing gap, out of
458
+ // scope for fw#2225).
459
+ if (screen.toolbarActions !== undefined) {
460
+ for (const action of screen.toolbarActions) {
461
+ if (action.kind === "drawer") {
462
+ validateToolbarDrawerAction(
463
+ feature.name,
464
+ screenId,
465
+ "projectionList",
466
+ action,
467
+ feature.screens,
468
+ );
469
+ }
470
+ }
471
+ }
377
472
  continue;
378
473
  }
379
474
 
@@ -923,6 +1018,14 @@ export function validateScreens(
923
1018
  `navigate-target "${action.screen}" does not resolve to a registered screen in any feature.`,
924
1019
  );
925
1020
  }
1021
+ } else if (action.kind === "drawer") {
1022
+ validateToolbarDrawerAction(
1023
+ feature.name,
1024
+ screenId,
1025
+ "entityList",
1026
+ action,
1027
+ feature.screens,
1028
+ );
926
1029
  } else {
927
1030
  if (!allWriteHandlerQns.has(action.handler)) {
928
1031
  throw new Error(
@@ -269,6 +269,7 @@ export type {
269
269
  FieldRenderer,
270
270
  FormatSpec,
271
271
  ListColumnSpec,
272
+ ListFacetSpec,
272
273
  PlatformComponent,
273
274
  ProjectionDetailScreenDefinition,
274
275
  ProjectionListScreenDefinition,
@@ -279,3 +279,29 @@ describe("backfillEventPiiEncryption", () => {
279
279
  );
280
280
  });
281
281
  });
282
+
283
+ describe("backfillEventPiiEncryption: raw jsonb column type (fw#2253)", () => {
284
+ // The UPDATE used to wrap outcome.payload in JSON.stringify before handing
285
+ // it to the ::jsonb cast — Bun.SQL already serializes objects, so the
286
+ // double encoding produced a jsonb STRING scalar instead of an object.
287
+ // loadAggregate stayed green either way: the typed read path (bun-db's
288
+ // coerceRow) re-parses string-shaped jsonb columns on the way out, which
289
+ // cancels the write-side bug out. Only raw SQL consumers (this query,
290
+ // GDPR exports, MSP replays) saw the corruption, so assert on the column
291
+ // type directly instead of going through loadAggregate.
292
+ test("UPDATE writes payload as a jsonb object, not a double-encoded string", async () => {
293
+ const c1 = generateId();
294
+ await appendPlain(c1, "contact", "contact.created", { id: c1, email: "raw@x.com" });
295
+
296
+ armKms();
297
+ const result = await backfillEventPiiEncryption(testDb.db, registry);
298
+ expect(result.updatedEvents).toBe(1);
299
+
300
+ const rows = (await asRawClient(testDb.db).unsafe(
301
+ `SELECT jsonb_typeof(payload) AS t FROM "kumiko_events" WHERE aggregate_id = $1`,
302
+ [c1],
303
+ )) as ReadonlyArray<{ t: string }>;
304
+ expect(rows).toHaveLength(1);
305
+ expect(rows[0]?.t).toBe("object");
306
+ });
307
+ });
@@ -106,6 +106,9 @@ function pushToolbarActionKeys(out: Set<string>, action: ToolbarAction): void {
106
106
  pushKey(out, action.confirm);
107
107
  pushKey(out, action.confirmLabel);
108
108
  }
109
+ // drawer-kind carries no confirm/confirmLabel — action.label (the
110
+ // toolbar button AND the Drawer title, see ToolbarDrawerHost) is
111
+ // already pushed above.
109
112
  }
110
113
 
111
114
  export function requiredKeysFromScreen(
@@ -200,7 +200,7 @@ export type EnqueueProjectionRebuildDeps = {
200
200
  readonly db: DbConnection;
201
201
  readonly registry: Registry;
202
202
  // Present + projection-rebuild job registered (jobs composed) → tracked,
203
- // retryable job (read_job_runs + store_job_run_logs). Absent → inline rebuild.
203
+ // retryable job (store_job_runs + store_job_run_logs). Absent → inline rebuild.
204
204
  readonly jobRunner?: JobRunner;
205
205
  };
206
206
 
@@ -80,6 +80,7 @@ export type {
80
80
  FieldRenderer,
81
81
  FormWidth,
82
82
  ListColumnSpec,
83
+ ListFacetSpec,
83
84
  PlatformComponent,
84
85
  ProjectionDetailScreenDefinition,
85
86
  ProjectionListScreenDefinition,