@cosmicdrift/kumiko-framework 0.224.2 → 0.226.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.
- package/package.json +3 -3
- package/src/bun-db/query.ts +37 -1
- package/src/db/__tests__/migrate-generator.test.ts +173 -1
- package/src/db/event-store-executor-read.ts +115 -54
- package/src/db/migrate-generator.ts +111 -8
- package/src/db/render-ddl.ts +1 -1
- package/src/engine/__tests__/boot-validator-query-output-schema.test.ts +435 -0
- package/src/engine/__tests__/boot-validator.test.ts +92 -2
- package/src/engine/__tests__/build-app-schema.test.ts +215 -22
- package/src/engine/__tests__/multiselect-filter.integration.test.ts +141 -0
- package/src/engine/__tests__/projection-detail-tabs.test.ts +158 -0
- package/src/engine/boot-validator/index.ts +4 -0
- package/src/engine/boot-validator/projection-list-screens.ts +2 -2
- package/src/engine/boot-validator/query-output-columns.ts +236 -0
- package/src/engine/boot-validator/screens.ts +98 -9
- package/src/engine/boot-validator/zod-shape.ts +51 -0
- package/src/engine/build-app-schema.ts +50 -13
- package/src/engine/feature-entity-handlers.ts +3 -1
|
@@ -354,16 +354,15 @@ describe("buildAppSchema", () => {
|
|
|
354
354
|
// Bounded completeness check, scoped to MultiSelectFieldDef's own key set
|
|
355
355
|
// (packages/types/src/fields.ts) — NOT a general FieldDefinition lockstep.
|
|
356
356
|
// A full-union version would have to classify every property of all 20
|
|
357
|
-
// FieldDefinition variants as forwarded or server-only
|
|
358
|
-
//
|
|
359
|
-
// `
|
|
360
|
-
// `min`/`max`/`locale`, longText's `multiline`) —
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
// covers the type this fix actually touches.
|
|
357
|
+
// FieldDefinition variants as forwarded or server-only. fw#2497 closed the
|
|
358
|
+
// gaps fw#2494 flagged for other variants (image's `capture`, embedded's
|
|
359
|
+
// `derived`/`totals`/`totalsMatch`/`minItems`/`maxItems`, date's
|
|
360
|
+
// `min`/`max`/`locale`, longText's `multiline`, array/object `default`) —
|
|
361
|
+
// see the dedicated tests below. This check only covers the type this fix
|
|
362
|
+
// actually touches.
|
|
364
363
|
//
|
|
365
364
|
// The `_allKeysClassified` assignment below is the actual guard: if
|
|
366
|
-
// MultiSelectFieldDef ever gains a key that isn't in one of the
|
|
365
|
+
// MultiSelectFieldDef ever gains a key that isn't in one of the two
|
|
367
366
|
// lists, `Exclude<keyof MultiSelectFieldDef, Classified>` stops being
|
|
368
367
|
// `never` and the assignment fails to typecheck — `bun run typecheck`
|
|
369
368
|
// (part of `bun run kumiko check`) catches it even without touching this
|
|
@@ -376,6 +375,9 @@ describe("buildAppSchema", () => {
|
|
|
376
375
|
"display",
|
|
377
376
|
"columns",
|
|
378
377
|
"maxRows",
|
|
378
|
+
"default", // array-valued for multiSelect — projectField's
|
|
379
|
+
// isJsonSafeValue() (fw#2497) now recurses into arrays/plain
|
|
380
|
+
// objects instead of only string/number/boolean/null.
|
|
379
381
|
] as const;
|
|
380
382
|
|
|
381
383
|
const SERVER_ONLY_KEYS = [
|
|
@@ -390,20 +392,7 @@ describe("buildAppSchema", () => {
|
|
|
390
392
|
"subjectRef", // GDPR-hook-coverage marker, not a renderer concern
|
|
391
393
|
] as const;
|
|
392
394
|
|
|
393
|
-
|
|
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];
|
|
395
|
+
type Classified = (typeof FORWARDED_KEYS)[number] | (typeof SERVER_ONLY_KEYS)[number];
|
|
407
396
|
const _allKeysClassified: Exclude<keyof MultiSelectFieldDef, Classified> extends never
|
|
408
397
|
? true
|
|
409
398
|
: never = true;
|
|
@@ -415,6 +404,7 @@ describe("buildAppSchema", () => {
|
|
|
415
404
|
required: true,
|
|
416
405
|
filterable: true,
|
|
417
406
|
options: ["a", "b"],
|
|
407
|
+
default: ["a"],
|
|
418
408
|
display: "checkboxes",
|
|
419
409
|
columns: 2,
|
|
420
410
|
maxRows: 4,
|
|
@@ -449,6 +439,209 @@ describe("buildAppSchema", () => {
|
|
|
449
439
|
}
|
|
450
440
|
});
|
|
451
441
|
|
|
442
|
+
test("text/longText: multiline überlebt die Projection (fw#2497)", () => {
|
|
443
|
+
// Regression: without `multiline` in the client schema, DefaultInput
|
|
444
|
+
// always renders a single-line <input> — the textarea row count never
|
|
445
|
+
// arrives.
|
|
446
|
+
const entity = {
|
|
447
|
+
fields: {
|
|
448
|
+
notes: { type: "text", multiline: { rows: 6 } },
|
|
449
|
+
bio: { type: "text", multiline: true },
|
|
450
|
+
title: { type: "text" },
|
|
451
|
+
body: { type: "longText", multiline: true },
|
|
452
|
+
},
|
|
453
|
+
} as unknown as EntityDefinition;
|
|
454
|
+
|
|
455
|
+
const f = defineFeature("ent", (r) => {
|
|
456
|
+
r.entity("thing", entity);
|
|
457
|
+
});
|
|
458
|
+
const app = buildAppSchema(createRegistry([f]));
|
|
459
|
+
const fields = (
|
|
460
|
+
app.features[0]!.entities["thing"] as unknown as {
|
|
461
|
+
fields: Record<string, Record<string, unknown>>;
|
|
462
|
+
}
|
|
463
|
+
).fields;
|
|
464
|
+
|
|
465
|
+
expect(fields["notes"]?.["multiline"]).toEqual({ rows: 6 });
|
|
466
|
+
expect(fields["bio"]?.["multiline"]).toBe(true);
|
|
467
|
+
expect(fields["body"]?.["multiline"]).toBe(true);
|
|
468
|
+
// Fields without multiline don't carry the key (no false-litter).
|
|
469
|
+
expect(fields["title"]?.["multiline"]).toBeUndefined();
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test("number/date/timestamp/locatedTimestamp: min/max/locale überleben die Projection (fw#2497)", () => {
|
|
473
|
+
// Regression: input bounds and locale overrides never arrived at the
|
|
474
|
+
// renderer, so the browser's own range validation and date-picker
|
|
475
|
+
// formatting were always off.
|
|
476
|
+
const entity = {
|
|
477
|
+
fields: {
|
|
478
|
+
quantity: { type: "number", min: 0, max: 100 },
|
|
479
|
+
birthday: { type: "date", min: "1900-01-01", max: "2026-08-28", locale: "de-DE" },
|
|
480
|
+
startedAt: {
|
|
481
|
+
type: "timestamp",
|
|
482
|
+
min: "2020-01-01T00:00:00Z",
|
|
483
|
+
max: "2030-01-01T00:00:00Z",
|
|
484
|
+
locale: "de-DE",
|
|
485
|
+
},
|
|
486
|
+
pickupAt: {
|
|
487
|
+
type: "locatedTimestamp",
|
|
488
|
+
min: "2020-01-01T00:00:00",
|
|
489
|
+
max: "2030-01-01T00:00:00",
|
|
490
|
+
locale: "de-DE",
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
} as unknown as EntityDefinition;
|
|
494
|
+
|
|
495
|
+
const f = defineFeature("ent", (r) => {
|
|
496
|
+
r.entity("thing", entity);
|
|
497
|
+
});
|
|
498
|
+
const app = buildAppSchema(createRegistry([f]));
|
|
499
|
+
const fields = (
|
|
500
|
+
app.features[0]!.entities["thing"] as unknown as {
|
|
501
|
+
fields: Record<string, Record<string, unknown>>;
|
|
502
|
+
}
|
|
503
|
+
).fields;
|
|
504
|
+
|
|
505
|
+
expect(fields["quantity"]?.["min"]).toBe(0);
|
|
506
|
+
expect(fields["quantity"]?.["max"]).toBe(100);
|
|
507
|
+
expect(fields["birthday"]?.["min"]).toBe("1900-01-01");
|
|
508
|
+
expect(fields["birthday"]?.["max"]).toBe("2026-08-28");
|
|
509
|
+
expect(fields["birthday"]?.["locale"]).toBe("de-DE");
|
|
510
|
+
expect(fields["startedAt"]?.["min"]).toBe("2020-01-01T00:00:00Z");
|
|
511
|
+
expect(fields["startedAt"]?.["locale"]).toBe("de-DE");
|
|
512
|
+
expect(fields["pickupAt"]?.["max"]).toBe("2030-01-01T00:00:00");
|
|
513
|
+
expect(fields["pickupAt"]?.["locale"]).toBe("de-DE");
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test("image: capture überlebt die Projection (fw#2497)", () => {
|
|
517
|
+
// Regression: `capture` decides whether a mobile file picker opens the
|
|
518
|
+
// rear or front camera — never arrived, so it never applied.
|
|
519
|
+
const entity = {
|
|
520
|
+
fields: {
|
|
521
|
+
idPhoto: { type: "image", capture: "environment" },
|
|
522
|
+
avatar: { type: "image" },
|
|
523
|
+
},
|
|
524
|
+
} as unknown as EntityDefinition;
|
|
525
|
+
|
|
526
|
+
const f = defineFeature("ent", (r) => {
|
|
527
|
+
r.entity("thing", entity);
|
|
528
|
+
});
|
|
529
|
+
const app = buildAppSchema(createRegistry([f]));
|
|
530
|
+
const fields = (
|
|
531
|
+
app.features[0]!.entities["thing"] as unknown as {
|
|
532
|
+
fields: Record<string, Record<string, unknown>>;
|
|
533
|
+
}
|
|
534
|
+
).fields;
|
|
535
|
+
|
|
536
|
+
expect(fields["idPhoto"]?.["capture"]).toBe("environment");
|
|
537
|
+
expect(fields["avatar"]?.["capture"]).toBeUndefined();
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test("embedded: minItems/maxItems/derived/totals/totalsMatch überleben die Projection (fw#2497)", () => {
|
|
541
|
+
// Regression: `totals` carries "Renderer metadata: numeric sub-field
|
|
542
|
+
// names to sum in a totals row" in its own doc-comment but was never
|
|
543
|
+
// forwarded — same for the other embedded-list renderer hints.
|
|
544
|
+
const entity = {
|
|
545
|
+
fields: {
|
|
546
|
+
lines: {
|
|
547
|
+
type: "embedded",
|
|
548
|
+
multiple: true,
|
|
549
|
+
schema: {
|
|
550
|
+
qty: { type: "number" },
|
|
551
|
+
amount: { type: "money" },
|
|
552
|
+
},
|
|
553
|
+
minItems: 1,
|
|
554
|
+
maxItems: 20,
|
|
555
|
+
derived: { amount: { op: "multiply", from: ["qty", "unitPrice"] } },
|
|
556
|
+
totals: ["amount"],
|
|
557
|
+
totalsMatch: { amount: "invoiceTotal" },
|
|
558
|
+
},
|
|
559
|
+
},
|
|
560
|
+
} as unknown as EntityDefinition;
|
|
561
|
+
|
|
562
|
+
const f = defineFeature("ent", (r) => {
|
|
563
|
+
r.entity("thing", entity);
|
|
564
|
+
});
|
|
565
|
+
const app = buildAppSchema(createRegistry([f]));
|
|
566
|
+
const fields = (
|
|
567
|
+
app.features[0]!.entities["thing"] as unknown as {
|
|
568
|
+
fields: Record<string, Record<string, unknown>>;
|
|
569
|
+
}
|
|
570
|
+
).fields;
|
|
571
|
+
|
|
572
|
+
expect(fields["lines"]?.["minItems"]).toBe(1);
|
|
573
|
+
expect(fields["lines"]?.["maxItems"]).toBe(20);
|
|
574
|
+
expect(fields["lines"]?.["derived"]).toEqual({
|
|
575
|
+
amount: { op: "multiply", from: ["qty", "unitPrice"] },
|
|
576
|
+
});
|
|
577
|
+
expect(fields["lines"]?.["totals"]).toEqual(["amount"]);
|
|
578
|
+
expect(fields["lines"]?.["totalsMatch"]).toEqual({ amount: "invoiceTotal" });
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
test("embedded: derived/totalsMatch mit Function eine Ebene tief bleiben blockiert (fw#2497)", () => {
|
|
582
|
+
// Regression: a shallow `isPlainObject`/`Array.isArray` check alone
|
|
583
|
+
// would let a function survive one level deep — `derived`/`totals`/
|
|
584
|
+
// `totalsMatch`/`multiline` must go through isJsonSafeValue() too, not
|
|
585
|
+
// just `default`.
|
|
586
|
+
const entity = {
|
|
587
|
+
fields: {
|
|
588
|
+
lines: {
|
|
589
|
+
type: "embedded",
|
|
590
|
+
multiple: true,
|
|
591
|
+
schema: { qty: { type: "number" } },
|
|
592
|
+
derived: { amount: { op: "multiply", from: () => ["qty"] } },
|
|
593
|
+
totals: [() => "amount"],
|
|
594
|
+
totalsMatch: { amount: () => "invoiceTotal" },
|
|
595
|
+
},
|
|
596
|
+
multi: { type: "text", multiline: { rows: () => 4 } },
|
|
597
|
+
},
|
|
598
|
+
} as unknown as EntityDefinition;
|
|
599
|
+
|
|
600
|
+
const f = defineFeature("ent", (r) => {
|
|
601
|
+
r.entity("thing", entity);
|
|
602
|
+
});
|
|
603
|
+
const app = buildAppSchema(createRegistry([f]));
|
|
604
|
+
const fields = (
|
|
605
|
+
app.features[0]!.entities["thing"] as unknown as {
|
|
606
|
+
fields: Record<string, Record<string, unknown>>;
|
|
607
|
+
}
|
|
608
|
+
).fields;
|
|
609
|
+
|
|
610
|
+
expect(fields["lines"]?.["derived"]).toBeUndefined();
|
|
611
|
+
expect(fields["lines"]?.["totals"]).toBeUndefined();
|
|
612
|
+
expect(fields["lines"]?.["totalsMatch"]).toBeUndefined();
|
|
613
|
+
expect(fields["multi"]?.["multiline"]).toBeUndefined();
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
test("default: JSON-safe Arrays/Objects überleben die Projection, Nicht-JSON-Werte bleiben blockiert (fw#2497)", () => {
|
|
617
|
+
// isJsonSafeValue() now recurses into arrays/plain objects instead of
|
|
618
|
+
// only accepting string/number/boolean/null — but the defense-in-depth
|
|
619
|
+
// against smuggled function/class-instance defaults must still hold.
|
|
620
|
+
const entity = {
|
|
621
|
+
fields: {
|
|
622
|
+
tags: { type: "multiSelect", options: ["a", "b"], default: ["a"] },
|
|
623
|
+
nested: { type: "text", default: { rows: [1, "x"], flag: true } },
|
|
624
|
+
brokenFn: { type: "text", default: () => "x" },
|
|
625
|
+
brokenClass: { type: "text", default: new Date() },
|
|
626
|
+
},
|
|
627
|
+
} as unknown as EntityDefinition;
|
|
628
|
+
|
|
629
|
+
const f = defineFeature("ent", (r) => {
|
|
630
|
+
r.entity("thing", entity);
|
|
631
|
+
});
|
|
632
|
+
const app = buildAppSchema(createRegistry([f]));
|
|
633
|
+
const fields = (
|
|
634
|
+
app.features[0]!.entities["thing"] as unknown as {
|
|
635
|
+
fields: Record<string, Record<string, unknown>>;
|
|
636
|
+
}
|
|
637
|
+
).fields;
|
|
638
|
+
|
|
639
|
+
expect(fields["tags"]?.["default"]).toEqual(["a"]);
|
|
640
|
+
expect(fields["nested"]?.["default"]).toEqual({ rows: [1, "x"], flag: true });
|
|
641
|
+
expect(fields["brokenFn"]?.["default"]).toBeUndefined();
|
|
642
|
+
expect(fields["brokenClass"]?.["default"]).toBeUndefined();
|
|
643
|
+
});
|
|
644
|
+
|
|
452
645
|
test("AppSchema ist via JSON.stringify roundtrip-sicher", () => {
|
|
453
646
|
// Echter Smoke-Test des Vertrags — wenn jemand in den project-
|
|
454
647
|
// Helper eine Function reinschmuggelt, würde das hier brennen.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// fw#2490: `multiSelect` fields accept `filterable: true` at boot but the
|
|
2
|
+
// executor's screen-filter WHERE builder only ever emitted scalar equality
|
|
3
|
+
// (`col = $1`) against the jsonb-array column — Postgres rejects that as
|
|
4
|
+
// "operator does not exist: jsonb = text" the first time a filter actually
|
|
5
|
+
// runs. This proves the fix over real HTTP: eq/ne/in on a filterable
|
|
6
|
+
// multiSelect field must use jsonb containment (`@>`), not scalar equality.
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { deriveEntityTableMeta } from "../../db/entity-table-meta";
|
|
10
|
+
import { asRawClient, selectMany } from "../../db/query";
|
|
11
|
+
import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
12
|
+
import { defineFeature } from "../define-feature";
|
|
13
|
+
import { createEntity, createMultiSelectField, createTextField } from "../factories";
|
|
14
|
+
|
|
15
|
+
const equipmentEntity = createEntity({
|
|
16
|
+
table: "ms_filter_equipment",
|
|
17
|
+
fields: {
|
|
18
|
+
name: createTextField({ required: true }),
|
|
19
|
+
tags: createMultiSelectField({
|
|
20
|
+
options: ["vip", "urgent", "loaner"] as const,
|
|
21
|
+
filterable: true,
|
|
22
|
+
}),
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const LIST_QN = "checklist:query:equipment:list";
|
|
27
|
+
|
|
28
|
+
const checklistFeature = defineFeature("checklist", (r) => {
|
|
29
|
+
r.crud("equipment", equipmentEntity, {
|
|
30
|
+
write: { access: { roles: ["Admin"] } },
|
|
31
|
+
read: { access: { openToAll: true } },
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("multiSelect filterable — jsonb containment, not scalar equality (fw#2490)", () => {
|
|
36
|
+
let stack: TestStack;
|
|
37
|
+
|
|
38
|
+
beforeAll(async () => {
|
|
39
|
+
stack = await setupTestStack({ features: [checklistFeature] });
|
|
40
|
+
await unsafeCreateEntityTable(stack.db, equipmentEntity);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterAll(async () => {
|
|
44
|
+
await stack.cleanup();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
beforeEach(async () => {
|
|
48
|
+
await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
|
|
49
|
+
await asRawClient(stack.db).unsafe('DELETE FROM "ms_filter_equipment"');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
async function seed(): Promise<void> {
|
|
53
|
+
const CREATE = "checklist:write:equipment:create";
|
|
54
|
+
await stack.http.write(CREATE, { name: "Drill", tags: ["vip", "urgent"] }, TestUsers.admin);
|
|
55
|
+
await stack.http.write(CREATE, { name: "Ladder", tags: ["loaner"] }, TestUsers.admin);
|
|
56
|
+
await stack.http.write(CREATE, { name: "Saw", tags: [] }, TestUsers.admin);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
test("op:eq on a multiSelect field returns rows whose array contains the value", async () => {
|
|
60
|
+
await seed();
|
|
61
|
+
const result = await stack.http.queryOk<{
|
|
62
|
+
readonly rows: readonly { readonly name: string; readonly tags: readonly string[] }[];
|
|
63
|
+
}>(LIST_QN, { limit: 50, filter: { field: "tags", op: "eq", value: "vip" } }, TestUsers.admin);
|
|
64
|
+
|
|
65
|
+
expect(result.rows).toHaveLength(1);
|
|
66
|
+
expect(result.rows[0]?.name).toBe("Drill");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("op:eq with an array value returns rows whose array contains all listed values", async () => {
|
|
70
|
+
await seed();
|
|
71
|
+
const result = await stack.http.queryOk<{
|
|
72
|
+
readonly rows: readonly { readonly name: string }[];
|
|
73
|
+
}>(
|
|
74
|
+
LIST_QN,
|
|
75
|
+
{ limit: 50, filter: { field: "tags", op: "eq", value: ["vip", "urgent"] } },
|
|
76
|
+
TestUsers.admin,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
expect(result.rows.map((r) => r.name)).toEqual(["Drill"]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("op:ne on a multiSelect field returns rows whose array does not contain the value", async () => {
|
|
83
|
+
await seed();
|
|
84
|
+
const result = await stack.http.queryOk<{
|
|
85
|
+
readonly rows: readonly { readonly name: string }[];
|
|
86
|
+
}>(LIST_QN, { limit: 50, filter: { field: "tags", op: "ne", value: "vip" } }, TestUsers.admin);
|
|
87
|
+
|
|
88
|
+
expect(result.rows.map((r) => r.name).sort()).toEqual(["Ladder", "Saw"]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("op:in on a multiSelect field returns rows whose array contains any listed value", async () => {
|
|
92
|
+
await seed();
|
|
93
|
+
const result = await stack.http.queryOk<{
|
|
94
|
+
readonly rows: readonly { readonly name: string }[];
|
|
95
|
+
}>(
|
|
96
|
+
LIST_QN,
|
|
97
|
+
{ limit: 50, filters: [{ field: "tags", op: "in", value: ["urgent", "loaner"] }] },
|
|
98
|
+
TestUsers.admin,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
expect(result.rows.map((r) => r.name).sort()).toEqual(["Drill", "Ladder"]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("op:lt on a multiSelect field is unsatisfiable — empty result, no crash", async () => {
|
|
105
|
+
await seed();
|
|
106
|
+
const result = await stack.http.queryOk<{
|
|
107
|
+
readonly rows: readonly { readonly name: string }[];
|
|
108
|
+
}>(LIST_QN, { limit: 50, filter: { field: "tags", op: "lt", value: "vip" } }, TestUsers.admin);
|
|
109
|
+
|
|
110
|
+
expect(result.rows).toHaveLength(0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// The issue's own diagnosis (fw#2490) points at buildWhereClause in
|
|
114
|
+
// bun-db/query.ts, not the screen-filter path above — that generic query
|
|
115
|
+
// API is what direct app/handler code hits when it filters a multiSelect
|
|
116
|
+
// column without going through a screen filter. Same jsonb-array bug,
|
|
117
|
+
// separate WHERE-builder, must be covered independently.
|
|
118
|
+
describe("generic selectMany() query API (bun-db/query.ts buildWhereClause)", () => {
|
|
119
|
+
const meta = deriveEntityTableMeta("equipment", equipmentEntity);
|
|
120
|
+
|
|
121
|
+
test("scalar equality uses jsonb containment, not `=`", async () => {
|
|
122
|
+
await seed();
|
|
123
|
+
const rows = await selectMany<{ name: string }>(stack.db, meta, { tags: "vip" });
|
|
124
|
+
expect(rows.map((r) => r.name).sort()).toEqual(["Drill"]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("`ne` returns rows whose array does not contain the value", async () => {
|
|
128
|
+
await seed();
|
|
129
|
+
const rows = await selectMany<{ name: string }>(stack.db, meta, { tags: { ne: "vip" } });
|
|
130
|
+
expect(rows.map((r) => r.name).sort()).toEqual(["Ladder", "Saw"]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("`in` returns rows whose array contains any listed value", async () => {
|
|
134
|
+
await seed();
|
|
135
|
+
const rows = await selectMany<{ name: string }>(stack.db, meta, {
|
|
136
|
+
tags: { in: ["urgent", "loaner"] },
|
|
137
|
+
});
|
|
138
|
+
expect(rows.map((r) => r.name).sort()).toEqual(["Drill", "Ladder"]);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
|
|
4
|
+
import { validateBoot as validateBootRaw } from "../boot-validator";
|
|
5
|
+
import { defineFeature } from "../define-feature";
|
|
6
|
+
import { createEntity, createTextField } from "../factories";
|
|
7
|
+
|
|
8
|
+
function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
|
|
9
|
+
validateBootRaw(withBootValidatorFixture(features));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Akten-Layout: projectionDetail `layout.mode: "tabs"` + `header`/`metrics`.
|
|
13
|
+
describe("validateBoot — projectionDetail tabs (fw record-layout)", () => {
|
|
14
|
+
test("mode: tabs with only one section throws", () => {
|
|
15
|
+
const feature = defineFeature("app", (r) => {
|
|
16
|
+
r.screen({
|
|
17
|
+
id: "rent-detail",
|
|
18
|
+
type: "projectionDetail",
|
|
19
|
+
query: "app:query:rent:detail",
|
|
20
|
+
layout: {
|
|
21
|
+
mode: "tabs",
|
|
22
|
+
sections: [{ id: "overview", title: "Overview", fields: ["description"] }],
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
expect(() => validateBoot([feature])).toThrow(/tabs need at least 2 sections/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("tabs section without a title throws", () => {
|
|
30
|
+
const feature = defineFeature("app", (r) => {
|
|
31
|
+
r.screen({
|
|
32
|
+
id: "rent-detail",
|
|
33
|
+
type: "projectionDetail",
|
|
34
|
+
query: "app:query:rent:detail",
|
|
35
|
+
layout: {
|
|
36
|
+
mode: "tabs",
|
|
37
|
+
sections: [
|
|
38
|
+
{ id: "overview", fields: ["description"] },
|
|
39
|
+
{ id: "history", title: "History", fields: ["notes"] },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
expect(() => validateBoot([feature])).toThrow(/sections\[0\] has no title/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("tabs section without an id throws", () => {
|
|
48
|
+
const feature = defineFeature("app", (r) => {
|
|
49
|
+
r.screen({
|
|
50
|
+
id: "rent-detail",
|
|
51
|
+
type: "projectionDetail",
|
|
52
|
+
query: "app:query:rent:detail",
|
|
53
|
+
layout: {
|
|
54
|
+
mode: "tabs",
|
|
55
|
+
sections: [
|
|
56
|
+
{ title: "Overview", fields: ["description"] },
|
|
57
|
+
{ id: "history", title: "History", fields: ["notes"] },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
expect(() => validateBoot([feature])).toThrow(/sections\[0\] \("Overview"\) has no id/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("tabs section id that is not kebab-case throws", () => {
|
|
66
|
+
const feature = defineFeature("app", (r) => {
|
|
67
|
+
r.screen({
|
|
68
|
+
id: "rent-detail",
|
|
69
|
+
type: "projectionDetail",
|
|
70
|
+
query: "app:query:rent:detail",
|
|
71
|
+
layout: {
|
|
72
|
+
mode: "tabs",
|
|
73
|
+
sections: [
|
|
74
|
+
{ id: "Overview", title: "Overview", fields: ["description"] },
|
|
75
|
+
{ id: "history", title: "History", fields: ["notes"] },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
expect(() => validateBoot([feature])).toThrow(/must be kebab-case/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("duplicate tab ids throw", () => {
|
|
84
|
+
const feature = defineFeature("app", (r) => {
|
|
85
|
+
r.screen({
|
|
86
|
+
id: "rent-detail",
|
|
87
|
+
type: "projectionDetail",
|
|
88
|
+
query: "app:query:rent:detail",
|
|
89
|
+
layout: {
|
|
90
|
+
mode: "tabs",
|
|
91
|
+
sections: [
|
|
92
|
+
{ id: "overview", title: "Overview", fields: ["description"] },
|
|
93
|
+
{ id: "overview", title: "History", fields: ["notes"] },
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
expect(() => validateBoot([feature])).toThrow(/duplicate tab id "overview"/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("mode: tabs on entityEdit throws — tabs are projectionDetail-only", () => {
|
|
102
|
+
const feature = defineFeature("app", (r) => {
|
|
103
|
+
r.entity("rent", createEntity({ fields: { name: createTextField() } }));
|
|
104
|
+
r.screen({
|
|
105
|
+
id: "rent-edit",
|
|
106
|
+
type: "entityEdit",
|
|
107
|
+
entity: "rent",
|
|
108
|
+
layout: {
|
|
109
|
+
mode: "tabs",
|
|
110
|
+
sections: [
|
|
111
|
+
{ id: "overview", title: "Overview", columns: 1, fields: ["name"] },
|
|
112
|
+
{ id: "history", title: "History", columns: 1, fields: ["name"] },
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
118
|
+
/Screen "rent-edit" \(entityEdit\) sets mode: "tabs" — tabs are only supported on projectionDetail/,
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("metric without a fieldLabels entry throws — no fallback to the raw column name", () => {
|
|
123
|
+
const feature = defineFeature("app", (r) => {
|
|
124
|
+
r.screen({
|
|
125
|
+
id: "rent-detail",
|
|
126
|
+
type: "projectionDetail",
|
|
127
|
+
query: "app:query:rent:detail",
|
|
128
|
+
layout: { sections: [{ title: "s", fields: ["description"] }] },
|
|
129
|
+
metrics: ["balance"],
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
expect(() => validateBoot([feature])).toThrow(/metric "balance" has no entry in fieldLabels/);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("valid tabs + header + metrics declaration boots cleanly", () => {
|
|
136
|
+
const feature = defineFeature("app", (r) => {
|
|
137
|
+
r.queryHandler("rent:detail", z.object({}), async () => ({ description: "x" }), {
|
|
138
|
+
access: { openToAll: true },
|
|
139
|
+
});
|
|
140
|
+
r.screen({
|
|
141
|
+
id: "rent-detail",
|
|
142
|
+
type: "projectionDetail",
|
|
143
|
+
query: "app:query:rent:detail",
|
|
144
|
+
header: { title: "name", subtitle: "address", status: "state" },
|
|
145
|
+
metrics: ["balance", "overdueDays"],
|
|
146
|
+
fieldLabels: { balance: "rent.balance", overdueDays: "rent.overdueDays" },
|
|
147
|
+
layout: {
|
|
148
|
+
mode: "tabs",
|
|
149
|
+
sections: [
|
|
150
|
+
{ id: "overview", title: "Overview", fields: ["description"] },
|
|
151
|
+
{ id: "history", title: "History", fields: ["notes"] },
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
import { validateOwnershipRules } from "./ownership";
|
|
47
47
|
import { validatePiiAndRetention } from "./pii-retention";
|
|
48
48
|
import { validateProjectionListScreens } from "./projection-list-screens";
|
|
49
|
+
import { validateQueryOutputColumns } from "./query-output-columns";
|
|
49
50
|
import { validateQueryRefs } from "./query-refs";
|
|
50
51
|
import {
|
|
51
52
|
collectScreenQns,
|
|
@@ -222,6 +223,9 @@ export function validateBoot(
|
|
|
222
223
|
// misleading "no search parameter in its Zod schema" error instead of
|
|
223
224
|
// the clear typo message below.
|
|
224
225
|
validateQueryRefs(features);
|
|
226
|
+
// Must also run after validateQueryRefs — column/field checks below
|
|
227
|
+
// assume every `query` string already resolves to a registered handler.
|
|
228
|
+
validateQueryOutputColumns(features);
|
|
225
229
|
validateProjectionListScreens(features);
|
|
226
230
|
validateExtensionPreSaveWiring(features);
|
|
227
231
|
validateGdprStoragePersistence(features);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { ZodObject } from "zod";
|
|
2
1
|
import { QnTypes, qualifyEntityName } from "../qualified-name";
|
|
3
2
|
import type { FeatureDefinition, ProjectionListScreenDefinition, QueryHandlerDef } from "../types";
|
|
4
3
|
import { SEARCHABLE_FALSE_WHITELIST } from "./entity-list-screens";
|
|
4
|
+
import { getZodObjectShape } from "./zod-shape";
|
|
5
5
|
|
|
6
6
|
// Sibling to entity-list-screens.ts rather than an extension of it:
|
|
7
7
|
// validateOneEntityListScreen is typed to EntityListScreenDefinition and
|
|
@@ -27,7 +27,7 @@ export function buildQueryHandlerMap(
|
|
|
27
27
|
// unresolved query handlers both fall through to "capability absent" —
|
|
28
28
|
// consistent with buildAppSchema's derivation, no throw either way.
|
|
29
29
|
function schemaAccepts(schema: QueryHandlerDef["schema"] | undefined, key: string): boolean {
|
|
30
|
-
const shape = schema
|
|
30
|
+
const shape = getZodObjectShape(schema);
|
|
31
31
|
return shape !== undefined && key in shape;
|
|
32
32
|
}
|
|
33
33
|
|