@cosmicdrift/kumiko-framework 0.186.3 → 0.187.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.186.3",
3
+ "version": "0.187.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -182,7 +182,7 @@
182
182
  "./package.json": "./package.json"
183
183
  },
184
184
  "dependencies": {
185
- "@cosmicdrift/kumiko-types": "0.186.3",
185
+ "@cosmicdrift/kumiko-types": "0.187.0",
186
186
  "bullmq": "^5.76.7",
187
187
  "bun-types": "^1.3.13",
188
188
  "hono": "^4.12.27",
@@ -198,7 +198,7 @@
198
198
  "zod": "^4.4.3"
199
199
  },
200
200
  "devDependencies": {
201
- "@cosmicdrift/kumiko-dispatcher-live": "0.186.3",
201
+ "@cosmicdrift/kumiko-dispatcher-live": "0.187.0",
202
202
  "bun-types": "^1.3.13",
203
203
  "pino-pretty": "^13.1.3"
204
204
  },
@@ -819,6 +819,146 @@ describe("embedded-list derived cell recomputation (kumiko-framework#1837)", ()
819
819
  expect(result.data["lines"]).toEqual([{ qty: 3, amount: 999 }]);
820
820
  }
821
821
  });
822
+
823
+ // --- kumiko-framework#1852: a fractional product on a money/decimal
824
+ // target isn't representable by the target sub-field's strict
825
+ // integer/scale validation — round to the target's declared precision
826
+ // before it's written back, instead of rejecting the whole row. ---
827
+
828
+ test("a fractional product on a money target is rounded to whole minor units (kaufmännisch)", () => {
829
+ const entity = createEntity({
830
+ table: "Orders",
831
+ fields: {
832
+ lines: createEmbeddedListField(
833
+ {
834
+ qty: { type: "decimal", scale: 2, required: true },
835
+ price: { type: "money", required: true },
836
+ amount: { type: "money", required: false },
837
+ },
838
+ { derived: { amount: { op: "multiply", from: ["qty", "price"] } } },
839
+ ),
840
+ },
841
+ });
842
+ const schema = buildInsertSchema(entity);
843
+ // 12.34 * 187 = 2307.58 minor units — not representable by money's
844
+ // integer constraint. Rounds up (half-away-from-zero would round .58
845
+ // to .0 anyway, this just isn't a half-step case).
846
+ const result = schema.safeParse({ lines: [{ qty: 12.34, price: 187 }] });
847
+ expect(result.success).toBe(true);
848
+ if (result.success) {
849
+ const row = (result.data["lines"] as readonly Record<string, unknown>[])[0];
850
+ expect(row?.["amount"]).toBe(2308);
851
+ }
852
+ });
853
+
854
+ test("a negative product exactly on a half-step rounds away from zero, not toward it", () => {
855
+ const entity = createEntity({
856
+ table: "Orders",
857
+ fields: {
858
+ lines: createEmbeddedListField(
859
+ {
860
+ qty: { type: "decimal", scale: 1, required: true },
861
+ price: { type: "money", required: true },
862
+ amount: { type: "money", required: false },
863
+ },
864
+ { derived: { amount: { op: "multiply", from: ["qty", "price"] } } },
865
+ ),
866
+ },
867
+ });
868
+ const schema = buildInsertSchema(entity);
869
+ // 2.5 * -923 = -2307.5 exactly. `Math.round(-2307.5)` alone would give
870
+ // -2307 (rounds toward zero for negative .5); half-away-from-zero must
871
+ // give -2308.
872
+ const result = schema.safeParse({ lines: [{ qty: 2.5, price: -923 }] });
873
+ expect(result.success).toBe(true);
874
+ if (result.success) {
875
+ const row = (result.data["lines"] as readonly Record<string, unknown>[])[0];
876
+ expect(row?.["amount"]).toBe(-2308);
877
+ }
878
+ });
879
+
880
+ test("a decimal target rounds correctly through the classic float half-step trap", () => {
881
+ const entity = createEntity({
882
+ table: "Orders",
883
+ fields: {
884
+ lines: createEmbeddedListField(
885
+ {
886
+ qty: { type: "decimal", scale: 3, required: true },
887
+ price: { type: "number", required: true },
888
+ amount: { type: "decimal", scale: 2, required: false },
889
+ },
890
+ { derived: { amount: { op: "multiply", from: ["qty", "price"] } } },
891
+ ),
892
+ },
893
+ });
894
+ const schema = buildInsertSchema(entity);
895
+ // 1.005 * 1 === 1.005 as a JS number, but 1.005 * 100 is actually
896
+ // 100.49999999999999 in float — naive Math.round would floor this to
897
+ // 1.00 instead of the mathematically-correct 1.01.
898
+ const result = schema.safeParse({ lines: [{ qty: 1.005, price: 1 }] });
899
+ expect(result.success).toBe(true);
900
+ if (result.success) {
901
+ const row = (result.data["lines"] as readonly Record<string, unknown>[])[0];
902
+ expect(row?.["amount"]).toBe(1.01);
903
+ }
904
+ });
905
+
906
+ test("totalsMatch validates against the rounded derived values, not the raw fractional products", () => {
907
+ const entity = createEntity({
908
+ table: "Orders",
909
+ fields: {
910
+ total: createMoneyField({ required: true }),
911
+ lines: createEmbeddedListField(
912
+ {
913
+ qty: { type: "decimal", scale: 1, required: true },
914
+ price: { type: "money", required: true },
915
+ amount: { type: "money", required: false },
916
+ },
917
+ {
918
+ derived: { amount: { op: "multiply", from: ["qty", "price"] } },
919
+ totalsMatch: { amount: "total" },
920
+ },
921
+ ),
922
+ },
923
+ defaultCurrency: "EUR",
924
+ });
925
+ const schema = buildInsertSchema(entity);
926
+ // Each row's raw product is x.5 and rounds up by 1 minor unit: row 1 =
927
+ // 2.5 * 923 = 2307.5 -> 2308; row 2 = 3.5 * 100 = 350.0 -> 350 exactly.
928
+ // Sibling total must equal the sum of the ROUNDED amounts (26.58 EUR),
929
+ // not the sum of the raw fractional products.
930
+ const result = schema.safeParse({
931
+ total: { amount: 26.58, currency: "EUR" },
932
+ lines: [
933
+ { qty: 2.5, price: 923 },
934
+ { qty: 3.5, price: 100 },
935
+ ],
936
+ });
937
+ expect(result.success).toBe(true);
938
+ });
939
+
940
+ test("a number-typed derived cell is left unrounded (unit-agnostic pass-through)", () => {
941
+ const entity = createEntity({
942
+ table: "Orders",
943
+ fields: {
944
+ lines: createEmbeddedListField(
945
+ {
946
+ qty: { type: "number", required: true },
947
+ price: { type: "number", required: true },
948
+ amount: { type: "number", required: false },
949
+ },
950
+ { derived: { amount: { op: "multiply", from: ["qty", "price"] } } },
951
+ ),
952
+ },
953
+ });
954
+ const schema = buildInsertSchema(entity);
955
+ const result = schema.safeParse({ lines: [{ qty: 1.5, price: 2 }] });
956
+ expect(result.success).toBe(true);
957
+ if (result.success) {
958
+ const row = (result.data["lines"] as readonly Record<string, unknown>[])[0];
959
+ expect(row?.["amount"]).toBe(3);
960
+ }
961
+ });
822
962
  });
823
963
 
824
964
  // --- Update schema (all partial) ---
@@ -1,11 +1,13 @@
1
- import type { EmbeddedDerivedCellDef } from "./types";
1
+ import type { EmbeddedDerivedCellDef, EmbeddedSubFieldDef } from "./types";
2
2
 
3
3
  /** Computes a derived cell from its source values. Missing/non-numeric
4
4
  * sources are treated as 0 for "sum"/"subtract"; "multiply" with any
5
5
  * missing source returns undefined (an incomplete product isn't a
6
6
  * meaningful partial value). Money cells are minor-unit integers — this
7
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). */
8
+ * it's given (caller passes minor units for money, not major/float).
9
+ * `withDerivedCells` rounds the result to the target sub-field's declared
10
+ * precision afterward. */
9
11
  export function computeDerivedCellValue(
10
12
  op: EmbeddedDerivedCellDef["op"],
11
13
  values: readonly (number | undefined)[],
@@ -21,16 +23,46 @@ export function computeDerivedCellValue(
21
23
  return rest.reduce((remainder, value) => remainder - value, first ?? 0);
22
24
  }
23
25
 
26
+ export type DerivedCellRoundingTarget = {
27
+ readonly type: EmbeddedSubFieldDef["type"];
28
+ readonly scale?: number;
29
+ };
30
+
31
+ /** Rounds a derived cell's computed value to the precision its target
32
+ * sub-field declares — commercial rounding (round-half-away-from-zero,
33
+ * correct for signed minor-unit money). money → integer; decimal → `scale`
34
+ * digits; every other target type passes through unchanged (the function
35
+ * stays unit-agnostic for those). */
36
+ export function roundDerivedCellValue(value: number, target: DerivedCellRoundingTarget): number {
37
+ if (target.type === "money") return roundHalfAwayFromZero(value, 0);
38
+ if (target.type === "decimal") return roundHalfAwayFromZero(value, target.scale ?? 0);
39
+ return value;
40
+ }
41
+
42
+ function roundHalfAwayFromZero(value: number, decimals: number): number {
43
+ const factor = 10 ** decimals;
44
+ // `toPrecision` strips the float-multiplication noise (e.g. 1.005 * 100
45
+ // === 100.49999999999999) before rounding, so a value that's
46
+ // mathematically exactly at the half-step doesn't fall to the wrong side.
47
+ // ponytail: toPrecision(15) can shift by ±1 minor unit for values near Number.MAX_SAFE_INTEGER (2^53); fine for realistic money amounts.
48
+ const scaled = Number((Math.abs(value) * factor).toPrecision(15));
49
+ return (Math.sign(value) * Math.round(scaled)) / factor;
50
+ }
51
+
24
52
  /** Recomputes every derived cell of an embedded-list row from its raw
25
53
  * values, overwriting whatever the client sent instead of merely checking
26
54
  * it — the server is the authority for derived cells. Reads source values
27
55
  * from the original row (never from an already-recomputed derived cell),
28
56
  * so the iteration order of `derived` never matters. A row that isn't a
29
57
  * plain object (already invalid, or not this field's shape) passes through
30
- * untouched — validation downstream rejects it. */
58
+ * untouched — validation downstream rejects it. The computed value is
59
+ * rounded to the target sub-field's declared precision (`schema`) before
60
+ * it's written back, so a fractional product lands on a value the target
61
+ * type can actually represent. */
31
62
  export function withDerivedCells(
32
63
  row: unknown,
33
64
  derived: Readonly<Record<string, EmbeddedDerivedCellDef>>,
65
+ schema: Readonly<Record<string, EmbeddedSubFieldDef>>,
34
66
  ): unknown {
35
67
  if (typeof row !== "object" || row === null || Array.isArray(row)) return row;
36
68
  const source = row as Readonly<Record<string, unknown>>;
@@ -44,7 +76,8 @@ export function withDerivedCells(
44
76
  if (computed === undefined) {
45
77
  delete copy[cellName];
46
78
  } else {
47
- copy[cellName] = computed;
79
+ const target = schema[cellName];
80
+ copy[cellName] = target === undefined ? computed : roundDerivedCellValue(computed, target);
48
81
  }
49
82
  }
50
83
  return copy;
@@ -212,7 +212,7 @@ export function fieldToZod(
212
212
  const row =
213
213
  derived === undefined
214
214
  ? baseRow
215
- : z.preprocess((value) => withDerivedCells(value, derived), baseRow);
215
+ : z.preprocess((value) => withDerivedCells(value, derived, field.schema), baseRow);
216
216
  if (field.multiple !== true) return row;
217
217
  // `required: true` means non-empty, same reading as multiSelect —
218
218
  // whether the key may be omitted at all is decided by buildInsertSchema
@@ -299,6 +299,10 @@ export function fieldToZod(
299
299
  // sum in the entity's default currency against a sibling amount tagged with
300
300
  // a different currency string still passes. Add a currency-equality check
301
301
  // here if multi-currency siblings become a real case.
302
+ //
303
+ // Known limitation: compares against rounded `derived` cells, i.e.
304
+ // "sum-of-rounded" not "round-of-sum" (kumiko-framework#1866). Follow-up
305
+ // for a computed, read-only sibling total: kumiko-framework#1873.
302
306
  function applyTotalsMatchRefinements(
303
307
  entity: EntityDefinition,
304
308
  schema: z.ZodObject<Record<string, z.ZodTypeAny>>,
@@ -20,7 +20,8 @@
20
20
  // When adding a symbol here, verify it's either a type or a pure
21
21
  // helper with no cross-module side-effects.
22
22
 
23
- export { computeDerivedCellValue } from "../engine/embedded-derived";
23
+ export type { DerivedCellRoundingTarget } from "../engine/embedded-derived";
24
+ export { computeDerivedCellValue, roundDerivedCellValue } from "../engine/embedded-derived";
24
25
  export type { ParsedRefTarget } from "../engine/parse-ref-target";
25
26
  export { parseRefTarget } from "../engine/parse-ref-target";
26
27
  export {