@cosmicdrift/kumiko-framework 0.183.2 → 0.185.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/db/__tests__/compound-types.test.ts +2 -2
- package/src/db/__tests__/money.test.ts +30 -14
- package/src/db/money.ts +6 -3
- package/src/engine/__tests__/boot-validator.test.ts +112 -16
- package/src/engine/__tests__/engine.test.ts +183 -0
- package/src/engine/__tests__/schema-builder.test.ts +218 -0
- package/src/engine/boot-validator/entity-handler.ts +247 -65
- package/src/engine/boot-validator/index.ts +1 -1
- package/src/engine/embedded-derived.ts +51 -0
- package/src/engine/schema-builder.ts +81 -6
- package/src/engine/types/index.ts +1 -0
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +34 -0
- package/src/pipeline/dispatch-shared.ts +19 -14
- package/src/ui-types/index.ts +1 -0
|
@@ -433,6 +433,63 @@ describe("buildInsertSchema", () => {
|
|
|
433
433
|
expect(required.safeParse({ lines: [{ accountId: "bank" }] }).success).toBe(true);
|
|
434
434
|
});
|
|
435
435
|
|
|
436
|
+
test("select sub-field accepts a listed option, rejects an unlisted string", () => {
|
|
437
|
+
const entity = createEntity({
|
|
438
|
+
table: "Test",
|
|
439
|
+
fields: {
|
|
440
|
+
lines: createEmbeddedListField({
|
|
441
|
+
status: { type: "select", options: ["draft", "sent"], required: true },
|
|
442
|
+
}),
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
const schema = buildInsertSchema(entity);
|
|
446
|
+
expect(schema.safeParse({ lines: [{ status: "sent" }] }).success).toBe(true);
|
|
447
|
+
expect(schema.safeParse({ lines: [{ status: "archived" }] }).success).toBe(false);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("reference sub-field accepts a UUID, rejects a non-UUID string", () => {
|
|
451
|
+
const entity = createEntity({
|
|
452
|
+
table: "Test",
|
|
453
|
+
fields: {
|
|
454
|
+
lines: createEmbeddedListField({
|
|
455
|
+
productId: { type: "reference", entity: "product", required: true },
|
|
456
|
+
}),
|
|
457
|
+
},
|
|
458
|
+
});
|
|
459
|
+
const schema = buildInsertSchema(entity);
|
|
460
|
+
expect(
|
|
461
|
+
schema.safeParse({ lines: [{ productId: "550e8400-e29b-41d4-a716-446655440000" }] }).success,
|
|
462
|
+
).toBe(true);
|
|
463
|
+
expect(schema.safeParse({ lines: [{ productId: "not-a-uuid" }] }).success).toBe(false);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("minItems/maxItems bound an embedded list", () => {
|
|
467
|
+
const entity = createEntity({
|
|
468
|
+
table: "Test",
|
|
469
|
+
fields: {
|
|
470
|
+
lines: createEmbeddedListField(
|
|
471
|
+
{ accountId: { type: "text", required: true } },
|
|
472
|
+
{ minItems: 2, maxItems: 3 },
|
|
473
|
+
),
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
const schema = buildInsertSchema(entity);
|
|
477
|
+
expect(schema.safeParse({ lines: [{ accountId: "a" }] }).success).toBe(false);
|
|
478
|
+
expect(schema.safeParse({ lines: [{ accountId: "a" }, { accountId: "b" }] }).success).toBe(
|
|
479
|
+
true,
|
|
480
|
+
);
|
|
481
|
+
expect(
|
|
482
|
+
schema.safeParse({
|
|
483
|
+
lines: [{ accountId: "a" }, { accountId: "b" }, { accountId: "c" }],
|
|
484
|
+
}).success,
|
|
485
|
+
).toBe(true);
|
|
486
|
+
expect(
|
|
487
|
+
schema.safeParse({
|
|
488
|
+
lines: [{ accountId: "a" }, { accountId: "b" }, { accountId: "c" }, { accountId: "d" }],
|
|
489
|
+
}).success,
|
|
490
|
+
).toBe(false);
|
|
491
|
+
});
|
|
492
|
+
|
|
436
493
|
test("money sub-field accepts signed integer minor units, rejects fractions", () => {
|
|
437
494
|
const entity = createEntity({
|
|
438
495
|
table: "Test",
|
|
@@ -603,6 +660,167 @@ describe("buildInsertSchema", () => {
|
|
|
603
660
|
});
|
|
604
661
|
});
|
|
605
662
|
|
|
663
|
+
// --- fw#1839: embedded-list timestamp sub-field ---
|
|
664
|
+
|
|
665
|
+
describe("embedded sub-field: timestamp", () => {
|
|
666
|
+
test("accepts a valid ISO datetime, rejects an invalid string", () => {
|
|
667
|
+
const entity = createEntity({
|
|
668
|
+
table: "Test",
|
|
669
|
+
fields: {
|
|
670
|
+
lines: createEmbeddedListField({
|
|
671
|
+
loggedAt: { type: "timestamp", required: true },
|
|
672
|
+
}),
|
|
673
|
+
},
|
|
674
|
+
});
|
|
675
|
+
const schema = buildInsertSchema(entity);
|
|
676
|
+
expect(schema.safeParse({ lines: [{ loggedAt: "2026-08-06T10:00:00Z" }] }).success).toBe(true);
|
|
677
|
+
expect(schema.safeParse({ lines: [{ loggedAt: "not-a-date" }] }).success).toBe(false);
|
|
678
|
+
expect(schema.safeParse({ lines: [{ loggedAt: "2026-08-06" }] }).success).toBe(false);
|
|
679
|
+
});
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
// --- fw#1839: totalsMatch cross-field validation (schema-level, shared by
|
|
683
|
+
// client form-controller and server write handler via the same
|
|
684
|
+
// z.object().safeParse() call) ---
|
|
685
|
+
|
|
686
|
+
describe("totalsMatch (fw#1839)", () => {
|
|
687
|
+
function invoiceEntity() {
|
|
688
|
+
return createEntity({
|
|
689
|
+
table: "Invoices",
|
|
690
|
+
fields: {
|
|
691
|
+
total: createMoneyField({ required: true }),
|
|
692
|
+
lines: createEmbeddedListField(
|
|
693
|
+
{ amount: { type: "money", required: true } },
|
|
694
|
+
{ totalsMatch: { amount: "total" } },
|
|
695
|
+
),
|
|
696
|
+
},
|
|
697
|
+
defaultCurrency: "EUR",
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
test("accepts when the sum of line amounts (minor units) equals the sibling total (major units)", () => {
|
|
702
|
+
const schema = buildInsertSchema(invoiceEntity());
|
|
703
|
+
const result = schema.safeParse({
|
|
704
|
+
total: { amount: 30, currency: "EUR" },
|
|
705
|
+
lines: [{ amount: 1000 }, { amount: 2000 }],
|
|
706
|
+
});
|
|
707
|
+
expect(result.success).toBe(true);
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
test("rejects when the sum diverges from the sibling total, issue path is the embedded field name", () => {
|
|
711
|
+
const schema = buildInsertSchema(invoiceEntity());
|
|
712
|
+
const result = schema.safeParse({
|
|
713
|
+
total: { amount: 30, currency: "EUR" },
|
|
714
|
+
lines: [{ amount: 1000 }, { amount: 1500 }],
|
|
715
|
+
});
|
|
716
|
+
expect(result.success).toBe(false);
|
|
717
|
+
if (!result.success) {
|
|
718
|
+
expect(result.error.issues.some((issue) => issue.path.join(".") === "lines")).toBe(true);
|
|
719
|
+
}
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
test("update payload omitting the embedded list is not checked (nothing to sum)", () => {
|
|
723
|
+
const schema = buildUpdateSchema(invoiceEntity());
|
|
724
|
+
expect(schema.safeParse({ total: { amount: 30, currency: "EUR" } }).success).toBe(true);
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
test("update payload omitting the sibling total is not checked (nothing to compare against)", () => {
|
|
728
|
+
const schema = buildUpdateSchema(invoiceEntity());
|
|
729
|
+
expect(schema.safeParse({ lines: [{ amount: 1000 }, { amount: 1500 }] }).success).toBe(true);
|
|
730
|
+
});
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
// --- kumiko-framework#1837: derived-cell server-side recomputation — the
|
|
734
|
+
// server is the authority for derived cells, a client value is overwritten
|
|
735
|
+
// with the recomputed one instead of merely checked against it. ---
|
|
736
|
+
|
|
737
|
+
describe("embedded-list derived cell recomputation (kumiko-framework#1837)", () => {
|
|
738
|
+
function orderEntity() {
|
|
739
|
+
return createEntity({
|
|
740
|
+
table: "Orders",
|
|
741
|
+
fields: {
|
|
742
|
+
lines: createEmbeddedListField(
|
|
743
|
+
{
|
|
744
|
+
qty: { type: "number", required: true },
|
|
745
|
+
price: { type: "number", required: true },
|
|
746
|
+
amount: { type: "number", required: false },
|
|
747
|
+
},
|
|
748
|
+
{ derived: { amount: { op: "multiply", from: ["qty", "price"] } } },
|
|
749
|
+
),
|
|
750
|
+
},
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
test("a matching client-sent derived cell parses through unchanged", () => {
|
|
755
|
+
const schema = buildInsertSchema(orderEntity());
|
|
756
|
+
const result = schema.safeParse({ lines: [{ qty: 3, price: 10, amount: 30 }] });
|
|
757
|
+
expect(result.success).toBe(true);
|
|
758
|
+
if (result.success) {
|
|
759
|
+
expect(result.data["lines"]).toEqual([{ qty: 3, price: 10, amount: 30 }]);
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
test("a diverging client-sent derived cell is overwritten with the server-computed value, not rejected", () => {
|
|
764
|
+
const schema = buildInsertSchema(orderEntity());
|
|
765
|
+
const result = schema.safeParse({ lines: [{ qty: 3, price: 10, amount: 99 }] });
|
|
766
|
+
expect(result.success).toBe(true);
|
|
767
|
+
if (result.success) {
|
|
768
|
+
expect(result.data["lines"]).toEqual([{ qty: 3, price: 10, amount: 30 }]);
|
|
769
|
+
}
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
test("a derived cell omitted from the payload is filled in server-side", () => {
|
|
773
|
+
const schema = buildInsertSchema(orderEntity());
|
|
774
|
+
const result = schema.safeParse({ lines: [{ qty: 3, price: 10 }] });
|
|
775
|
+
expect(result.success).toBe(true);
|
|
776
|
+
if (result.success) {
|
|
777
|
+
expect(result.data["lines"]).toEqual([{ qty: 3, price: 10, amount: 30 }]);
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
test("a missing source for a multiply-derived cell leaves the cell unset rather than 0", () => {
|
|
782
|
+
const entity = createEntity({
|
|
783
|
+
table: "Orders",
|
|
784
|
+
fields: {
|
|
785
|
+
lines: createEmbeddedListField(
|
|
786
|
+
{
|
|
787
|
+
qty: { type: "number", required: true },
|
|
788
|
+
price: { type: "number", required: false },
|
|
789
|
+
amount: { type: "number", required: false },
|
|
790
|
+
},
|
|
791
|
+
{ derived: { amount: { op: "multiply", from: ["qty", "price"] } } },
|
|
792
|
+
),
|
|
793
|
+
},
|
|
794
|
+
});
|
|
795
|
+
const schema = buildInsertSchema(entity);
|
|
796
|
+
const result = schema.safeParse({ lines: [{ qty: 3, amount: 30 }] });
|
|
797
|
+
expect(result.success).toBe(true);
|
|
798
|
+
if (result.success) {
|
|
799
|
+
const row = (result.data["lines"] as readonly Record<string, unknown>[])[0];
|
|
800
|
+
expect(row).toBeDefined();
|
|
801
|
+
expect(Object.hasOwn(row as Record<string, unknown>, "amount")).toBe(false);
|
|
802
|
+
}
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
test("an embedded-list field without `derived` leaves a client-sent value untouched", () => {
|
|
806
|
+
const entity = createEntity({
|
|
807
|
+
table: "Orders",
|
|
808
|
+
fields: {
|
|
809
|
+
lines: createEmbeddedListField({
|
|
810
|
+
qty: { type: "number", required: true },
|
|
811
|
+
amount: { type: "number", required: false },
|
|
812
|
+
}),
|
|
813
|
+
},
|
|
814
|
+
});
|
|
815
|
+
const schema = buildInsertSchema(entity);
|
|
816
|
+
const result = schema.safeParse({ lines: [{ qty: 3, amount: 999 }] });
|
|
817
|
+
expect(result.success).toBe(true);
|
|
818
|
+
if (result.success) {
|
|
819
|
+
expect(result.data["lines"]).toEqual([{ qty: 3, amount: 999 }]);
|
|
820
|
+
}
|
|
821
|
+
});
|
|
822
|
+
});
|
|
823
|
+
|
|
606
824
|
// --- Update schema (all partial) ---
|
|
607
825
|
|
|
608
826
|
describe("buildUpdateSchema", () => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { parseRefTarget } from "../parse-ref-target";
|
|
2
|
-
import type { FeatureDefinition } from "../types";
|
|
2
|
+
import type { EmbeddedFieldDef, EntityDefinition, FeatureDefinition } from "../types";
|
|
3
3
|
|
|
4
4
|
export const FILE_FIELD_TYPES = new Set(["file", "image", "files", "images"]);
|
|
5
5
|
|
|
@@ -364,7 +364,19 @@ export function validateFileFields(feature: FeatureDefinition): boolean {
|
|
|
364
364
|
|
|
365
365
|
// --- Embedded field validation ---
|
|
366
366
|
|
|
367
|
-
const VALID_EMBEDDED_SUB_TYPES = new Set([
|
|
367
|
+
const VALID_EMBEDDED_SUB_TYPES = new Set([
|
|
368
|
+
"text",
|
|
369
|
+
"number",
|
|
370
|
+
"boolean",
|
|
371
|
+
"date",
|
|
372
|
+
"money",
|
|
373
|
+
"decimal",
|
|
374
|
+
"select",
|
|
375
|
+
"reference",
|
|
376
|
+
"timestamp",
|
|
377
|
+
]);
|
|
378
|
+
|
|
379
|
+
const NUMERIC_EMBEDDED_SUB_TYPES = new Set(["number", "money", "decimal"]);
|
|
368
380
|
|
|
369
381
|
// 15 is where 10^scale exhausts a double's integer range — beyond it the
|
|
370
382
|
// scale check in the write schema could no longer hold.
|
|
@@ -372,15 +384,83 @@ function isValidEmbeddedDecimalScale(scale: number): boolean {
|
|
|
372
384
|
return Number.isInteger(scale) && scale >= 0 && scale <= 15;
|
|
373
385
|
}
|
|
374
386
|
|
|
375
|
-
// Tier 2.7e-3 + Cross-Feature: ReferenceFieldDef
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
//
|
|
383
|
-
//
|
|
387
|
+
// Tier 2.7e-3 + Cross-Feature: ReferenceFieldDef validation, shared by
|
|
388
|
+
// top-level reference fields and reference sub-fields of an embedded field
|
|
389
|
+
// (only the field-path in error messages differs, e.g. "accountId" vs
|
|
390
|
+
// "lines.accountId", so a failure is locatable either way).
|
|
391
|
+
// 1) referenced entity exists (same-feature OR cross-feature qualified via
|
|
392
|
+
// "<feature>:<entity>"). Same-feature is the default; cross-feature
|
|
393
|
+
// requires an explicit ":" prefix.
|
|
394
|
+
// 2) labelField (if set) exists on the referenced entity.
|
|
395
|
+
// 3) Query handler `<feature>:query:<entity>:list` is registered — the
|
|
396
|
+
// renderer fires it on Combobox open, so a missing handler crashes the
|
|
397
|
+
// Combobox at runtime.
|
|
398
|
+
function validateReferenceTarget(
|
|
399
|
+
entityName: string,
|
|
400
|
+
fieldPath: string,
|
|
401
|
+
refString: string,
|
|
402
|
+
labelField: string | undefined,
|
|
403
|
+
feature: FeatureDefinition,
|
|
404
|
+
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
405
|
+
): void {
|
|
406
|
+
const target = parseRefTarget(refString, feature.name);
|
|
407
|
+
const targetFeature = featureMap.get(target.featureName);
|
|
408
|
+
if (!targetFeature) {
|
|
409
|
+
const knownFeatures = [...featureMap.keys()].sort().join(", ");
|
|
410
|
+
throw new Error(
|
|
411
|
+
`[Feature ${feature.name}] Reference field "${fieldPath}" on entity "${entityName}" ` +
|
|
412
|
+
`targets unknown feature "${target.featureName}" via "${refString}". ` +
|
|
413
|
+
`Known features: ${knownFeatures}.`,
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
const targetEntity = targetFeature.entities?.[target.entityName];
|
|
417
|
+
if (!targetEntity) {
|
|
418
|
+
const known =
|
|
419
|
+
Object.keys(targetFeature.entities ?? {})
|
|
420
|
+
.sort()
|
|
421
|
+
.join(", ") || "(none)";
|
|
422
|
+
const where =
|
|
423
|
+
target.featureName === feature.name
|
|
424
|
+
? `in this feature`
|
|
425
|
+
: `in feature "${target.featureName}"`;
|
|
426
|
+
throw new Error(
|
|
427
|
+
`[Feature ${feature.name}] Reference field "${fieldPath}" on entity "${entityName}" ` +
|
|
428
|
+
`targets unknown entity "${target.entityName}" ${where}. ` +
|
|
429
|
+
`Known entities: ${known}.`,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
if (labelField !== undefined) {
|
|
433
|
+
const knownFields = Object.keys(targetEntity.fields);
|
|
434
|
+
// "id" always exists, even without an explicit field definition (PK).
|
|
435
|
+
if (labelField !== "id" && !knownFields.includes(labelField)) {
|
|
436
|
+
throw new Error(
|
|
437
|
+
`[Feature ${feature.name}] Reference field "${fieldPath}" on entity "${entityName}" ` +
|
|
438
|
+
`references labelField "${labelField}" which does not exist on entity ` +
|
|
439
|
+
`"${target.entityName}". Known fields: ${[...knownFields, "id"].sort().join(", ")}.`,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
// Pins query-handler existence. The renderer fires
|
|
444
|
+
// `<targetFeature>:query:<targetEntity>:list` on Combobox open
|
|
445
|
+
// (use-reference-lookup, ReferenceInput); without a handler that's a 404
|
|
446
|
+
// on first click. defaultEntityQueryHandler names are stored short as
|
|
447
|
+
// "<entity>:list" in feature.queryHandlers.
|
|
448
|
+
const expectedHandlerShortName = `${target.entityName}:list`;
|
|
449
|
+
if (targetFeature.queryHandlers[expectedHandlerShortName] === undefined) {
|
|
450
|
+
throw new Error(
|
|
451
|
+
`[Feature ${feature.name}] Reference field "${fieldPath}" on entity "${entityName}" ` +
|
|
452
|
+
`targets entity "${target.entityName}" but no list-query-handler is registered ` +
|
|
453
|
+
`there. Add r.queryHandler(defineEntityListHandler("${target.entityName}", ` +
|
|
454
|
+
`${target.entityName}Entity)) to feature "${target.featureName}", or pick a ` +
|
|
455
|
+
`different label/entity.`,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Tier 2.7e-3 + Cross-Feature: ReferenceFieldDef validation for top-level
|
|
461
|
+
// reference fields (self-reference, entity → entity, is allowed). The actual
|
|
462
|
+
// checks run in validateReferenceTarget — shared with the reference
|
|
463
|
+
// sub-fields of an embedded field (validateEmbeddedFields).
|
|
384
464
|
export function validateReferenceFields(
|
|
385
465
|
feature: FeatureDefinition,
|
|
386
466
|
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
@@ -388,64 +468,22 @@ export function validateReferenceFields(
|
|
|
388
468
|
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
389
469
|
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
390
470
|
if (field.type !== "reference") continue;
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
`Known features: ${knownFeatures}.`,
|
|
400
|
-
);
|
|
401
|
-
}
|
|
402
|
-
const targetEntity = targetFeature.entities?.[target.entityName];
|
|
403
|
-
if (!targetEntity) {
|
|
404
|
-
const known =
|
|
405
|
-
Object.keys(targetFeature.entities ?? {})
|
|
406
|
-
.sort()
|
|
407
|
-
.join(", ") || "(none)";
|
|
408
|
-
const where =
|
|
409
|
-
target.featureName === feature.name
|
|
410
|
-
? `in this feature`
|
|
411
|
-
: `in feature "${target.featureName}"`;
|
|
412
|
-
throw new Error(
|
|
413
|
-
`[Feature ${feature.name}] Reference field "${fieldName}" on entity "${entityName}" ` +
|
|
414
|
-
`targets unknown entity "${target.entityName}" ${where}. ` +
|
|
415
|
-
`Known entities: ${known}.`,
|
|
416
|
-
);
|
|
417
|
-
}
|
|
418
|
-
if (field.labelField !== undefined) {
|
|
419
|
-
const knownFields = Object.keys(targetEntity.fields);
|
|
420
|
-
// "id" ist immer da, auch ohne Field-Definition (PK).
|
|
421
|
-
if (field.labelField !== "id" && !knownFields.includes(field.labelField)) {
|
|
422
|
-
throw new Error(
|
|
423
|
-
`[Feature ${feature.name}] Reference field "${fieldName}" on entity "${entityName}" ` +
|
|
424
|
-
`references labelField "${field.labelField}" which does not exist on entity ` +
|
|
425
|
-
`"${target.entityName}". Known fields: ${[...knownFields, "id"].sort().join(", ")}.`,
|
|
426
|
-
);
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
// Audit-Fix #2: Query-Handler-Existenz pinnen. Renderer feuert
|
|
430
|
-
// `<targetFeature>:query:<targetEntity>:list` beim Combobox-Open
|
|
431
|
-
// (use-reference-lookup, ReferenceInput); ohne Handler kommt
|
|
432
|
-
// beim ersten Klick ein 404. defaultEntityQueryHandler-Names
|
|
433
|
-
// sind als kurz "<entity>:list" in feature.queryHandlers gespeichert.
|
|
434
|
-
const expectedHandlerShortName = `${target.entityName}:list`;
|
|
435
|
-
if (targetFeature.queryHandlers[expectedHandlerShortName] === undefined) {
|
|
436
|
-
throw new Error(
|
|
437
|
-
`[Feature ${feature.name}] Reference field "${fieldName}" on entity "${entityName}" ` +
|
|
438
|
-
`targets entity "${target.entityName}" but no list-query-handler is registered ` +
|
|
439
|
-
`there. Add r.queryHandler(defineEntityListHandler("${target.entityName}", ` +
|
|
440
|
-
`${target.entityName}Entity)) to feature "${target.featureName}", or pick a ` +
|
|
441
|
-
`different label/entity.`,
|
|
442
|
-
);
|
|
443
|
-
}
|
|
471
|
+
validateReferenceTarget(
|
|
472
|
+
entityName,
|
|
473
|
+
fieldName,
|
|
474
|
+
field.entity,
|
|
475
|
+
field.labelField,
|
|
476
|
+
feature,
|
|
477
|
+
featureMap,
|
|
478
|
+
);
|
|
444
479
|
}
|
|
445
480
|
}
|
|
446
481
|
}
|
|
447
482
|
|
|
448
|
-
export function validateEmbeddedFields(
|
|
483
|
+
export function validateEmbeddedFields(
|
|
484
|
+
feature: FeatureDefinition,
|
|
485
|
+
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
486
|
+
): void {
|
|
449
487
|
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
450
488
|
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
451
489
|
if (field.type !== "embedded") continue;
|
|
@@ -467,11 +505,155 @@ export function validateEmbeddedFields(feature: FeatureDefinition): void {
|
|
|
467
505
|
`Embedded field "${fieldName}.${subName}" on entity "${entityName}" has invalid scale ${subField.scale}. Must be an integer between 0 and 15.`,
|
|
468
506
|
);
|
|
469
507
|
}
|
|
508
|
+
if (subField.type === "select" && subField.options.length === 0) {
|
|
509
|
+
throw new Error(
|
|
510
|
+
`Embedded field "${fieldName}.${subName}" on entity "${entityName}" has empty options`,
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
// Reference sub-fields get the same target/labelField/query-handler
|
|
514
|
+
// checks as a top-level reference field — same failure mode
|
|
515
|
+
// (crashing Combobox at runtime) if skipped, so it can't stay a
|
|
516
|
+
// second-class citizen just because it's nested.
|
|
517
|
+
if (subField.type === "reference") {
|
|
518
|
+
validateReferenceTarget(
|
|
519
|
+
entityName,
|
|
520
|
+
`${fieldName}.${subName}`,
|
|
521
|
+
subField.entity,
|
|
522
|
+
subField.labelField,
|
|
523
|
+
feature,
|
|
524
|
+
featureMap,
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
validateEmbeddedListMetadata(fieldName, entityName, field, entity);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function validateEmbeddedListBounds(
|
|
535
|
+
fieldName: string,
|
|
536
|
+
entityName: string,
|
|
537
|
+
field: EmbeddedFieldDef,
|
|
538
|
+
): void {
|
|
539
|
+
if (field.minItems !== undefined && field.minItems < 0) {
|
|
540
|
+
throw new Error(
|
|
541
|
+
`Embedded-list field "${fieldName}" on entity "${entityName}" has invalid minItems ${field.minItems}. Must be >= 0.`,
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
if (field.maxItems !== undefined && field.maxItems < 1) {
|
|
545
|
+
throw new Error(
|
|
546
|
+
`Embedded-list field "${fieldName}" on entity "${entityName}" has invalid maxItems ${field.maxItems}. Must be >= 1.`,
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
if (
|
|
550
|
+
field.minItems !== undefined &&
|
|
551
|
+
field.maxItems !== undefined &&
|
|
552
|
+
field.minItems > field.maxItems
|
|
553
|
+
) {
|
|
554
|
+
throw new Error(
|
|
555
|
+
`Embedded-list field "${fieldName}" on entity "${entityName}" has minItems ${field.minItems} greater than maxItems ${field.maxItems}.`,
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function validateEmbeddedDerivedCells(
|
|
561
|
+
fieldName: string,
|
|
562
|
+
entityName: string,
|
|
563
|
+
field: EmbeddedFieldDef,
|
|
564
|
+
): void {
|
|
565
|
+
// skip: no derived cells declared — nothing to validate
|
|
566
|
+
if (field.derived === undefined) return;
|
|
567
|
+
for (const [derivedName, derivedDef] of Object.entries(field.derived)) {
|
|
568
|
+
if (!(derivedName in field.schema)) {
|
|
569
|
+
throw new Error(
|
|
570
|
+
`Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" that is not a sub-field in its schema.`,
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
for (const sourceName of derivedDef.from) {
|
|
574
|
+
if (!(sourceName in field.schema)) {
|
|
575
|
+
throw new Error(
|
|
576
|
+
`Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" reading unknown sub-field "${sourceName}".`,
|
|
577
|
+
);
|
|
470
578
|
}
|
|
471
579
|
}
|
|
472
580
|
}
|
|
473
581
|
}
|
|
474
582
|
|
|
583
|
+
function validateEmbeddedTotalsColumns(
|
|
584
|
+
fieldName: string,
|
|
585
|
+
entityName: string,
|
|
586
|
+
field: EmbeddedFieldDef,
|
|
587
|
+
): void {
|
|
588
|
+
// skip: no totals columns declared — nothing to validate
|
|
589
|
+
if (field.totals === undefined) return;
|
|
590
|
+
for (const totalName of field.totals) {
|
|
591
|
+
const totalSubField = field.schema[totalName];
|
|
592
|
+
if (!totalSubField || !NUMERIC_EMBEDDED_SUB_TYPES.has(totalSubField.type)) {
|
|
593
|
+
throw new Error(
|
|
594
|
+
`Embedded-list field "${fieldName}" on entity "${entityName}" lists "${totalName}" in totals, but it is not a number/money/decimal sub-field.`,
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function validateEmbeddedTotalsMatch(
|
|
601
|
+
fieldName: string,
|
|
602
|
+
entityName: string,
|
|
603
|
+
field: EmbeddedFieldDef,
|
|
604
|
+
entity: EntityDefinition,
|
|
605
|
+
): void {
|
|
606
|
+
// skip: no totalsMatch declared — nothing to validate
|
|
607
|
+
if (field.totalsMatch === undefined) return;
|
|
608
|
+
for (const [subFieldName, siblingFieldName] of Object.entries(field.totalsMatch)) {
|
|
609
|
+
const subField = field.schema[subFieldName];
|
|
610
|
+
if (subField?.type !== "money") {
|
|
611
|
+
throw new Error(
|
|
612
|
+
`Embedded-list field "${fieldName}" on entity "${entityName}" has a totalsMatch entry for "${subFieldName}", which is not a money sub-field in its schema.`,
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
const siblingField = entity.fields[siblingFieldName];
|
|
616
|
+
if (siblingField?.type !== "money") {
|
|
617
|
+
throw new Error(
|
|
618
|
+
`Embedded-list field "${fieldName}" on entity "${entityName}" has a totalsMatch entry mapping "${subFieldName}" to sibling field "${siblingFieldName}", which is not a money field on entity "${entityName}".`,
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function validateEmbeddedDerivedAndTotals(
|
|
625
|
+
fieldName: string,
|
|
626
|
+
entityName: string,
|
|
627
|
+
field: EmbeddedFieldDef,
|
|
628
|
+
entity: EntityDefinition,
|
|
629
|
+
): void {
|
|
630
|
+
validateEmbeddedDerivedCells(fieldName, entityName, field);
|
|
631
|
+
validateEmbeddedTotalsColumns(fieldName, entityName, field);
|
|
632
|
+
validateEmbeddedTotalsMatch(fieldName, entityName, field, entity);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function validateEmbeddedListMetadata(
|
|
636
|
+
fieldName: string,
|
|
637
|
+
entityName: string,
|
|
638
|
+
field: EmbeddedFieldDef,
|
|
639
|
+
entity: EntityDefinition,
|
|
640
|
+
): void {
|
|
641
|
+
validateEmbeddedListBounds(fieldName, entityName, field);
|
|
642
|
+
if (
|
|
643
|
+
field.multiple !== true &&
|
|
644
|
+
(field.minItems !== undefined ||
|
|
645
|
+
field.maxItems !== undefined ||
|
|
646
|
+
field.derived !== undefined ||
|
|
647
|
+
field.totals !== undefined ||
|
|
648
|
+
field.totalsMatch !== undefined)
|
|
649
|
+
) {
|
|
650
|
+
throw new Error(
|
|
651
|
+
`Embedded field "${fieldName}" on entity "${entityName}" sets minItems/maxItems/derived/totals/totalsMatch, which is only valid on an embedded LIST field (multiple: true).`,
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
validateEmbeddedDerivedAndTotals(fieldName, entityName, field, entity);
|
|
655
|
+
}
|
|
656
|
+
|
|
475
657
|
// --- MultiSelect field validation ---
|
|
476
658
|
//
|
|
477
659
|
// options muss non-empty sein (sonst wäre das Feld nicht benutzbar) und
|
|
@@ -166,7 +166,7 @@ export function validateBoot(
|
|
|
166
166
|
if (validateFileFields(feature)) hasFileFields = true;
|
|
167
167
|
validatePiiAndRetention(feature);
|
|
168
168
|
validateApiExposureMatching(feature, allExposedApis, featureMap);
|
|
169
|
-
validateEmbeddedFields(feature);
|
|
169
|
+
validateEmbeddedFields(feature, featureMap);
|
|
170
170
|
validateMultiSelectFields(feature);
|
|
171
171
|
validateReferenceFields(feature, featureMap);
|
|
172
172
|
validateTransitions(feature);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { EmbeddedDerivedCellDef } from "./types";
|
|
2
|
+
|
|
3
|
+
/** Computes a derived cell from its source values. Missing/non-numeric
|
|
4
|
+
* sources are treated as 0 for "sum"/"subtract"; "multiply" with any
|
|
5
|
+
* missing source returns undefined (an incomplete product isn't a
|
|
6
|
+
* meaningful partial value). Money cells are minor-unit integers — this
|
|
7
|
+
* function is unit-agnostic, it just does arithmetic on whatever numbers
|
|
8
|
+
* it's given (caller passes minor units for money, not major/float). */
|
|
9
|
+
export function computeDerivedCellValue(
|
|
10
|
+
op: EmbeddedDerivedCellDef["op"],
|
|
11
|
+
values: readonly (number | undefined)[],
|
|
12
|
+
): number | undefined {
|
|
13
|
+
if (op === "multiply") {
|
|
14
|
+
if (values.some((value) => value === undefined)) return undefined;
|
|
15
|
+
return (values as readonly number[]).reduce((product, value) => product * value, 1);
|
|
16
|
+
}
|
|
17
|
+
const numeric = values.map((value) => value ?? 0);
|
|
18
|
+
if (op === "sum") return numeric.reduce((sum, value) => sum + value, 0);
|
|
19
|
+
// subtract: first value minus every subsequent value.
|
|
20
|
+
const [first, ...rest] = numeric;
|
|
21
|
+
return rest.reduce((remainder, value) => remainder - value, first ?? 0);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Recomputes every derived cell of an embedded-list row from its raw
|
|
25
|
+
* values, overwriting whatever the client sent instead of merely checking
|
|
26
|
+
* it — the server is the authority for derived cells. Reads source values
|
|
27
|
+
* from the original row (never from an already-recomputed derived cell),
|
|
28
|
+
* so the iteration order of `derived` never matters. A row that isn't a
|
|
29
|
+
* plain object (already invalid, or not this field's shape) passes through
|
|
30
|
+
* untouched — validation downstream rejects it. */
|
|
31
|
+
export function withDerivedCells(
|
|
32
|
+
row: unknown,
|
|
33
|
+
derived: Readonly<Record<string, EmbeddedDerivedCellDef>>,
|
|
34
|
+
): unknown {
|
|
35
|
+
if (typeof row !== "object" || row === null || Array.isArray(row)) return row;
|
|
36
|
+
const source = row as Readonly<Record<string, unknown>>;
|
|
37
|
+
const copy: Record<string, unknown> = { ...source };
|
|
38
|
+
for (const [cellName, def] of Object.entries(derived)) {
|
|
39
|
+
const sourceValues = def.from.map((sourceField) => {
|
|
40
|
+
const value = source[sourceField];
|
|
41
|
+
return typeof value === "number" ? value : undefined;
|
|
42
|
+
});
|
|
43
|
+
const computed = computeDerivedCellValue(def.op, sourceValues);
|
|
44
|
+
if (computed === undefined) {
|
|
45
|
+
delete copy[cellName];
|
|
46
|
+
} else {
|
|
47
|
+
copy[cellName] = computed;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return copy;
|
|
51
|
+
}
|