@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.183.2",
3
+ "version": "0.185.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.183.2",
185
+ "@cosmicdrift/kumiko-types": "0.185.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.183.2",
201
+ "@cosmicdrift/kumiko-dispatcher-live": "0.185.0",
202
202
  "bun-types": "^1.3.13",
203
203
  "pino-pretty": "^13.1.3"
204
204
  },
@@ -85,7 +85,7 @@ describe("rehydrateCompoundTypes — Pipeline", () => {
85
85
  label: "ACME",
86
86
  pickup: { at: "2026-04-15T10:00:00", tz: "Europe/Lisbon", utc: "2026-04-15T09:00:00Z" },
87
87
  // rehydrateMoney converts minor units (DB) → major units (API, ÷100).
88
- buyingPrice: { amount: 45_000, currency: "EUR" },
88
+ buyingPrice: { amount: 45_000, currency: "EUR", amountMinor: 4_500_000 },
89
89
  });
90
90
  });
91
91
 
@@ -98,7 +98,7 @@ describe("rehydrateCompoundTypes — Pipeline", () => {
98
98
  const round = rehydrateCompoundTypes(flattenCompoundTypes(original, mixedEntity), mixedEntity);
99
99
  // pickup bekommt utc dazu beim Read (war beim Insert nicht gesetzt)
100
100
  expect((round["pickup"] as { utc: string }).utc).toBe("2026-04-15T09:00:00Z");
101
- expect(round["buyingPrice"]).toEqual({ amount: 100, currency: "EUR" });
101
+ expect(round["buyingPrice"]).toEqual({ amount: 100, currency: "EUR", amountMinor: 10_000 });
102
102
  expect(round["label"]).toBe("ACME");
103
103
  });
104
104
 
@@ -99,20 +99,33 @@ describe("flattenMoney — Insert/Update Convert (major units → minor units)",
99
99
  });
100
100
 
101
101
  describe("rehydrateMoney — Read Convert (minor units → major units)", () => {
102
- test("{ <name>: minorUnits, <name>Currency: string } → { <name>: { amount: majorUnits, currency } }", () => {
102
+ test("{ <name>: minorUnits, <name>Currency: string } → { <name>: { amount: majorUnits, currency, amountMinor } }", () => {
103
103
  const out = rehydrateMoney({ buyingPrice: 45000, buyingPriceCurrency: "EUR" }, orderEntity);
104
- expect(out).toEqual({ buyingPrice: { amount: 450, currency: "EUR" } });
104
+ expect(out).toEqual({ buyingPrice: { amount: 450, currency: "EUR", amountMinor: 45000 } });
105
105
  });
106
106
 
107
107
  test("PG-BIGINT als String wird zu number gecastet", () => {
108
108
  // Postgres-driver liefert BIGINT manchmal als String (>2^53 sicher).
109
109
  const out = rehydrateMoney({ buyingPrice: "45000", buyingPriceCurrency: "EUR" }, orderEntity);
110
- expect(out["buyingPrice"]).toEqual({ amount: 450, currency: "EUR" });
110
+ expect(out["buyingPrice"]).toEqual({ amount: 450, currency: "EUR", amountMinor: 45000 });
111
111
  });
112
112
 
113
113
  test("fehlende Currency-Spalte fällt auf entity.defaultCurrency", () => {
114
114
  const out = rehydrateMoney({ buyingPrice: 45000 }, orderEntity);
115
- expect(out["buyingPrice"]).toEqual({ amount: 450, currency: "EUR" });
115
+ expect(out["buyingPrice"]).toEqual({ amount: 450, currency: "EUR", amountMinor: 45000 });
116
+ });
117
+
118
+ test("amountMinor bleibt exakter Integer, amount kann Float-Drift haben (fw#1830)", () => {
119
+ const rows = [10, 20, 30].map(
120
+ (minor) =>
121
+ rehydrateMoney({ buyingPrice: minor, buyingPriceCurrency: "EUR" }, orderEntity)[
122
+ "buyingPrice"
123
+ ] as { amount: number; amountMinor: number },
124
+ );
125
+ const [a, b, c] = rows;
126
+
127
+ expect(a!.amountMinor + b!.amountMinor).toBe(c!.amountMinor);
128
+ expect(a!.amount + b!.amount).not.toBe(c!.amount);
116
129
  });
117
130
 
118
131
  test("null/undefined amount → Field wird aus Output entfernt", () => {
@@ -131,25 +144,28 @@ describe("rehydrateMoney — Read Convert (minor units → major units)", () =>
131
144
  orderEntity,
132
145
  );
133
146
  expect(out).toEqual({
134
- buyingPrice: { amount: 450, currency: "EUR" },
135
- sellingPrice: { amount: 600, currency: "USD" },
147
+ buyingPrice: { amount: 450, currency: "EUR", amountMinor: 45000 },
148
+ sellingPrice: { amount: 600, currency: "USD", amountMinor: 60000 },
136
149
  });
137
150
  });
138
151
 
139
- test("Round-Trip: flatten dann rehydrate ergibt dasselbe, inkl. Cents", () => {
152
+ test("Round-Trip: flatten dann rehydrate ergibt dasselbe amount/currency, plus amountMinor", () => {
140
153
  const original = {
141
154
  buyingPrice: { amount: 450.5, currency: "EUR" },
142
155
  sellingPrice: { amount: 56799.16, currency: "USD" },
143
156
  };
144
157
  const flat = flattenMoney(original, orderEntity);
145
158
  const rehydrated = rehydrateMoney(flat, orderEntity);
146
- expect(rehydrated).toEqual(original);
159
+ expect(rehydrated).toEqual({
160
+ buyingPrice: { amount: 450.5, currency: "EUR", amountMinor: 45050 },
161
+ sellingPrice: { amount: 56799.16, currency: "USD", amountMinor: 5679916 },
162
+ });
147
163
  });
148
164
 
149
- test("Round-Trip primitive-Insert: flatten(450) → rehydrate → { amount:450, currency:EUR }", () => {
165
+ test("Round-Trip primitive-Insert: flatten(450) → rehydrate → { amount:450, currency:EUR, amountMinor:45000 }", () => {
150
166
  const flat = flattenMoney({ buyingPrice: 450 }, orderEntity);
151
167
  const out = rehydrateMoney(flat, orderEntity);
152
- expect(out["buyingPrice"]).toEqual({ amount: 450, currency: "EUR" });
168
+ expect(out["buyingPrice"]).toEqual({ amount: 450, currency: "EUR", amountMinor: 45000 });
153
169
  });
154
170
 
155
171
  test("ist pure — input wird nicht mutiert", () => {
@@ -181,7 +197,7 @@ describe("Round-Trip im Update-Pfad (Helper-Verkettung wie im Executor)", () =>
181
197
 
182
198
  // DB liefert dieselben Spalten zurück
183
199
  const out = rehydrateMoney(flat, orderEntity);
184
- expect(out).toEqual({ buyingPrice: { amount: 990, currency: "USD" } });
200
+ expect(out).toEqual({ buyingPrice: { amount: 990, currency: "USD", amountMinor: 99_000 } });
185
201
  });
186
202
 
187
203
  test("List-Pfad: mehrere Rows hintereinander rehydraten", () => {
@@ -192,9 +208,9 @@ describe("Round-Trip im Update-Pfad (Helper-Verkettung wie im Executor)", () =>
192
208
  ];
193
209
  const apiRows = dbRows.map((r) => rehydrateMoney(r, orderEntity));
194
210
  expect(apiRows).toEqual([
195
- { buyingPrice: { amount: 1, currency: "EUR" } },
196
- { buyingPrice: { amount: 2, currency: "USD" } },
197
- { buyingPrice: { amount: 3, currency: "GBP" } },
211
+ { buyingPrice: { amount: 1, currency: "EUR", amountMinor: 100 } },
212
+ { buyingPrice: { amount: 2, currency: "USD", amountMinor: 200 } },
213
+ { buyingPrice: { amount: 3, currency: "GBP", amountMinor: 300 } },
198
214
  ]);
199
215
  });
200
216
  });
package/src/db/money.ts CHANGED
@@ -3,7 +3,10 @@
3
3
  // Vertrag (siehe auch db/located-timestamp.ts — gleicher Compound-Type-Pattern):
4
4
  // API-Form: { amount, currency } | number — amount in MAJOR units (56799.16 EUR)
5
5
  // DB-Form: <name> BIGINT (minor units, e.g. cents) + <name>Currency TEXT
6
- // Read-Form: { amount, currency } — amount in MAJOR units again
6
+ // Read-Form: { amount, currency, amountMinor } — amount in MAJOR units again,
7
+ // amountMinor sits alongside as exact integer cents (fw#1830) for
8
+ // callers that need cent-exact comparisons (e.g. invoice sums)
9
+ // instead of round-tripping the float amount through /100·*100.
7
10
  //
8
11
  // table-builder.ts's moneyAmount column has always documented BIGINT as
9
12
  // "the integer minor unit" — this file used to just pass the API amount
@@ -33,7 +36,7 @@ const FRAMEWORK_DEFAULT_CURRENCY = DEFAULT_CURRENCIES[0]; // "EUR"
33
36
  // upgrade path once a currency needing a different scale actually lands.
34
37
  const MINOR_UNIT_SCALE = 100;
35
38
 
36
- function toMinorUnits(amount: number): number {
39
+ export function toMinorUnits(amount: number): number {
37
40
  return Math.round(amount * MINOR_UNIT_SCALE);
38
41
  }
39
42
 
@@ -151,7 +154,7 @@ export function rehydrateMoney(
151
154
  const currency =
152
155
  typeof currencyRaw === "string" && currencyRaw !== "" ? currencyRaw : fallbackCurrency;
153
156
 
154
- result[name] = { amount: toMajorUnits(amountMinor), currency };
157
+ result[name] = { amount: toMajorUnits(amountMinor), currency, amountMinor };
155
158
  }
156
159
 
157
160
  return result;
@@ -7,6 +7,7 @@ import { validateBoot as validateBootRaw } from "../boot-validator";
7
7
  import { createSystemConfig, createTenantConfig } from "../config-helpers";
8
8
  import {
9
9
  createDerivedField,
10
+ createEmbeddedListField,
10
11
  createEntity,
11
12
  createMultiSelectField,
12
13
  createTextField,
@@ -19,6 +20,23 @@ function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
19
20
  validateBootRaw(withBootValidatorFixture(features));
20
21
  }
21
22
 
23
+ // Registriert einen Stub-Query-Handler `<entity>:list` damit der Boot-
24
+ // Validator den Audit-Fix-#2-Check (Handler-Existenz auf der target-
25
+ // Entity) durch lässt — used by both top-level and embedded-list
26
+ // reference-field tests.
27
+ function stubListHandler(
28
+ // biome-ignore lint/suspicious/noExplicitAny: Registrar-Typ ist generisch, hier reicht das.
29
+ r: any,
30
+ entityName: string,
31
+ ): void {
32
+ r.queryHandler({
33
+ name: `${entityName}:list`,
34
+ schema: z.object({}),
35
+ handler: async () => ({ rows: [], nextCursor: null }) as never,
36
+ access: { openToAll: true },
37
+ });
38
+ }
39
+
22
40
  describe("boot-validator", () => {
23
41
  test("passes for valid features with no issues", () => {
24
42
  const features = [
@@ -2030,22 +2048,6 @@ describe("boot-validator", () => {
2030
2048
 
2031
2049
  // --- Tier 2.7e-3: ReferenceFieldDef ---
2032
2050
  describe("reference field (Tier 2.7e-3)", () => {
2033
- // Helper: registriert einen Stub-Query-Handler `<entity>:list`
2034
- // damit der Boot-Validator den Audit-Fix-#2-Check (Handler-
2035
- // Existenz auf der target-Entity) durch lässt.
2036
- function stubListHandler(
2037
- // biome-ignore lint/suspicious/noExplicitAny: Registrar-Typ ist generisch, hier reicht das.
2038
- r: any,
2039
- entityName: string,
2040
- ): void {
2041
- r.queryHandler({
2042
- name: `${entityName}:list`,
2043
- schema: z.object({}),
2044
- handler: async () => ({ rows: [], nextCursor: null }) as never,
2045
- access: { openToAll: true },
2046
- });
2047
- }
2048
-
2049
2051
  test("reference auf bestehende Entity → kein Throw", () => {
2050
2052
  const features = [
2051
2053
  defineFeature("shop", (r) => {
@@ -2269,6 +2271,100 @@ describe("boot-validator", () => {
2269
2271
  });
2270
2272
  });
2271
2273
 
2274
+ // --- fw#1839: reference sub-fields of an embedded-list field get the
2275
+ // same target/labelField/query-handler checks as a top-level reference
2276
+ // field, so a bad "lines.productId" fails at boot instead of crashing
2277
+ // the Combobox at runtime.
2278
+ describe("embedded-list reference sub-field (fw#1839)", () => {
2279
+ test("reference sub-field targeting an unknown entity → Throw with the parent.child field path", () => {
2280
+ const features = [
2281
+ defineFeature("shop", (r) => {
2282
+ r.entity(
2283
+ "invoice",
2284
+ createEntity({
2285
+ fields: {
2286
+ lines: createEmbeddedListField({
2287
+ productId: { type: "reference", entity: "ghost-entity" },
2288
+ }),
2289
+ },
2290
+ }),
2291
+ );
2292
+ }),
2293
+ ];
2294
+ expect(() => validateBoot(features)).toThrow(
2295
+ /Reference field "lines\.productId" on entity "invoice" targets unknown entity "ghost-entity"/,
2296
+ );
2297
+ });
2298
+
2299
+ test("reference sub-field with a valid target + registered list-handler → kein Throw", () => {
2300
+ const features = [
2301
+ defineFeature("shop", (r) => {
2302
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
2303
+ stubListHandler(r, "product");
2304
+ r.entity(
2305
+ "invoice",
2306
+ createEntity({
2307
+ fields: {
2308
+ lines: createEmbeddedListField({
2309
+ productId: { type: "reference", entity: "product", labelField: "name" },
2310
+ }),
2311
+ },
2312
+ }),
2313
+ );
2314
+ }),
2315
+ ];
2316
+ expect(() => validateBoot(features)).not.toThrow();
2317
+ });
2318
+
2319
+ test("reference sub-field with a valid target but no registered list-handler → Throw", () => {
2320
+ const features = [
2321
+ defineFeature("shop", (r) => {
2322
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
2323
+ // KEIN stubListHandler — das ist der Punkt des Tests
2324
+ r.entity(
2325
+ "invoice",
2326
+ createEntity({
2327
+ fields: {
2328
+ lines: createEmbeddedListField({
2329
+ productId: { type: "reference", entity: "product" },
2330
+ }),
2331
+ },
2332
+ }),
2333
+ );
2334
+ }),
2335
+ ];
2336
+ expect(() => validateBoot(features)).toThrow(
2337
+ /Reference field "lines\.productId" on entity "invoice" targets entity "product" but no list-query-handler is registered/,
2338
+ );
2339
+ });
2340
+
2341
+ test("reference sub-field labelField referencing an unknown field → Throw with the parent.child field path", () => {
2342
+ const features = [
2343
+ defineFeature("shop", (r) => {
2344
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
2345
+ stubListHandler(r, "product");
2346
+ r.entity(
2347
+ "invoice",
2348
+ createEntity({
2349
+ fields: {
2350
+ lines: createEmbeddedListField({
2351
+ productId: {
2352
+ type: "reference",
2353
+ entity: "product",
2354
+ labelField: "ghost-field",
2355
+ },
2356
+ }),
2357
+ },
2358
+ }),
2359
+ );
2360
+ }),
2361
+ ];
2362
+ expect(() => validateBoot(features)).toThrow(
2363
+ /Reference field "lines\.productId" on entity "invoice" references labelField "ghost-field" which does not exist on entity "product"/,
2364
+ );
2365
+ });
2366
+ });
2367
+
2272
2368
  // --- Tier 2.7e-1: rowAction kind="navigate" target-existenz ---
2273
2369
  describe("entityList rowAction kind=navigate (Tier 2.7e-1)", () => {
2274
2370
  function makeFeature(targetScreen: string, withTarget: boolean) {
@@ -8,6 +8,7 @@ import { defineQueryHandler, defineWriteHandler } from "../define-handler";
8
8
  import {
9
9
  createBooleanField,
10
10
  createEmbeddedField,
11
+ createEmbeddedListField,
11
12
  createEntity,
12
13
  createMoneyField,
13
14
  createSelectField,
@@ -961,6 +962,188 @@ describe("createApp", () => {
961
962
  expect(() => createApp({ roles: ["Admin"], features: [feature] })).not.toThrow();
962
963
  });
963
964
 
965
+ test("rejects embedded select sub-field with empty options", () => {
966
+ const feature = defineFeature("test", (r) => {
967
+ r.entity(
968
+ "doc",
969
+ createEntity({
970
+ table: "Docs",
971
+ fields: {
972
+ lines: createEmbeddedListField({
973
+ status: { type: "select", options: [] },
974
+ }),
975
+ },
976
+ }),
977
+ );
978
+ });
979
+ expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow("has empty options");
980
+ });
981
+
982
+ test("rejects embedded-list minItems greater than maxItems", () => {
983
+ const feature = defineFeature("test", (r) => {
984
+ r.entity(
985
+ "doc",
986
+ createEntity({
987
+ table: "Docs",
988
+ fields: {
989
+ lines: createEmbeddedListField(
990
+ { accountId: { type: "text" } },
991
+ { minItems: 3, maxItems: 1 },
992
+ ),
993
+ },
994
+ }),
995
+ );
996
+ });
997
+ expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow(
998
+ "greater than maxItems",
999
+ );
1000
+ });
1001
+
1002
+ test("rejects derived cell referencing an unknown sub-field", () => {
1003
+ const feature = defineFeature("test", (r) => {
1004
+ r.entity(
1005
+ "doc",
1006
+ createEntity({
1007
+ table: "Docs",
1008
+ fields: {
1009
+ lines: createEmbeddedListField(
1010
+ { qty: { type: "number" }, price: { type: "number" }, total: { type: "number" } },
1011
+ { derived: { total: { op: "multiply", from: ["qty", "unknownField"] } } },
1012
+ ),
1013
+ },
1014
+ }),
1015
+ );
1016
+ });
1017
+ expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow(
1018
+ 'derived cell "total" reading unknown sub-field "unknownField"',
1019
+ );
1020
+ });
1021
+
1022
+ test("rejects totals referencing a non-numeric sub-field", () => {
1023
+ const feature = defineFeature("test", (r) => {
1024
+ r.entity(
1025
+ "doc",
1026
+ createEntity({
1027
+ table: "Docs",
1028
+ fields: {
1029
+ lines: createEmbeddedListField(
1030
+ { label: { type: "text" }, amount: { type: "number" } },
1031
+ { totals: ["label"] },
1032
+ ),
1033
+ },
1034
+ }),
1035
+ );
1036
+ });
1037
+ expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow(
1038
+ 'lists "label" in totals, but it is not a number/money/decimal sub-field',
1039
+ );
1040
+ });
1041
+
1042
+ test("rejects totalsMatch entry whose sub-field is not a money sub-field (fw#1839)", () => {
1043
+ const feature = defineFeature("test", (r) => {
1044
+ r.entity(
1045
+ "invoice",
1046
+ createEntity({
1047
+ table: "Invoices",
1048
+ fields: {
1049
+ total: createMoneyField({ required: true }),
1050
+ lines: createEmbeddedListField(
1051
+ { qty: { type: "number" } },
1052
+ { totalsMatch: { qty: "total" } },
1053
+ ),
1054
+ },
1055
+ defaultCurrency: "EUR",
1056
+ }),
1057
+ );
1058
+ });
1059
+ expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow(
1060
+ 'has a totalsMatch entry for "qty", which is not a money sub-field',
1061
+ );
1062
+ });
1063
+
1064
+ test("rejects totalsMatch entry whose sibling field is not a money field on the entity (fw#1839)", () => {
1065
+ const feature = defineFeature("test", (r) => {
1066
+ r.entity(
1067
+ "invoice",
1068
+ createEntity({
1069
+ table: "Invoices",
1070
+ fields: {
1071
+ title: createTextField(),
1072
+ lines: createEmbeddedListField(
1073
+ { amount: { type: "money" } },
1074
+ { totalsMatch: { amount: "title" } },
1075
+ ),
1076
+ },
1077
+ }),
1078
+ );
1079
+ });
1080
+ expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow(
1081
+ 'mapping "amount" to sibling field "title", which is not a money field on entity "invoice"',
1082
+ );
1083
+ });
1084
+
1085
+ test("rejects totalsMatch entry whose sibling field does not exist on the entity (fw#1839)", () => {
1086
+ const feature = defineFeature("test", (r) => {
1087
+ r.entity(
1088
+ "invoice",
1089
+ createEntity({
1090
+ table: "Invoices",
1091
+ fields: {
1092
+ lines: createEmbeddedListField(
1093
+ { amount: { type: "money" } },
1094
+ { totalsMatch: { amount: "ghostTotal" } },
1095
+ ),
1096
+ },
1097
+ }),
1098
+ );
1099
+ });
1100
+ expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow(
1101
+ 'mapping "amount" to sibling field "ghostTotal", which is not a money field on entity "invoice"',
1102
+ );
1103
+ });
1104
+
1105
+ test("accepts a valid totalsMatch entry (fw#1839)", () => {
1106
+ const feature = defineFeature("test", (r) => {
1107
+ r.entity(
1108
+ "invoice",
1109
+ createEntity({
1110
+ table: "Invoices",
1111
+ fields: {
1112
+ total: createMoneyField({ required: true }),
1113
+ lines: createEmbeddedListField(
1114
+ { amount: { type: "money" } },
1115
+ { totalsMatch: { amount: "total" } },
1116
+ ),
1117
+ },
1118
+ defaultCurrency: "EUR",
1119
+ }),
1120
+ );
1121
+ });
1122
+ expect(() => createApp({ roles: ["Admin"], features: [feature] })).not.toThrow();
1123
+ });
1124
+
1125
+ test("rejects totalsMatch on a plain (non-list) embedded field", () => {
1126
+ const feature = defineFeature("test", (r) => {
1127
+ r.entity(
1128
+ "invoice",
1129
+ createEntity({
1130
+ table: "Invoices",
1131
+ fields: {
1132
+ total: createMoneyField({ required: true }),
1133
+ meta: createEmbeddedField(
1134
+ { amount: { type: "money" } },
1135
+ { totalsMatch: { amount: "total" } },
1136
+ ),
1137
+ },
1138
+ defaultCurrency: "EUR",
1139
+ }),
1140
+ );
1141
+ });
1142
+ expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow(
1143
+ "which is only valid on an embedded LIST field (multiple: true)",
1144
+ );
1145
+ });
1146
+
964
1147
  test("rejects transitions on non-select field", () => {
965
1148
  const feature = defineFeature("test", (r) => {
966
1149
  r.entity(