@cosmicdrift/kumiko-framework 0.193.1 → 0.195.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.
Files changed (38) hide show
  1. package/package.json +3 -3
  2. package/src/api/__tests__/http-route-rate-limit.integration.test.ts +71 -0
  3. package/src/api/server.ts +1 -1
  4. package/src/db/__tests__/migrate-generator.test.ts +17 -0
  5. package/src/db/__tests__/money.test.ts +41 -5
  6. package/src/db/event-store-executor-read.ts +1 -1
  7. package/src/db/index.ts +1 -1
  8. package/src/db/migrate-generator.ts +13 -3
  9. package/src/db/money.ts +34 -4
  10. package/src/derivatives/__tests__/derivatives-context.integration.test.ts +73 -6
  11. package/src/derivatives/__tests__/derivatives-context.test.ts +9 -2
  12. package/src/derivatives/__tests__/variant-key.test.ts +2 -2
  13. package/src/derivatives/__tests__/variant-route.integration.test.ts +28 -0
  14. package/src/derivatives/derivatives-context.ts +7 -7
  15. package/src/derivatives/variant-key.ts +1 -1
  16. package/src/engine/__tests__/boot-validator.test.ts +140 -0
  17. package/src/engine/__tests__/embedded-derived.test.ts +35 -0
  18. package/src/engine/__tests__/engine.test.ts +20 -0
  19. package/src/engine/__tests__/schema-builder.test.ts +93 -0
  20. package/src/engine/boot-validator/entity-handler.ts +64 -5
  21. package/src/engine/boot-validator/screens.ts +57 -36
  22. package/src/engine/embedded-derived.ts +9 -1
  23. package/src/engine/schema-builder.ts +33 -23
  24. package/src/entrypoint/__tests__/split-deploy.integration.test.ts +37 -6
  25. package/src/errors/zod-bridge.ts +4 -9
  26. package/src/event-store/__tests__/perf.integration.test.ts +2 -11
  27. package/src/files/file-routes.ts +20 -6
  28. package/src/files/storage-tracking.ts +2 -1
  29. package/src/jobs/job-runner.ts +18 -9
  30. package/src/logging/__tests__/fallback-logger.test.ts +43 -0
  31. package/src/logging/utils.ts +14 -1
  32. package/src/observability/__tests__/metric-validator.test.ts +10 -2
  33. package/src/observability/__tests__/metrics-handle.test.ts +30 -0
  34. package/src/observability/metric-validator.ts +4 -3
  35. package/src/observability/metrics-handle.ts +24 -12
  36. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +69 -5
  37. package/src/pipeline/dispatch-shared.ts +12 -9
  38. package/src/ui-types/index.ts +1 -0
@@ -1487,6 +1487,146 @@ describe("boot-validator", () => {
1487
1487
  ];
1488
1488
  expect(() => validateBoot(features)).toThrow(/must match/);
1489
1489
  });
1490
+
1491
+ test("rejects size beyond the max variant edge", () => {
1492
+ const features = [
1493
+ defineFeature("profile", (r) => {
1494
+ r.entity(
1495
+ "person",
1496
+ createEntity({
1497
+ fields: {
1498
+ avatar: createImageField({
1499
+ variants: { huge: { size: { width: 100000, height: 100000 } } },
1500
+ }),
1501
+ },
1502
+ }),
1503
+ );
1504
+ }),
1505
+ ];
1506
+ expect(() => validateBoot(features)).toThrow(/size/);
1507
+ });
1508
+
1509
+ test("rejects maxEdge beyond the max variant edge", () => {
1510
+ const features = [
1511
+ defineFeature("profile", (r) => {
1512
+ r.entity(
1513
+ "person",
1514
+ createEntity({
1515
+ fields: {
1516
+ avatar: createImageField({ variants: { huge: { maxEdge: 100000 } } }),
1517
+ },
1518
+ }),
1519
+ );
1520
+ }),
1521
+ ];
1522
+ expect(() => validateBoot(features)).toThrow(/maxEdge/);
1523
+ });
1524
+
1525
+ test("rejects a negative blur", () => {
1526
+ const features = [
1527
+ defineFeature("profile", (r) => {
1528
+ r.entity(
1529
+ "person",
1530
+ createEntity({
1531
+ fields: {
1532
+ avatar: createImageField({ variants: { thumb: { maxEdge: 200, blur: -5 } } }),
1533
+ },
1534
+ }),
1535
+ );
1536
+ }),
1537
+ ];
1538
+ expect(() => validateBoot(features)).toThrow(/blur/);
1539
+ });
1540
+
1541
+ test("rejects a blur far beyond the renderer's supported sigma", () => {
1542
+ const features = [
1543
+ defineFeature("profile", (r) => {
1544
+ r.entity(
1545
+ "person",
1546
+ createEntity({
1547
+ fields: {
1548
+ avatar: createImageField({
1549
+ variants: { thumb: { maxEdge: 200, blur: 100000 } },
1550
+ }),
1551
+ },
1552
+ }),
1553
+ );
1554
+ }),
1555
+ ];
1556
+ expect(() => validateBoot(features)).toThrow(/blur/);
1557
+ });
1558
+
1559
+ test("accepts a valid blur", () => {
1560
+ process.env["FILE_STORAGE_PROVIDER"] = "local";
1561
+ try {
1562
+ const features = [
1563
+ defineFeature("profile", (r) => {
1564
+ r.entity(
1565
+ "person",
1566
+ createEntity({
1567
+ fields: {
1568
+ avatar: createImageField({ variants: { thumb: { maxEdge: 200, blur: 8 } } }),
1569
+ },
1570
+ }),
1571
+ );
1572
+ }),
1573
+ ];
1574
+ expect(() => validateBoot(features)).not.toThrow();
1575
+ } finally {
1576
+ delete process.env["FILE_STORAGE_PROVIDER"];
1577
+ }
1578
+ });
1579
+
1580
+ test("rejects a blurRegion that overflows the unit square", () => {
1581
+ const features = [
1582
+ defineFeature("profile", (r) => {
1583
+ r.entity(
1584
+ "person",
1585
+ createEntity({
1586
+ fields: {
1587
+ avatar: createImageField({
1588
+ variants: {
1589
+ thumb: {
1590
+ maxEdge: 200,
1591
+ blurRegions: [{ x: -0.3, y: 0, width: 0.99, height: 0 }],
1592
+ },
1593
+ },
1594
+ }),
1595
+ },
1596
+ }),
1597
+ );
1598
+ }),
1599
+ ];
1600
+ expect(() => validateBoot(features)).toThrow(/blurRegions/);
1601
+ });
1602
+
1603
+ test("accepts a valid blurRegion", () => {
1604
+ process.env["FILE_STORAGE_PROVIDER"] = "local";
1605
+ try {
1606
+ const features = [
1607
+ defineFeature("profile", (r) => {
1608
+ r.entity(
1609
+ "person",
1610
+ createEntity({
1611
+ fields: {
1612
+ avatar: createImageField({
1613
+ variants: {
1614
+ thumb: {
1615
+ maxEdge: 200,
1616
+ blurRegions: [{ x: 0.1, y: 0.1, width: 0.2, height: 0.2 }],
1617
+ },
1618
+ },
1619
+ }),
1620
+ },
1621
+ }),
1622
+ );
1623
+ }),
1624
+ ];
1625
+ expect(() => validateBoot(features)).not.toThrow();
1626
+ } finally {
1627
+ delete process.env["FILE_STORAGE_PROVIDER"];
1628
+ }
1629
+ });
1490
1630
  });
1491
1631
 
1492
1632
  // --- entityList column-renderer form-check ---
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { roundDerivedCellValue } from "../embedded-derived";
3
+
4
+ describe("roundDerivedCellValue — float-noise vs. genuine near-half values", () => {
5
+ test("money: float-multiplication noise just below a half-step still rounds up (deliberate)", () => {
6
+ // 1.005 * 100 === 100.49999999999999 in IEEE754 — this is the case the
7
+ // toPrecision(15) normalisation exists for: it must round to 101 minor
8
+ // units (100.5 → away-from-zero), not 100.
9
+ expect(roundDerivedCellValue(100.49999999999999, { type: "money" })).toBe(101);
10
+ });
11
+
12
+ test("money: a value within ~1e-16 of a half-step also snaps up, even though it is not float noise", () => {
13
+ // Pinned trade-off (see the `ponytail:` comment in embedded-derived.ts):
14
+ // toPrecision(15) cannot distinguish "float noise from a real
15
+ // multiplication" from "a value that happens to sit just below .5" —
16
+ // both normalise to the same rounded string and both round up here.
17
+ expect(roundDerivedCellValue(0.49999999999999994, { type: "money" })).toBe(1);
18
+ });
19
+
20
+ test("money: a value clearly below the half-step still rounds down", () => {
21
+ expect(roundDerivedCellValue(0.49, { type: "money" })).toBe(0);
22
+ });
23
+
24
+ test("decimal: respects the target scale", () => {
25
+ expect(roundDerivedCellValue(1.2345, { type: "decimal", scale: 2 })).toBe(1.23);
26
+ });
27
+
28
+ test("non-money/decimal targets pass through unchanged", () => {
29
+ expect(roundDerivedCellValue(1.23456, { type: "number" })).toBe(1.23456);
30
+ });
31
+
32
+ test("decimal with no scale: passes the value through unrounded instead of truncating to 0 decimals", () => {
33
+ expect(roundDerivedCellValue(1.2345, { type: "decimal", scale: undefined })).toBe(1.2345);
34
+ });
35
+ });
@@ -1023,6 +1023,26 @@ describe("createApp", () => {
1023
1023
  );
1024
1024
  });
1025
1025
 
1026
+ test("rejects embedded-list required:true combined with minItems:0", () => {
1027
+ const feature = defineFeature("test", (r) => {
1028
+ r.entity(
1029
+ "doc",
1030
+ createEntity({
1031
+ table: "Docs",
1032
+ fields: {
1033
+ lines: createEmbeddedListField(
1034
+ { accountId: { type: "text" } },
1035
+ { required: true, minItems: 0 },
1036
+ ),
1037
+ },
1038
+ }),
1039
+ );
1040
+ });
1041
+ expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow(
1042
+ "required:true and minItems:0",
1043
+ );
1044
+ });
1045
+
1026
1046
  test("rejects derived cell referencing an unknown sub-field", () => {
1027
1047
  const feature = defineFeature("test", (r) => {
1028
1048
  r.entity(
@@ -724,10 +724,51 @@ describe("totalsMatch (fw#1839)", () => {
724
724
  expect(schema.safeParse({ total: { amount: 30, currency: "EUR" } }).success).toBe(true);
725
725
  });
726
726
 
727
+ // kumiko-framework#1972: a round total (30 EUR = 3000 minor) still passes
728
+ // even if the major/minor conversion is silently skipped, because 30 and
729
+ // 3000 both "look" plausible under either interpretation. A crooked
730
+ // amount catches that a round-number fixture wouldn't.
731
+ test("accepts a crooked total (1234.56 EUR) whose minor-unit line sum matches exactly", () => {
732
+ const schema = buildInsertSchema(invoiceEntity());
733
+ const result = schema.safeParse({
734
+ total: { amount: 1234.56, currency: "EUR" },
735
+ lines: [{ amount: 100000 }, { amount: 23456 }],
736
+ });
737
+ expect(result.success).toBe(true);
738
+ });
739
+
740
+ // The exact shape of the reported solon incident: a genuinely wrong total
741
+ // (100.00 EUR entered as if it were already minor units, i.e. 1.00 EUR)
742
+ // must still be rejected — proves the refinement doesn't silently pass a
743
+ // mismatch through by accident.
744
+ test("rejects a total that was itself entered/scaled wrong (100x too small)", () => {
745
+ const schema = buildInsertSchema(invoiceEntity());
746
+ const result = schema.safeParse({
747
+ total: { amount: 1, currency: "EUR" }, // should have been 100
748
+ lines: [{ amount: 10000 }], // 100.00 EUR
749
+ });
750
+ expect(result.success).toBe(false);
751
+ if (!result.success) {
752
+ expect(result.error.issues.some((issue) => issue.path.join(".") === "lines")).toBe(true);
753
+ }
754
+ });
755
+
727
756
  test("update payload omitting the sibling total is not checked (nothing to compare against)", () => {
728
757
  const schema = buildUpdateSchema(invoiceEntity());
729
758
  expect(schema.safeParse({ lines: [{ amount: 1000 }, { amount: 1500 }] }).success).toBe(true);
730
759
  });
760
+
761
+ test("rejects a sibling total tagged with a currency other than the entity's default, even when the raw minor-unit amounts match", () => {
762
+ const schema = buildInsertSchema(invoiceEntity());
763
+ const result = schema.safeParse({
764
+ total: { amount: 30, currency: "USD" },
765
+ lines: [{ amount: 1000 }, { amount: 2000 }],
766
+ });
767
+ expect(result.success).toBe(false);
768
+ if (!result.success) {
769
+ expect(result.error.issues.some((issue) => issue.path.join(".") === "total")).toBe(true);
770
+ }
771
+ });
731
772
  });
732
773
 
733
774
  // --- kumiko-framework#1837: derived-cell server-side recomputation — the
@@ -959,6 +1000,58 @@ describe("embedded-list derived cell recomputation (kumiko-framework#1837)", ()
959
1000
  expect(row?.["amount"]).toBe(3);
960
1001
  }
961
1002
  });
1003
+
1004
+ test("a sum of scale-3 sources on a scale-2 decimal target is rounded, not rejected as over-scale", () => {
1005
+ const entity = createEntity({
1006
+ table: "Orders",
1007
+ fields: {
1008
+ lines: createEmbeddedListField(
1009
+ {
1010
+ a: { type: "decimal", scale: 3, required: true },
1011
+ b: { type: "decimal", scale: 3, required: true },
1012
+ c: { type: "decimal", scale: 3, required: true },
1013
+ total: { type: "decimal", scale: 2, required: false },
1014
+ },
1015
+ { derived: { total: { op: "sum", from: ["a", "b", "c"] } } },
1016
+ ),
1017
+ },
1018
+ });
1019
+ const schema = buildInsertSchema(entity);
1020
+ // Each source is in-scale for its own scale-3 field, but the sum
1021
+ // (0.333) has 3 decimal digits — over-scale for the scale-2 target
1022
+ // without the rounding this PR added.
1023
+ const result = schema.safeParse({ lines: [{ a: 0.111, b: 0.111, c: 0.111 }] });
1024
+ expect(result.success).toBe(true);
1025
+ if (result.success) {
1026
+ const row = (result.data["lines"] as readonly Record<string, unknown>[])[0];
1027
+ expect(row?.["total"]).toBe(0.33);
1028
+ }
1029
+ });
1030
+
1031
+ test("a subtract landing on a money target rounds to whole minor units", () => {
1032
+ const entity = createEntity({
1033
+ table: "Orders",
1034
+ fields: {
1035
+ lines: createEmbeddedListField(
1036
+ {
1037
+ gross: { type: "decimal", scale: 2, required: true },
1038
+ refund: { type: "decimal", scale: 2, required: true },
1039
+ total: { type: "money", required: false },
1040
+ },
1041
+ { derived: { total: { op: "subtract", from: ["gross", "refund"] } } },
1042
+ ),
1043
+ },
1044
+ });
1045
+ const schema = buildInsertSchema(entity);
1046
+ // 2400.58 - 93 = 2307.58 minor units — not representable by money's
1047
+ // integer constraint without rounding.
1048
+ const result = schema.safeParse({ lines: [{ gross: 2400.58, refund: 93 }] });
1049
+ expect(result.success).toBe(true);
1050
+ if (result.success) {
1051
+ const row = (result.data["lines"] as readonly Record<string, unknown>[])[0];
1052
+ expect(row?.["total"]).toBe(2308);
1053
+ }
1054
+ });
962
1055
  });
963
1056
 
964
1057
  // --- Update schema (all partial) ---
@@ -1,4 +1,4 @@
1
- import type { VariantSpec } from "@cosmicdrift/kumiko-types/derivatives-types";
1
+ import type { BlurRegion, VariantSpec } from "@cosmicdrift/kumiko-types/derivatives-types";
2
2
  import { VARIANT_NAME_PATTERN } from "../../derivatives/variant-key";
3
3
  import { parseRefTarget } from "../parse-ref-target";
4
4
  import type { EmbeddedFieldDef, EntityDefinition, FeatureDefinition } from "../types";
@@ -557,6 +557,15 @@ function validateEmbeddedListBounds(
557
557
  `Embedded-list field "${fieldName}" on entity "${entityName}" has minItems ${field.minItems} greater than maxItems ${field.maxItems}.`,
558
558
  );
559
559
  }
560
+ // required:true means "at least one row" (schema-builder.ts falls back to
561
+ // minItems:1 for that); an explicit minItems:0 would silently win over
562
+ // that and let a required list submit empty — reject the contradiction
563
+ // instead of picking one side of it for the caller.
564
+ if (field.required === true && field.minItems === 0) {
565
+ throw new Error(
566
+ `Embedded-list field "${fieldName}" on entity "${entityName}" sets required:true and minItems:0 — these contradict each other. Drop minItems (defaults to 1) or set required:false.`,
567
+ );
568
+ }
560
569
  }
561
570
 
562
571
  function validateEmbeddedDerivedCells(
@@ -693,6 +702,31 @@ function isPositiveInt(value: number): boolean {
693
702
  return Number.isInteger(value) && value > 0;
694
703
  }
695
704
 
705
+ // Renderers cap dimensions in practice (sharp refuses output above 0x1000000
706
+ // pixels); 8192 per edge is generously above any real thumbnail/preview use
707
+ // and keeps a boot-declared spec from becoming a memory-exhaustion vector.
708
+ const MAX_VARIANT_EDGE_PX = 8192;
709
+ // sharp's blur() accepts a sigma of 0.3..1000; anything above that throws at
710
+ // render time, months after boot, on the first request for that variant.
711
+ const MAX_BLUR_SIGMA = 1000;
712
+
713
+ function isValidBlurRegion(region: BlurRegion): boolean {
714
+ const { x, y, width, height } = region;
715
+ return (
716
+ Number.isFinite(x) &&
717
+ Number.isFinite(y) &&
718
+ Number.isFinite(width) &&
719
+ Number.isFinite(height) &&
720
+ x >= 0 &&
721
+ y >= 0 &&
722
+ width >= 0 &&
723
+ height >= 0 &&
724
+ x + width <= 1 &&
725
+ y + height <= 1
726
+ );
727
+ }
728
+
729
+ // kumiko-lint-ignore complexity-budget blur/size bounds added for boot-time DoS guard
696
730
  function assertValidVariantSpec(name: string, spec: VariantSpec, where: string): void {
697
731
  if (!VARIANT_NAME_PATTERN.test(name)) {
698
732
  throw new Error(
@@ -704,15 +738,23 @@ function assertValidVariantSpec(name: string, spec: VariantSpec, where: string):
704
738
  }
705
739
  if (
706
740
  spec.size !== undefined &&
707
- !(isPositiveInt(spec.size.width) && isPositiveInt(spec.size.height))
741
+ !(
742
+ isPositiveInt(spec.size.width) &&
743
+ isPositiveInt(spec.size.height) &&
744
+ spec.size.width <= MAX_VARIANT_EDGE_PX &&
745
+ spec.size.height <= MAX_VARIANT_EDGE_PX
746
+ )
708
747
  ) {
709
748
  throw new Error(
710
- `Image variant "${name}" ${where} has a non-positive-integer "size" (${spec.size.width}x${spec.size.height}).`,
749
+ `Image variant "${name}" ${where} has an invalid "size" (${spec.size.width}x${spec.size.height}) — must be a positive integer up to ${MAX_VARIANT_EDGE_PX}px per edge.`,
711
750
  );
712
751
  }
713
- if (spec.maxEdge !== undefined && !isPositiveInt(spec.maxEdge)) {
752
+ if (
753
+ spec.maxEdge !== undefined &&
754
+ !(isPositiveInt(spec.maxEdge) && spec.maxEdge <= MAX_VARIANT_EDGE_PX)
755
+ ) {
714
756
  throw new Error(
715
- `Image variant "${name}" ${where} has a non-positive-integer "maxEdge" (${spec.maxEdge}).`,
757
+ `Image variant "${name}" ${where} has an invalid "maxEdge" (${spec.maxEdge}) — must be a positive integer up to ${MAX_VARIANT_EDGE_PX}px.`,
716
758
  );
717
759
  }
718
760
  if (spec.quality !== undefined && !(isPositiveInt(spec.quality) && spec.quality <= 100)) {
@@ -720,6 +762,23 @@ function assertValidVariantSpec(name: string, spec: VariantSpec, where: string):
720
762
  `Image variant "${name}" ${where} has "quality" ${spec.quality} — must be an integer in 1..100.`,
721
763
  );
722
764
  }
765
+ if (
766
+ spec.blur !== undefined &&
767
+ !(Number.isFinite(spec.blur) && spec.blur > 0 && spec.blur <= MAX_BLUR_SIGMA)
768
+ ) {
769
+ throw new Error(
770
+ `Image variant "${name}" ${where} has "blur" ${spec.blur} — must be a finite number in (0, ${MAX_BLUR_SIGMA}].`,
771
+ );
772
+ }
773
+ if (spec.blurRegions !== undefined) {
774
+ for (const region of spec.blurRegions) {
775
+ if (!isValidBlurRegion(region)) {
776
+ throw new Error(
777
+ `Image variant "${name}" ${where} has an invalid "blurRegions" entry ${JSON.stringify(region)} — x/y/width/height must be within 0..1 and x+width/y+height must not exceed 1.`,
778
+ );
779
+ }
780
+ }
781
+ }
723
782
  }
724
783
 
725
784
  // A bad spec is only visible when someone finally requests that variant —
@@ -8,7 +8,7 @@ import { rowMetaFieldNames } from "../../db/table-builder";
8
8
  import { isValidQn, qualifyEntityName } from "../qualified-name";
9
9
  import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
10
10
  import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../screen-helpers";
11
- import type { EntityDefinition, FeatureDefinition } from "../types";
11
+ import type { EntityDefinition, FeatureDefinition, FieldDefinition } from "../types";
12
12
  import type {
13
13
  DashboardCustomPanel,
14
14
  DashboardFilterDefinition,
@@ -27,7 +27,12 @@ import type {
27
27
  // Mirrors FIELD_TYPES_WITHOUT_WIDGET in packages/renderer/src/app/form-schema.ts.
28
28
  // Can't import it directly — renderer depends on framework, not the reverse.
29
29
  // Keep both lists in sync when a field type gains or loses an auto-wired widget.
30
- const NO_WIDGET_FIELD_TYPES = new Set(["jsonb", "embedded", "files", "images"]);
30
+ const NO_WIDGET_FIELD_TYPES: ReadonlySet<FieldDefinition["type"]> = new Set([
31
+ "jsonb",
32
+ "embedded",
33
+ "files",
34
+ "images",
35
+ ]);
31
36
 
32
37
  // A field type in NO_WIDGET_FIELD_TYPES renders read-only on the auto-wired
33
38
  // entityEdit path (#1925) — a required field the user can never fill would
@@ -46,9 +51,7 @@ function validateNoWidgetRequiredField(
46
51
  if (fieldDef === undefined || !NO_WIDGET_FIELD_TYPES.has(fieldDef.type)) return;
47
52
  // Embedded LIST fields (`multiple: true`) get their own EmbeddedListField
48
53
  // grid widget (#1838) — only plain (non-list) embedded has no widget.
49
- const isEmbeddedList =
50
- fieldDef.type === "embedded" &&
51
- (fieldDef as unknown as { multiple?: boolean }).multiple === true;
54
+ const isEmbeddedList = fieldDef.type === "embedded" && fieldDef.multiple === true;
52
55
  // skip: list variant has a widget — not the no-widget case this guard targets.
53
56
  if (isEmbeddedList) return;
54
57
  // skip: already read-only by spec — no fillable widget needed regardless of type.
@@ -115,13 +118,9 @@ function validateRowActionNavigateParams(
115
118
  }
116
119
  }
117
120
 
118
- // Wizard layouts (mode: "wizard") render one section per step — a single
119
- // step (or a step without a title, which would leave the progress
120
- // indicator blank) defeats the point, so both fail at boot rather than
121
- // as a broken step UI. Missing/blank titles are checked identically for
122
- // both section kinds — EditExtensionSection.title is required by type,
123
- // but that doesn't stop author code that circumvented the check from
124
- // passing an empty string.
121
+ // Wizard layouts (mode: "wizard") need >= 2 titled sections — a single or
122
+ // untitled step would leave the progress indicator blank, so both fail at
123
+ // boot rather than as a broken step UI.
125
124
  function validateWizardLayout(
126
125
  featureName: string,
127
126
  screenId: string,
@@ -276,6 +275,25 @@ function resolveScreenTargetQn(featureName: string, target: string): string {
276
275
  return isValidQn(target) ? target : qualifyEntityName(featureName, "screen", target);
277
276
  }
278
277
 
278
+ function validateScreenNavTarget(
279
+ featureName: string,
280
+ screenId: string,
281
+ screenKind: string,
282
+ fieldName: string,
283
+ value: string,
284
+ allScreenQns: ReadonlySet<string>,
285
+ screens: FeatureDefinition["screens"],
286
+ ): void {
287
+ const candidateQn = resolveScreenTargetQn(featureName, value);
288
+ if (!allScreenQns.has(candidateQn)) {
289
+ throw new Error(
290
+ `[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${fieldName} "${value}" ` +
291
+ `does not resolve to a registered screen (checked "${candidateQn}"). Known screens ` +
292
+ `in this feature: ${[...Object.keys(screens)].sort().join(", ") || "(none)"}.`,
293
+ );
294
+ }
295
+ }
296
+
279
297
  export function validateScreens(
280
298
  feature: FeatureDefinition,
281
299
  featureMap: ReadonlyMap<string, FeatureDefinition>,
@@ -554,26 +572,28 @@ export function validateScreens(
554
572
  // (`<feature>:screen:<id>`) — der Renderer strippt letztere beim
555
573
  // Navigieren auf die kurze ID (lastSegment), die der nav-Router
556
574
  // app-weit auflöst (#1946).
557
- const candidateQn = resolveScreenTargetQn(feature.name, screen.redirect);
558
- if (!allScreenQns.has(candidateQn)) {
559
- throw new Error(
560
- `[Feature ${feature.name}] Screen "${screenId}" (actionForm) redirect "${screen.redirect}" ` +
561
- `does not resolve to a registered screen (checked "${candidateQn}"). Known screens ` +
562
- `in this feature: ${[...Object.keys(feature.screens)].sort().join(", ") || "(none)"}.`,
563
- );
564
- }
575
+ validateScreenNavTarget(
576
+ feature.name,
577
+ screenId,
578
+ "actionForm",
579
+ "redirect",
580
+ screen.redirect,
581
+ allScreenQns,
582
+ feature.screens,
583
+ );
565
584
  }
566
585
  if (typeof screen.cancelTarget === "string") {
567
586
  // Gleiche Regel wie redirect — `false` (kein Cancel-Button)
568
587
  // braucht keine Validierung.
569
- const candidateQn = resolveScreenTargetQn(feature.name, screen.cancelTarget);
570
- if (!allScreenQns.has(candidateQn)) {
571
- throw new Error(
572
- `[Feature ${feature.name}] Screen "${screenId}" (actionForm) cancelTarget "${screen.cancelTarget}" ` +
573
- `does not resolve to a registered screen (checked "${candidateQn}"). Known screens ` +
574
- `in this feature: ${[...Object.keys(feature.screens)].sort().join(", ") || "(none)"}.`,
575
- );
576
- }
588
+ validateScreenNavTarget(
589
+ feature.name,
590
+ screenId,
591
+ "actionForm",
592
+ "cancelTarget",
593
+ screen.cancelTarget,
594
+ allScreenQns,
595
+ feature.screens,
596
+ );
577
597
  }
578
598
  continue;
579
599
  }
@@ -882,14 +902,15 @@ export function validateScreens(
882
902
  if (screen.redirect !== undefined) {
883
903
  // Same rule as actionForm's redirect: short screen-ID (same-feature)
884
904
  // or a fully-qualified cross-feature QN (#1946).
885
- const candidateQn = resolveScreenTargetQn(feature.name, screen.redirect);
886
- if (!allScreenQns.has(candidateQn)) {
887
- throw new Error(
888
- `[Feature ${feature.name}] Screen "${screenId}" (entityEdit) redirect "${screen.redirect}" ` +
889
- `does not resolve to a registered screen (checked "${candidateQn}"). Known screens ` +
890
- `in this feature: ${[...Object.keys(feature.screens)].sort().join(", ") || "(none)"}.`,
891
- );
892
- }
905
+ validateScreenNavTarget(
906
+ feature.name,
907
+ screenId,
908
+ "entityEdit",
909
+ "redirect",
910
+ screen.redirect,
911
+ allScreenQns,
912
+ feature.screens,
913
+ );
893
914
  }
894
915
  }
895
916
  }
@@ -35,7 +35,14 @@ export type DerivedCellRoundingTarget = {
35
35
  * stays unit-agnostic for those). */
36
36
  export function roundDerivedCellValue(value: number, target: DerivedCellRoundingTarget): number {
37
37
  if (target.type === "money") return roundHalfAwayFromZero(value, 0);
38
- if (target.type === "decimal") return roundHalfAwayFromZero(value, target.scale ?? 0);
38
+ // `scale` is required on the real decimal EmbeddedSubFieldDef (fields.ts:
39
+ // "no silent default that could truncate") — DerivedCellRoundingTarget
40
+ // only widens it to optional for the money/decimal split above. If it's
41
+ // ever missing anyway, pass the value through unrounded rather than
42
+ // guessing 0 decimals and truncating a value the caller never asked to round.
43
+ if (target.type === "decimal") {
44
+ return target.scale === undefined ? value : roundHalfAwayFromZero(value, target.scale);
45
+ }
39
46
  return value;
40
47
  }
41
48
 
@@ -45,6 +52,7 @@ function roundHalfAwayFromZero(value: number, decimals: number): number {
45
52
  // === 100.49999999999999) before rounding, so a value that's
46
53
  // mathematically exactly at the half-step doesn't fall to the wrong side.
47
54
  // ponytail: toPrecision(15) can shift by ±1 minor unit for values near Number.MAX_SAFE_INTEGER (2^53); fine for realistic money amounts.
55
+ // ponytail: also snaps values within ~1e-16 of a half-step up (e.g. roundHalfAwayFromZero(0.49999999999999994, 0) === 1) — deliberate, the float-noise case is far more common than a genuine near-half input.
48
56
  const scaled = Number((Math.abs(value) * factor).toPrecision(15));
49
57
  return (Math.sign(value) * Math.round(scaled)) / factor;
50
58
  }