@cosmicdrift/kumiko-framework 0.224.0 → 0.224.2

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.224.0",
3
+ "version": "0.224.2",
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>",
@@ -198,7 +198,7 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-types": "0.224.0",
201
+ "@cosmicdrift/kumiko-types": "0.224.2",
202
202
  "bullmq": "^5.76.7",
203
203
  "bun-types": "^1.3.13",
204
204
  "hono": "^4.13.1",
@@ -214,7 +214,7 @@
214
214
  "zod": "^4.4.3"
215
215
  },
216
216
  "devDependencies": {
217
- "@cosmicdrift/kumiko-dispatcher-live": "0.224.0",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.224.2",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
@@ -12,7 +12,7 @@ import { z } from "zod";
12
12
  import { buildAppSchema, findNonJsonSafePath } from "../build-app-schema";
13
13
  import { defineFeature } from "../define-feature";
14
14
  import { createRegistry } from "../registry";
15
- import type { EntityDefinition } from "../types/fields";
15
+ import type { EntityDefinition, MultiSelectFieldDef } from "../types/fields";
16
16
  import type { ProjectionListScreenDefinition } from "../types/screen";
17
17
 
18
18
  describe("buildAppSchema", () => {
@@ -314,6 +314,141 @@ describe("buildAppSchema", () => {
314
314
  expect(fields["name"]?.["searchable"]).toBeUndefined();
315
315
  });
316
316
 
317
+ test("MultiSelect: display/columns/maxRows überleben die Projection (fw#2494)", () => {
318
+ // Regression: without display in the client schema, render-field.tsx's
319
+ // `field.display === "checkboxes"` check always falls through → the
320
+ // renderer always shows the combobox, MultiSelectCheckboxes is unreachable.
321
+ const entity = {
322
+ fields: {
323
+ tags: {
324
+ type: "multiSelect",
325
+ options: ["a", "b", "c"],
326
+ display: "checkboxes",
327
+ columns: 3,
328
+ maxRows: 5,
329
+ },
330
+ categories: { type: "multiSelect", options: ["x", "y"] },
331
+ },
332
+ } as unknown as EntityDefinition;
333
+
334
+ const f = defineFeature("ent", (r) => {
335
+ r.entity("thing", entity);
336
+ });
337
+ const app = buildAppSchema(createRegistry([f]));
338
+ const fields = (
339
+ app.features[0]!.entities["thing"] as unknown as {
340
+ fields: Record<string, Record<string, unknown>>;
341
+ }
342
+ ).fields;
343
+
344
+ expect(fields["tags"]?.["display"]).toBe("checkboxes");
345
+ expect(fields["tags"]?.["columns"]).toBe(3);
346
+ expect(fields["tags"]?.["maxRows"]).toBe(5);
347
+ // Fields without display/columns/maxRows don't carry the keys (no false-litter).
348
+ expect(fields["categories"]?.["display"]).toBeUndefined();
349
+ expect(fields["categories"]?.["columns"]).toBeUndefined();
350
+ expect(fields["categories"]?.["maxRows"]).toBeUndefined();
351
+ });
352
+
353
+ test("MultiSelectFieldDef: alle Keys sind in projectField bewusst eingeordnet", () => {
354
+ // Bounded completeness check, scoped to MultiSelectFieldDef's own key set
355
+ // (packages/types/src/fields.ts) — NOT a general FieldDefinition lockstep.
356
+ // A full-union version would have to classify every property of all 20
357
+ // FieldDefinition variants as forwarded or server-only, and several are
358
+ // known-forwarded-but-missing today (e.g. image's `capture`, embedded's
359
+ // `schema`/`derived`/`totals`/`minItems`/`maxItems`, date's
360
+ // `min`/`max`/`locale`, longText's `multiline`) — declaring those
361
+ // "server-only" here would be a false claim, and fixing them is out of
362
+ // scope for fw#2494 (see report for the full gap list). This check only
363
+ // covers the type this fix actually touches.
364
+ //
365
+ // The `_allKeysClassified` assignment below is the actual guard: if
366
+ // MultiSelectFieldDef ever gains a key that isn't in one of the three
367
+ // lists, `Exclude<keyof MultiSelectFieldDef, Classified>` stops being
368
+ // `never` and the assignment fails to typecheck — `bun run typecheck`
369
+ // (part of `bun run kumiko check`) catches it even without touching this
370
+ // test's runtime assertions.
371
+ const FORWARDED_KEYS = [
372
+ "type",
373
+ "required",
374
+ "filterable",
375
+ "options",
376
+ "display",
377
+ "columns",
378
+ "maxRows",
379
+ ] as const;
380
+
381
+ const SERVER_ONLY_KEYS = [
382
+ "sensitive", // controls write-response redaction, never rendered
383
+ "access", // server-side authz check, not a renderer concern
384
+ "pii", // PII classification, drives crypto/storage — not client-relevant
385
+ "userOwned", // same: subject-key annotation, server/crypto-only
386
+ "tenantOwned", // same: subject-key annotation, server/crypto-only
387
+ "anonymize", // retention-cleanup callback, never serializable to JSON
388
+ "allowPlaintext", // PII-audit reason string, not a renderer concern
389
+ "lookupable", // blind-index equality lookup, server-side query concern
390
+ "subjectRef", // GDPR-hook-coverage marker, not a renderer concern
391
+ ] as const;
392
+
393
+ // Keys that ARE client-relevant in principle but aren't forwarded today —
394
+ // pre-existing gaps, each with its own reason, none introduced by fw#2494.
395
+ const KNOWN_GAP_KEYS = [
396
+ "default", // array-valued for multiSelect, but projectField's isLiteral()
397
+ // only accepts string/number/boolean/null — so array defaults are
398
+ // silently dropped, never a real server-only property. Verified
399
+ // empirically: a multiSelect field with `default: ["a"]` projects to
400
+ // no `default` key at all. Separate pre-existing bug, out of scope here.
401
+ ] as const;
402
+
403
+ type Classified =
404
+ | (typeof FORWARDED_KEYS)[number]
405
+ | (typeof SERVER_ONLY_KEYS)[number]
406
+ | (typeof KNOWN_GAP_KEYS)[number];
407
+ const _allKeysClassified: Exclude<keyof MultiSelectFieldDef, Classified> extends never
408
+ ? true
409
+ : never = true;
410
+
411
+ const entity = {
412
+ fields: {
413
+ tags: {
414
+ type: "multiSelect",
415
+ required: true,
416
+ filterable: true,
417
+ options: ["a", "b"],
418
+ display: "checkboxes",
419
+ columns: 2,
420
+ maxRows: 4,
421
+ sensitive: true,
422
+ access: { read: ["admin"] },
423
+ pii: true,
424
+ userOwned: { ownerField: "authorId" },
425
+ tenantOwned: true,
426
+ anonymize: () => "[ANONYMIZED]",
427
+ allowPlaintext: "is_business_data",
428
+ lookupable: true,
429
+ subjectRef: true,
430
+ },
431
+ },
432
+ } as unknown as EntityDefinition;
433
+
434
+ const f = defineFeature("ent", (r) => {
435
+ r.entity("thing", entity);
436
+ });
437
+ const app = buildAppSchema(createRegistry([f]));
438
+ const projected = (
439
+ app.features[0]!.entities["thing"] as unknown as {
440
+ fields: Record<string, Record<string, unknown>>;
441
+ }
442
+ ).fields["tags"] as Record<string, unknown>;
443
+
444
+ for (const key of FORWARDED_KEYS) {
445
+ expect(projected[key]).toBeDefined();
446
+ }
447
+ for (const key of SERVER_ONLY_KEYS) {
448
+ expect(projected[key]).toBeUndefined();
449
+ }
450
+ });
451
+
317
452
  test("AppSchema ist via JSON.stringify roundtrip-sicher", () => {
318
453
  // Echter Smoke-Test des Vertrags — wenn jemand in den project-
319
454
  // Helper eine Function reinschmuggelt, würde das hier brennen.
@@ -428,6 +428,12 @@ function projectField(fieldDef: FieldDefinition): FieldDefinition {
428
428
  if (typeof def["entity"] === "string") out["entity"] = def["entity"];
429
429
  if (typeof def["labelField"] === "string") out["labelField"] = def["labelField"];
430
430
  if (typeof def["multiple"] === "boolean") out["multiple"] = def["multiple"];
431
+ // MultiSelect: display picks checkboxes vs. combobox in the renderer,
432
+ // columns/maxRows size the checkbox grid — without these the renderer
433
+ // always falls back to the combobox (fw#2494).
434
+ if (typeof def["display"] === "string") out["display"] = def["display"];
435
+ if (typeof def["columns"] === "number") out["columns"] = def["columns"];
436
+ if (typeof def["maxRows"] === "number") out["maxRows"] = def["maxRows"];
431
437
  return out as FieldDefinition; // @cast-boundary schema-walk
432
438
  }
433
439