@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.
@@ -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(["text", "number", "boolean", "date", "money", "decimal"]);
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-Validation.
376
- // 1) referenced entity existiert (same-feature OR cross-feature
377
- // qualifiziert per "<feature>:<entity>"). Same-feature ist
378
- // Default; cross-feature verlangt expliziten ":"-Prefix.
379
- // 2) labelField (wenn gesetzt) existiert auf der referenced Entity.
380
- // 3) Self-Reference erlaubt (entity entity).
381
- // 4) Audit-Fix: Query-Handler `<feature>:query:<entity>:list` muss
382
- // registriert sein der Renderer feuert den beim Combobox-
383
- // Open. Ohne Handler crasht die Combobox zur Laufzeit.
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
- const target = parseRefTarget(field.entity, feature.name);
393
- const targetFeature = featureMap.get(target.featureName);
394
- if (!targetFeature) {
395
- const knownFeatures = [...featureMap.keys()].sort().join(", ");
396
- throw new Error(
397
- `[Feature ${feature.name}] Reference field "${fieldName}" on entity "${entityName}" ` +
398
- `targets unknown feature "${target.featureName}" via "${field.entity}". ` +
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(feature: FeatureDefinition): void {
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
+ }