@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.
|
|
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.
|
|
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.
|
|
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
|
|