@cosmicdrift/kumiko-framework 0.186.3 → 0.188.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.188.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,10 +182,10 @@
182
182
  "./package.json": "./package.json"
183
183
  },
184
184
  "dependencies": {
185
- "@cosmicdrift/kumiko-types": "0.186.3",
185
+ "@cosmicdrift/kumiko-types": "0.188.0",
186
186
  "bullmq": "^5.76.7",
187
187
  "bun-types": "^1.3.13",
188
- "hono": "^4.12.27",
188
+ "hono": "^4.13.1",
189
189
  "i18next": "^26.1.0",
190
190
  "ioredis": "^5.10.1",
191
191
  "jose": "^6.2.3",
@@ -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.188.0",
202
202
  "bun-types": "^1.3.13",
203
203
  "pino-pretty": "^13.1.3"
204
204
  },
@@ -143,19 +143,18 @@ describe("originMiddleware", () => {
143
143
 
144
144
  // The guard runs as /api/* middleware before routing, so a disallowed-origin
145
145
  // request is rejected for every state-changing method even without a route.
146
- test.each([
147
- "PUT",
148
- "PATCH",
149
- "DELETE",
150
- ])("cookie transport + %s + disallowed origin → 403 (every state-changing method)", async (method) => {
151
- const { app, token } = await buildApp();
152
- const res = await app.request("/api/write", {
153
- method,
154
- headers: { Cookie: `${AUTH_COOKIE_NAME}=${token}`, Origin: DISALLOWED },
155
- });
156
- expect(res.status).toBe(403);
157
- expect(await readErrorCode(res)).toBe("origin_not_allowed");
158
- });
146
+ test.each(["PUT", "PATCH", "DELETE"])(
147
+ "cookie transport + %s + disallowed origin → 403 (every state-changing method)",
148
+ async (method) => {
149
+ const { app, token } = await buildApp();
150
+ const res = await app.request("/api/write", {
151
+ method,
152
+ headers: { Cookie: `${AUTH_COOKIE_NAME}=${token}`, Origin: DISALLOWED },
153
+ });
154
+ expect(res.status).toBe(403);
155
+ expect(await readErrorCode(res)).toBe("origin_not_allowed");
156
+ },
157
+ );
159
158
 
160
159
  test("disallowed origin is blocked even as a simple text/plain request", async () => {
161
160
  // The real vector: a `text/plain` POST skips the CORS preflight and reaches
@@ -29,17 +29,16 @@ describe("extractTableInfo — EntityTableMeta discriminator is shadow-proof", (
29
29
  expect(info.pgTypeOf("source")).toBe("text");
30
30
  });
31
31
 
32
- test.each([
33
- "columns",
34
- "tableName",
35
- "indexes",
36
- ])("an entity field named `%s` (another meta key) also does not shadow it", (fieldName) => {
37
- const table = buildEntityTable("thing", {
38
- fields: { [fieldName]: { type: "text", required: true } },
39
- });
40
- const info = extractTableInfo(table);
41
- expect(info.pgTypeOf("inserted_at")).toBe("timestamptz");
42
- });
32
+ test.each(["columns", "tableName", "indexes"])(
33
+ "an entity field named `%s` (another meta key) also does not shadow it",
34
+ (fieldName) => {
35
+ const table = buildEntityTable("thing", {
36
+ fields: { [fieldName]: { type: "text", required: true } },
37
+ });
38
+ const info = extractTableInfo(table);
39
+ expect(info.pgTypeOf("inserted_at")).toBe("timestamptz");
40
+ },
41
+ );
43
42
 
44
43
  test("control entity without a colliding field is unaffected", () => {
45
44
  const table = buildEntityTable("note", {
@@ -1742,6 +1742,8 @@ describe("boot-validator", () => {
1742
1742
  readonly redirect?: string;
1743
1743
  readonly cancelTarget?: string | false;
1744
1744
  readonly extraScreens?: readonly string[];
1745
+ readonly mode?: "single" | "wizard";
1746
+ readonly draft?: boolean;
1745
1747
  };
1746
1748
 
1747
1749
  // Hilfs-Schema-Setup: stamps eine Test-Entity + write-handler
@@ -1770,7 +1772,11 @@ describe("boot-validator", () => {
1770
1772
  type: "actionForm",
1771
1773
  handler,
1772
1774
  fields: fields as never,
1773
- layout: { sections: sections as never },
1775
+ layout: {
1776
+ sections: sections as never,
1777
+ ...(override.mode !== undefined && { mode: override.mode }),
1778
+ ...(override.draft !== undefined && { draft: override.draft }),
1779
+ },
1774
1780
  ...(override.redirect !== undefined && { redirect: override.redirect }),
1775
1781
  ...(override.cancelTarget !== undefined && { cancelTarget: override.cancelTarget }),
1776
1782
  });
@@ -1871,6 +1877,66 @@ describe("boot-validator", () => {
1871
1877
  const section = { kind: "extension", title: "Custom", component: { react: "Panel" } };
1872
1878
  expect(() => validateBoot([makeFeature({ sections: [section] as never })])).not.toThrow();
1873
1879
  });
1880
+
1881
+ test("mode: wizard mit nur 1 Section → Throw", () => {
1882
+ expect(() =>
1883
+ validateBoot([
1884
+ makeFeature({
1885
+ mode: "wizard",
1886
+ sections: [{ title: "Step 1", fields: ["note"] }],
1887
+ }),
1888
+ ]),
1889
+ ).toThrow(/mode: "wizard" but only 1 section\(s\)/);
1890
+ });
1891
+
1892
+ test("mode: wizard mit Section ohne Titel → Throw", () => {
1893
+ const sections = [
1894
+ { title: "Step 1", fields: ["note"] },
1895
+ { title: "", fields: ["priority"] },
1896
+ ];
1897
+ expect(() => validateBoot([makeFeature({ mode: "wizard", sections })])).toThrow(
1898
+ /sections\[1\] has no title/,
1899
+ );
1900
+ });
1901
+
1902
+ test("mode: wizard mit >= 2 betitelten Sections → kein Throw", () => {
1903
+ const sections = [
1904
+ { title: "Step 1", fields: ["note"] },
1905
+ { title: "Step 2", fields: ["priority"] },
1906
+ ];
1907
+ expect(() => validateBoot([makeFeature({ mode: "wizard", sections })])).not.toThrow();
1908
+ });
1909
+
1910
+ test("draft: true ohne gemountetes form-draft-Feature → Throw", () => {
1911
+ const sections = [
1912
+ { title: "Step 1", fields: ["note"] },
1913
+ { title: "Step 2", fields: ["priority"] },
1914
+ ];
1915
+ expect(() => validateBoot([makeFeature({ mode: "wizard", sections, draft: true })])).toThrow(
1916
+ /"form-draft" is not mounted/,
1917
+ );
1918
+ });
1919
+
1920
+ test("draft: true mit gemountetem form-draft-Feature → kein Throw", () => {
1921
+ const sections = [
1922
+ { title: "Step 1", fields: ["note"] },
1923
+ { title: "Step 2", fields: ["priority"] },
1924
+ ];
1925
+ expect(() =>
1926
+ validateBoot([
1927
+ makeFeature({ mode: "wizard", sections, draft: true }),
1928
+ defineFeature("form-draft", () => {}),
1929
+ ]),
1930
+ ).not.toThrow();
1931
+ });
1932
+
1933
+ test("draft: true ohne mode: 'wizard' → Throw", () => {
1934
+ expect(() => validateBoot([makeFeature({ draft: true })])).toThrow(/mode is not "wizard"/);
1935
+ });
1936
+
1937
+ test("mode weggelassen (Default 'single') bleibt bestehendes Layout gültig → kein Throw", () => {
1938
+ expect(() => validateBoot([makeFeature()])).not.toThrow();
1939
+ });
1874
1940
  });
1875
1941
 
1876
1942
  // --- configEdit-Screen ---
@@ -2046,6 +2112,67 @@ describe("boot-validator", () => {
2046
2112
  });
2047
2113
  });
2048
2114
 
2115
+ // --- entityEdit wizard mode (framework#1884) ---
2116
+ // layout.mode: "wizard" renders one section per step instead of all
2117
+ // sections at once — needs >= 2 sections, each with a title (the step
2118
+ // title), or it's a boot-fail instead of a broken step UI.
2119
+ describe("entityEdit wizard mode", () => {
2120
+ function makeFeature(
2121
+ sections: readonly { readonly title?: string; readonly fields: readonly string[] }[],
2122
+ mode?: "single" | "wizard",
2123
+ ) {
2124
+ return defineFeature("shop", (r) => {
2125
+ r.entity(
2126
+ "product",
2127
+ createEntity({ fields: { name: createTextField(), sku: createTextField() } }),
2128
+ );
2129
+ r.screen({
2130
+ id: "product-edit",
2131
+ type: "entityEdit",
2132
+ entity: "product",
2133
+ layout: {
2134
+ sections: sections as never,
2135
+ ...(mode !== undefined && { mode }),
2136
+ },
2137
+ });
2138
+ });
2139
+ }
2140
+
2141
+ test("mode: wizard mit nur 1 Section → Throw", () => {
2142
+ expect(() =>
2143
+ validateBoot([makeFeature([{ title: "Step 1", fields: ["name"] }], "wizard")]),
2144
+ ).toThrow(/mode: "wizard" but only 1 section\(s\)/);
2145
+ });
2146
+
2147
+ test("mode: wizard mit Section ohne Titel → Throw", () => {
2148
+ expect(() =>
2149
+ validateBoot([
2150
+ makeFeature([{ title: "Step 1", fields: ["name"] }, { fields: ["sku"] }], "wizard"),
2151
+ ]),
2152
+ ).toThrow(/sections\[1\] has no title/);
2153
+ });
2154
+
2155
+ test("mode: wizard mit >= 2 betitelten Sections → kein Throw", () => {
2156
+ expect(() =>
2157
+ validateBoot([
2158
+ makeFeature(
2159
+ [
2160
+ { title: "Step 1", fields: ["name"] },
2161
+ { title: "Step 2", fields: ["sku"] },
2162
+ ],
2163
+ "wizard",
2164
+ ),
2165
+ ]),
2166
+ ).not.toThrow();
2167
+ });
2168
+
2169
+ test("mode weggelassen (Default 'single') bleibt bestehendes Layout gültig → kein Throw", () => {
2170
+ expect(() =>
2171
+ validateBoot([makeFeature([{ title: "Details", fields: ["name", "sku"] }])]),
2172
+ ).not.toThrow();
2173
+ });
2174
+ });
2175
+
2049
2176
  // --- Tier 2.7e-3: ReferenceFieldDef ---
2050
2177
  describe("reference field (Tier 2.7e-3)", () => {
2051
2178
  test("reference auf bestehende Entity → kein Throw", () => {
@@ -160,7 +160,7 @@ describe("buildAppSchema", () => {
160
160
  });
161
161
  const app = buildAppSchema(createRegistry([f]));
162
162
  const fields = (
163
- app.features[0]?.entities["thing"] as unknown as {
163
+ app.features[0]!.entities["thing"] as unknown as {
164
164
  fields: Record<string, Record<string, unknown>>;
165
165
  }
166
166
  ).fields;
@@ -186,7 +186,7 @@ describe("buildAppSchema", () => {
186
186
  });
187
187
  const app = buildAppSchema(createRegistry([f]));
188
188
  const fields = (
189
- app.features[0]?.entities["thing"] as unknown as {
189
+ app.features[0]!.entities["thing"] as unknown as {
190
190
  fields: Record<string, Record<string, unknown>>;
191
191
  }
192
192
  ).fields;
@@ -207,7 +207,7 @@ describe("buildAppSchema", () => {
207
207
  });
208
208
  const app = buildAppSchema(createRegistry([f]));
209
209
  const fields = (
210
- app.features[0]?.entities["thing"] as unknown as {
210
+ app.features[0]!.entities["thing"] as unknown as {
211
211
  fields: Record<string, Record<string, unknown>>;
212
212
  }
213
213
  ).fields;
@@ -228,7 +228,7 @@ describe("buildAppSchema", () => {
228
228
  });
229
229
  const app = buildAppSchema(createRegistry([f]));
230
230
  const fields = (
231
- app.features[0]?.entities["thing"] as unknown as {
231
+ app.features[0]!.entities["thing"] as unknown as {
232
232
  fields: Record<string, Record<string, unknown>>;
233
233
  }
234
234
  ).fields;
@@ -634,14 +634,13 @@ describe("hasAccess", () => {
634
634
  { userRoles: [], requiredRoles: ["Admin"], expected: false },
635
635
  // Empty required-roles list denies everyone under default-deny.
636
636
  { userRoles: ["Admin"], requiredRoles: [], expected: false },
637
- ])("user $userRoles vs required $requiredRoles → $expected", ({
638
- userRoles,
639
- requiredRoles,
640
- expected,
641
- }) => {
642
- const user = createTestUser({ roles: userRoles });
643
- expect(hasAccess(user, { roles: requiredRoles })).toBe(expected);
644
- });
637
+ ])(
638
+ "user $userRoles vs required $requiredRoles → $expected",
639
+ ({ userRoles, requiredRoles, expected }) => {
640
+ const user = createTestUser({ roles: userRoles });
641
+ expect(hasAccess(user, { roles: requiredRoles })).toBe(expected);
642
+ },
643
+ );
645
644
 
646
645
  test("missing access rule denies access (default-deny)", () => {
647
646
  const user = createTestUser({ roles: ["Employee"] });
@@ -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) ---
@@ -120,7 +120,7 @@ describe("softDeleteCleanupJob handler", () => {
120
120
  test("cutoff defaults to DEFAULT_GRACE_DAYS when no config resolver", async () => {
121
121
  const calls: DeleteCall[] = [];
122
122
  await softDeleteCleanupJob({}, makeCtx({ calls }));
123
- const cutoff = (calls[0]?.where["deletedAt"] as { lt: Temporal.Instant }).lt;
123
+ const cutoff = (calls[0]!.where["deletedAt"] as { lt: Temporal.Instant }).lt;
124
124
  const expected = Temporal.Now.instant().subtract({ hours: DEFAULT_GRACE_DAYS * 24 });
125
125
  expect(Math.abs(cutoff.epochMilliseconds - expected.epochMilliseconds)).toBeLessThan(10_000);
126
126
  });
@@ -128,7 +128,7 @@ describe("softDeleteCleanupJob handler", () => {
128
128
  test("honours a per-tenant grace-days value from the config resolver", async () => {
129
129
  const calls: DeleteCall[] = [];
130
130
  await softDeleteCleanupJob({}, makeCtx({ calls, graceDays: 7 }));
131
- const cutoff = (calls[0]?.where["deletedAt"] as { lt: Temporal.Instant }).lt;
131
+ const cutoff = (calls[0]!.where["deletedAt"] as { lt: Temporal.Instant }).lt;
132
132
  const expected = Temporal.Now.instant().subtract({ hours: 7 * 24 });
133
133
  expect(Math.abs(cutoff.epochMilliseconds - expected.epochMilliseconds)).toBeLessThan(10_000);
134
134
  });
@@ -155,7 +155,7 @@ describe("softDeleteCleanupSystemJob handler", () => {
155
155
  test("cutoff is DEFAULT_GRACE_DAYS — no per-tenant config to read", async () => {
156
156
  const calls: DeleteCall[] = [];
157
157
  await softDeleteCleanupSystemJob({}, makeCtx({ calls }));
158
- const cutoff = (calls[0]?.where["deletedAt"] as { lt: Temporal.Instant }).lt;
158
+ const cutoff = (calls[0]!.where["deletedAt"] as { lt: Temporal.Instant }).lt;
159
159
  const expected = Temporal.Now.instant().subtract({ hours: DEFAULT_GRACE_DAYS * 24 });
160
160
  expect(Math.abs(cutoff.epochMilliseconds - expected.epochMilliseconds)).toBeLessThan(10_000);
161
161
  });
@@ -15,6 +15,7 @@ import type {
15
15
  DashboardPanelDefinition,
16
16
  DashboardScreenDefinition,
17
17
  DashboardStatGroupPanel,
18
+ EditLayout,
18
19
  FieldCondition,
19
20
  RowAction,
20
21
  RowFieldExtractor,
@@ -71,6 +72,58 @@ function validateRowActionNavigateParams(
71
72
  }
72
73
  }
73
74
 
75
+ // Wizard layouts (mode: "wizard") render one section per step — a single
76
+ // step (or a step without a title, which would leave the progress
77
+ // indicator blank) defeats the point, so both fail at boot rather than
78
+ // as a broken step UI. Missing/blank titles are checked identically for
79
+ // both section kinds — EditExtensionSection.title is required by type,
80
+ // but that doesn't stop author code that circumvented the check from
81
+ // passing an empty string.
82
+ function validateWizardLayout(
83
+ featureName: string,
84
+ screenId: string,
85
+ screenType: "entityEdit" | "actionForm",
86
+ layout: EditLayout,
87
+ featureMap: ReadonlyMap<string, FeatureDefinition>,
88
+ ): void {
89
+ // "form-draft" is hardcoded because the framework layer must not depend on
90
+ // @cosmicdrift/kumiko-bundled-features — same precedence as the
91
+ // "user-data-rights" check in gdpr-storage.ts.
92
+ if (layout.draft === true) {
93
+ if (layout.mode !== "wizard") {
94
+ throw new Error(
95
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) sets draft: true but ` +
96
+ `mode is not "wizard" — draft persistence only applies to wizard layouts. Remove ` +
97
+ `draft: true or set mode: "wizard".`,
98
+ );
99
+ }
100
+ if (!featureMap.has("form-draft")) {
101
+ throw new Error(
102
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) sets draft: true but the ` +
103
+ `bundled feature "form-draft" is not mounted — every resume would silently lose its ` +
104
+ `values. Add formDraftFeature() from @cosmicdrift/kumiko-bundled-features to the app's ` +
105
+ `feature list.`,
106
+ );
107
+ }
108
+ }
109
+ // skip: mode omitted/"single" — no wizard constraints apply.
110
+ if (layout.mode !== "wizard") return;
111
+ if (layout.sections.length < 2) {
112
+ throw new Error(
113
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) has mode: "wizard" but only ` +
114
+ `${layout.sections.length} section(s) — a wizard needs at least 2 sections (one per step).`,
115
+ );
116
+ }
117
+ layout.sections.forEach((section, index) => {
118
+ if (section.title === undefined || section.title.trim().length === 0) {
119
+ throw new Error(
120
+ `[Feature ${featureName}] Screen "${screenId}" (${screenType}) has mode: "wizard" but ` +
121
+ `sections[${index}] has no title — every wizard step needs a title.`,
122
+ );
123
+ }
124
+ });
125
+ }
126
+
74
127
  // --- Screen validation ---
75
128
  //
76
129
  // For every r.screen() declaration check what's locally knowable at boot:
@@ -441,6 +494,7 @@ export function validateScreens(
441
494
  }
442
495
  }
443
496
  }
497
+ validateWizardLayout(feature.name, screenId, "actionForm", screen.layout, featureMap);
444
498
  if (screen.redirect !== undefined) {
445
499
  // redirect ist die kurze Screen-ID (z.B. "item-list"); der
446
500
  // nav-Router resolved sie beim Mount gegen die Schema-Map.
@@ -772,6 +826,7 @@ export function validateScreens(
772
826
  }
773
827
  }
774
828
  }
829
+ validateWizardLayout(feature.name, screenId, "entityEdit", screen.layout, featureMap);
775
830
  }
776
831
  }
777
832
  }
@@ -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>>,
@@ -139,7 +139,7 @@ describe("upcaster error-policy: quarantine", () => {
139
139
  expect(result).toHaveLength(1);
140
140
  expect(result[0]?.id).toBe("10");
141
141
  expect(result[0]?.eventVersion).toBe(2);
142
- expect((result[0]?.payload as { migrated?: boolean }).migrated).toBe(true);
142
+ expect((result[0]!.payload as { migrated?: boolean }).migrated).toBe(true);
143
143
 
144
144
  const dl = await listDeadLetters(testDb.db);
145
145
  expect(dl).toHaveLength(1);
@@ -729,33 +729,36 @@ describe("runPostSaveBatch / runPostDeleteBatch", () => {
729
729
  (pipeline: ReturnType<typeof createLifecycleHooks>) =>
730
730
  pipeline.runPostDeleteBatch([deletectx], {}),
731
731
  ],
732
- ])("one %s hook throwing doesn't stop the others (Promise.allSettled) — logged, never thrown", async (_name, buildHooks, run) => {
733
- const consoleSpy = spyOn(console, "error").mockImplementation(() => {});
734
- try {
735
- const calls: string[] = [];
736
- const systemHooks = buildHooks([
737
- {
738
- name: "failing",
739
- priority: 1000,
740
- fn: async () => {
741
- throw new Error("batch-hook-boom");
732
+ ])(
733
+ "one %s hook throwing doesn't stop the others (Promise.allSettled) logged, never thrown",
734
+ async (_name, buildHooks, run) => {
735
+ const consoleSpy = spyOn(console, "error").mockImplementation(() => {});
736
+ try {
737
+ const calls: string[] = [];
738
+ const systemHooks = buildHooks([
739
+ {
740
+ name: "failing",
741
+ priority: 1000,
742
+ fn: async () => {
743
+ throw new Error("batch-hook-boom");
744
+ },
742
745
  },
743
- },
744
- {
745
- name: "ok",
746
- priority: 1001,
747
- fn: async () => {
748
- calls.push("ok-ran");
746
+ {
747
+ name: "ok",
748
+ priority: 1001,
749
+ fn: async () => {
750
+ calls.push("ok-ran");
751
+ },
749
752
  },
750
- },
751
- ]);
752
- const pipeline = createLifecycleHooks(makeRegistry(), systemHooks);
753
- // Must not throw.
754
- await run(pipeline);
755
- expect(calls).toEqual(["ok-ran"]);
756
- expect(consoleSpy).toHaveBeenCalled();
757
- } finally {
758
- consoleSpy.mockRestore();
759
- }
760
- });
753
+ ]);
754
+ const pipeline = createLifecycleHooks(makeRegistry(), systemHooks);
755
+ // Must not throw.
756
+ await run(pipeline);
757
+ expect(calls).toEqual(["ok-ran"]);
758
+ expect(consoleSpy).toHaveBeenCalled();
759
+ } finally {
760
+ consoleSpy.mockRestore();
761
+ }
762
+ },
763
+ );
761
764
  });
@@ -297,7 +297,7 @@ describe("entity cache", () => {
297
297
 
298
298
  const single = await cache.get("00000000-0000-4000-8000-000000000001", "event", 42);
299
299
  expect(single?.["insertedAt"]).toBeInstanceOf(Date);
300
- expect((single?.["insertedAt"] as Date).getTime()).toBe(insertedAt.getTime());
300
+ expect((single!["insertedAt"] as Date).getTime()).toBe(insertedAt.getTime());
301
301
  // Non-ISO strings must not be coerced
302
302
  expect(typeof single?.["title"]).toBe("string");
303
303
  expect(single?.["note"]).toBe("not a date: 2026-04");
@@ -4,26 +4,19 @@ import { isValidIanaTimeZone } from "../iana";
4
4
  describe("isValidIanaTimeZone", () => {
5
5
  // Die 5 Zonen der geplanten CI-TZ-Matrix (timezones.md) müssen alle gültig
6
6
  // sein — sonst kann die Matrix sie nicht setzen.
7
- test.each([
8
- "UTC",
9
- "Europe/Berlin",
10
- "America/Los_Angeles",
11
- "Asia/Tokyo",
12
- "Pacific/Apia",
13
- ])("akzeptiert kanonische Zone %s", (zone) => {
14
- expect(isValidIanaTimeZone(zone)).toBe(true);
15
- });
7
+ test.each(["UTC", "Europe/Berlin", "America/Los_Angeles", "Asia/Tokyo", "Pacific/Apia"])(
8
+ "akzeptiert kanonische Zone %s",
9
+ (zone) => {
10
+ expect(isValidIanaTimeZone(zone)).toBe(true);
11
+ },
12
+ );
16
13
 
17
- test.each([
18
- "",
19
- "Mars/Phobos",
20
- "europe/berlin",
21
- "Europe/Berlin ",
22
- "GMT+2",
23
- "not-a-zone",
24
- ])("lehnt ungültigen / nicht-kanonischen String %p ab", (value) => {
25
- expect(isValidIanaTimeZone(value)).toBe(false);
26
- });
14
+ test.each(["", "Mars/Phobos", "europe/berlin", "Europe/Berlin ", "GMT+2", "not-a-zone"])(
15
+ "lehnt ungültigen / nicht-kanonischen String %p ab",
16
+ (value) => {
17
+ expect(isValidIanaTimeZone(value)).toBe(false);
18
+ },
19
+ );
27
20
 
28
21
  // Intl.supportedValuesOf("timeZone") listet nur kanonische Namen — gültige
29
22
  // IANA-Aliase fehlen darin, obwohl Intl.DateTimeFormat/Temporal/ctx.tz.parse
@@ -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 {