@cosmicdrift/kumiko-framework 0.186.2 → 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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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>>,
|
package/src/ui-types/index.ts
CHANGED
|
@@ -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 {
|
|
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 {
|