@cosmicdrift/kumiko-framework 0.193.1 → 0.194.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 (32) 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__/variant-key.test.ts +2 -2
  11. package/src/derivatives/__tests__/variant-route.integration.test.ts +28 -0
  12. package/src/derivatives/variant-key.ts +1 -1
  13. package/src/engine/__tests__/boot-validator.test.ts +140 -0
  14. package/src/engine/__tests__/embedded-derived.test.ts +35 -0
  15. package/src/engine/__tests__/engine.test.ts +20 -0
  16. package/src/engine/__tests__/schema-builder.test.ts +93 -0
  17. package/src/engine/boot-validator/entity-handler.ts +64 -5
  18. package/src/engine/boot-validator/screens.ts +57 -36
  19. package/src/engine/embedded-derived.ts +9 -1
  20. package/src/engine/schema-builder.ts +33 -23
  21. package/src/entrypoint/__tests__/split-deploy.integration.test.ts +37 -6
  22. package/src/errors/zod-bridge.ts +4 -9
  23. package/src/event-store/__tests__/perf.integration.test.ts +2 -11
  24. package/src/files/file-routes.ts +20 -6
  25. package/src/files/storage-tracking.ts +2 -1
  26. package/src/jobs/job-runner.ts +18 -9
  27. package/src/logging/__tests__/fallback-logger.test.ts +43 -0
  28. package/src/logging/utils.ts +14 -1
  29. package/src/observability/__tests__/metrics-handle.test.ts +30 -0
  30. package/src/observability/metrics-handle.ts +24 -12
  31. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +5 -5
  32. package/src/ui-types/index.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.193.1",
3
+ "version": "0.194.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>",
@@ -186,7 +186,7 @@
186
186
  "./package.json": "./package.json"
187
187
  },
188
188
  "dependencies": {
189
- "@cosmicdrift/kumiko-types": "0.193.1",
189
+ "@cosmicdrift/kumiko-types": "0.194.0",
190
190
  "bullmq": "^5.76.7",
191
191
  "bun-types": "^1.3.13",
192
192
  "hono": "^4.13.1",
@@ -202,7 +202,7 @@
202
202
  "zod": "^4.4.3"
203
203
  },
204
204
  "devDependencies": {
205
- "@cosmicdrift/kumiko-dispatcher-live": "0.193.1",
205
+ "@cosmicdrift/kumiko-dispatcher-live": "0.194.0",
206
206
  "bun-types": "^1.3.13",
207
207
  "pino-pretty": "^13.1.3"
208
208
  },
@@ -0,0 +1,71 @@
1
+ // kumiko-framework#1977: r.httpRoute's systemQuery used to always run with
2
+ // requestContext.get()?.ip === undefined, so a `rateLimit: {per: "ip"}`
3
+ // handler invoked through it silently never bucketed — see server.ts's
4
+ // systemQuery wiring. Proves the fix: repeated calls through the same
5
+ // httpRoute, same client IP, DO hit the limit.
6
+
7
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
8
+ import { z } from "zod";
9
+ import { createEntity, createTextField, defineFeature } from "../../engine";
10
+ import type { TenantId } from "../../engine/types/identifiers";
11
+ import { RateLimitError } from "../../errors";
12
+ import { setupTestStack, type TestStack } from "../../stack";
13
+
14
+ const SYSTEM_TENANT_ID = "00000000-0000-4000-8000-000000000000" as TenantId;
15
+
16
+ const ipLimitedFeature = defineFeature("rl-http", (r) => {
17
+ r.entity("item", createEntity({ table: "Items", fields: { name: createTextField() } }));
18
+ r.queryHandler("ping", z.object({}), async () => ({ ok: true }), {
19
+ access: { roles: ["anonymous"] },
20
+ rateLimit: { per: "ip", limit: 2, windowSeconds: 60 },
21
+ });
22
+ r.httpRoute({
23
+ method: "GET",
24
+ path: "/ping",
25
+ anonymous: true,
26
+ handler: async (c, deps) => {
27
+ try {
28
+ await deps.systemQuery("rl-http:query:ping", {}, SYSTEM_TENANT_ID);
29
+ return c.json({ ok: true });
30
+ } catch (err) {
31
+ if (err instanceof RateLimitError) return c.json({ ok: false }, 429);
32
+ throw err;
33
+ }
34
+ },
35
+ });
36
+ });
37
+
38
+ let stack: TestStack;
39
+
40
+ beforeAll(async () => {
41
+ stack = await setupTestStack({ features: [ipLimitedFeature] });
42
+ });
43
+
44
+ afterAll(async () => {
45
+ await stack.cleanup();
46
+ });
47
+
48
+ beforeEach(async () => {
49
+ await stack.redis.flushNamespace();
50
+ });
51
+
52
+ describe("r.httpRoute → systemQuery propagates requestContext for per-ip rate limiting", () => {
53
+ test("2 calls allowed, 3rd from the same IP is rate-limited", async () => {
54
+ const call = () => stack.app.request("/ping", { headers: { "x-forwarded-for": "9.9.9.1" } });
55
+
56
+ expect((await call()).status).toBe(200);
57
+ expect((await call()).status).toBe(200);
58
+ expect((await call()).status).toBe(429);
59
+ });
60
+
61
+ test("a different client IP gets its own bucket", async () => {
62
+ const callAs = (ip: string) =>
63
+ stack.app.request("/ping", { headers: { "x-forwarded-for": ip } });
64
+
65
+ expect((await callAs("9.9.9.2")).status).toBe(200);
66
+ expect((await callAs("9.9.9.2")).status).toBe(200);
67
+ expect((await callAs("9.9.9.2")).status).toBe(429);
68
+
69
+ expect((await callAs("9.9.9.3")).status).toBe(200);
70
+ });
71
+ });
package/src/api/server.ts CHANGED
@@ -778,7 +778,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
778
778
  // systemQuery is silent dead-code: enforceRateLimit reads
779
779
  // requestContext.get()?.ip, which is undefined here, so
780
780
  // buildBucketKey always returns {kind: "skip"}.
781
- requestContext.run(buildRequestContextData(c), () =>
781
+ requestContext.run(requestContext.get() ?? buildRequestContextData(c), () =>
782
782
  dispatcher.query(type, payload, createAnonymousUser(tenantId)),
783
783
  ),
784
784
  });
@@ -165,6 +165,23 @@ describe("renderMigrationSql — managed recreate vs unmanaged in-place", () =>
165
165
  expect(sql).not.toContain("WARN: column-type-change");
166
166
  });
167
167
 
168
+ test("unmanaged: date → timestamptz type change emits the symmetric UTC-anchored USING clause", () => {
169
+ const prev = snapshotFromMetas([
170
+ meta("store_invoices", { name: "period_from", pgType: "date", notNull: true }),
171
+ ]);
172
+ const next = snapshotFromMetas([
173
+ meta("store_invoices", { name: "period_from", pgType: "timestamptz", notNull: true }),
174
+ ]);
175
+ const sql = renderMigrationSql(diffSnapshots(prev, next), {
176
+ name: "invoice-date-widen",
177
+ sequenceNumber: 8,
178
+ });
179
+ expect(sql).toContain(
180
+ 'ALTER TABLE "store_invoices" ALTER COLUMN "period_from" TYPE timestamptz USING ("period_from"::timestamp AT TIME ZONE \'UTC\');',
181
+ );
182
+ expect(sql).not.toContain("WARN: column-type-change");
183
+ });
184
+
168
185
  test("managed: multiple recreate reasons at once → all named in the warning", () => {
169
186
  const prev = snapshotFromMetas([
170
187
  meta("read_b", { name: "old_col", pgType: "text", notNull: false }, "managed"),
@@ -7,7 +7,7 @@
7
7
  import { describe, expect, test } from "bun:test";
8
8
  import { createEntity, createMoneyField, createTextField } from "../../engine";
9
9
  import type { EntityDefinition } from "../../engine/types";
10
- import { flattenMoney, rehydrateMoney } from "../money";
10
+ import { flattenMoney, type MoneyRead, moneyPayloadToMinorUnits, rehydrateMoney } from "../money";
11
11
 
12
12
  const orderEntity: EntityDefinition = createEntity({
13
13
  defaultCurrency: "EUR",
@@ -115,17 +115,16 @@ describe("rehydrateMoney — Read Convert (minor units → major units)", () =>
115
115
  expect(out["buyingPrice"]).toEqual({ amount: 450, currency: "EUR", amountMinor: 45000 });
116
116
  });
117
117
 
118
- test("amountMinor bleibt exakter Integer, amount kann Float-Drift haben (fw#1830)", () => {
118
+ test("amountMinor bleibt exakter Integer über mehrere Additionen (fw#1830)", () => {
119
119
  const rows = [10, 20, 30].map(
120
120
  (minor) =>
121
121
  rehydrateMoney({ buyingPrice: minor, buyingPriceCurrency: "EUR" }, orderEntity)[
122
122
  "buyingPrice"
123
- ] as { amount: number; amountMinor: number },
123
+ ] as MoneyRead,
124
124
  );
125
125
  const [a, b, c] = rows;
126
126
 
127
127
  expect(a!.amountMinor + b!.amountMinor).toBe(c!.amountMinor);
128
- expect(a!.amount + b!.amount).not.toBe(c!.amount);
129
128
  });
130
129
 
131
130
  test("null/undefined amount → Field wird aus Output entfernt", () => {
@@ -178,7 +177,19 @@ describe("rehydrateMoney — Read Convert (minor units → major units)", () =>
178
177
  test("korrupte string-amount (kein number) → loud throw, kein silent drop", () => {
179
178
  expect(() =>
180
179
  rehydrateMoney({ buyingPrice: "not-a-number", buyingPriceCurrency: "EUR" }, orderEntity),
181
- ).toThrow(/not a number — DB corruption/);
180
+ ).toThrow(/not a safe integer — DB corruption/);
181
+ });
182
+
183
+ test("fractional string amount (fw#1833) → loud throw statt amountMinor mit Nachkommastelle", () => {
184
+ expect(() =>
185
+ rehydrateMoney({ buyingPrice: "45000.7", buyingPriceCurrency: "EUR" }, orderEntity),
186
+ ).toThrow(/not a safe integer — DB corruption/);
187
+ });
188
+
189
+ test("string amount jenseits von Number.MAX_SAFE_INTEGER → loud throw statt Präzisionsverlust", () => {
190
+ expect(() =>
191
+ rehydrateMoney({ buyingPrice: "9007199254740993", buyingPriceCurrency: "EUR" }, orderEntity),
192
+ ).toThrow(/not a safe integer — DB corruption/);
182
193
  });
183
194
 
184
195
  test("unerwarteter amount-Typ (boolean) → loud throw", () => {
@@ -222,3 +233,28 @@ describe("flattenMoney — Strict-Mode Throw", () => {
222
233
  );
223
234
  });
224
235
  });
236
+
237
+ // kumiko-framework#1972: the write-handler-facing counterpart a custom
238
+ // totals check needs when reconciling a top-level money field's payload
239
+ // (major units, `{amount, currency}` or bare number) against an
240
+ // embedded-list row sum (minor-unit integers by convention).
241
+ describe("moneyPayloadToMinorUnits", () => {
242
+ test("{ amount, currency } object, crooked amount, rounds to the exact cent", () => {
243
+ expect(moneyPayloadToMinorUnits({ amount: 1234.56, currency: "EUR" })).toBe(123456);
244
+ });
245
+
246
+ test("legacy bare-number payload (also major units, per flattenMoney's permissive-insert contract)", () => {
247
+ expect(moneyPayloadToMinorUnits(1234.56)).toBe(123456);
248
+ });
249
+
250
+ test("a round amount does not mask a factor-of-100 regression: 30 major -> 3000 minor, not 30", () => {
251
+ expect(moneyPayloadToMinorUnits({ amount: 30, currency: "EUR" })).toBe(3000);
252
+ });
253
+
254
+ test("undefined for a payload without a numeric amount (nothing to compare)", () => {
255
+ expect(moneyPayloadToMinorUnits({ currency: "EUR" })).toBeUndefined();
256
+ expect(moneyPayloadToMinorUnits("100")).toBeUndefined();
257
+ expect(moneyPayloadToMinorUnits(null)).toBeUndefined();
258
+ expect(moneyPayloadToMinorUnits(undefined)).toBeUndefined();
259
+ });
260
+ });
@@ -166,7 +166,7 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
166
166
  const listSql = `SELECT * FROM "${tableName}"${whereClauseSqlText}${orderByClause} LIMIT ${limit}${offsetClause}`;
167
167
 
168
168
  const rawRows = await executeRawQuery<Record<string, unknown>>(db.raw, listSql, params);
169
- // Read-Side rehydrate pro Row + snake→camel coercion für driver-agnostic Feldnamen.
169
+ // Per-row read-side rehydrate + snake→camel coercion for driver-agnostic field names.
170
170
  // Coerce BEFORE rehydrate/decrypt: the raw SELECT * rows carry snake_case
171
171
  // column names, while compound-type lookups (rehydrateMoney et al.) and the
172
172
  // encrypted/pii field lists are all camelCase — running either first on a
package/src/db/index.ts CHANGED
@@ -100,7 +100,7 @@ export {
100
100
  runMigrationsFromDir,
101
101
  splitSqlStatements,
102
102
  } from "./migrate-runner";
103
- export { flattenMoney, rehydrateMoney } from "./money";
103
+ export { flattenMoney, type MoneyRead, moneyPayloadToMinorUnits, rehydrateMoney } from "./money";
104
104
  export {
105
105
  constraintOf,
106
106
  extractPgError,
@@ -284,10 +284,20 @@ function renderColumnChange(tableName: string, change: ColumnChange): readonly s
284
284
  out.push(
285
285
  `ALTER TABLE ${tbl} ALTER COLUMN ${col} TYPE date USING (${col} AT TIME ZONE 'UTC')::date;`,
286
286
  );
287
+ } else if (from === "date" && (to === "timestamptz" || to === "timestamptz(3)")) {
288
+ // Same non-determinism, reversed: a bare `date → timestamptz` cast
289
+ // interprets the calendar date at session-TimeZone midnight. Anchor
290
+ // explicitly at UTC midnight instead.
291
+ out.push(
292
+ `-- date → ${to}: explicit UTC anchor — a bare cast would use the session TimeZone (non-deterministic).`,
293
+ );
294
+ out.push(
295
+ `ALTER TABLE ${tbl} ALTER COLUMN ${col} TYPE ${to} USING (${col}::timestamp AT TIME ZONE 'UTC');`,
296
+ );
287
297
  } else {
288
- // pg ALTER TYPE braucht oft USING-clause für nicht-implicit-castable
289
- // type-changes. Wir emittieren das als Reviewer-Kommentar + raw cast —
290
- // App-Author muss prüfen ob das gewünscht ist.
298
+ // pg ALTER TYPE often needs a USING clause for non-implicitly-castable
299
+ // type changes. Emitted as a reviewer comment + raw cast — the app
300
+ // author has to confirm it's intended.
291
301
  out.push(`-- WARN: column-type-change ${from} → ${to}. Review USING-clause if needed.`);
292
302
  out.push(`ALTER TABLE ${tbl} ALTER COLUMN ${col} TYPE ${to};`);
293
303
  }
package/src/db/money.ts CHANGED
@@ -44,6 +44,27 @@ function toMajorUnits(amountMinor: number): number {
44
44
  return amountMinor / MINOR_UNIT_SCALE;
45
45
  }
46
46
 
47
+ // One money field's write payload — `{ amount, currency }` or a bare number
48
+ // (both MAJOR units, see file header) — reduced to minor units. This is the
49
+ // counterpart a write-handler needs when comparing a top-level money field
50
+ // against an `embeddedSubFieldToZod` list-row sum: rows are minor-unit
51
+ // integers by convention (currency lives on the head aggregate, not the
52
+ // row), so the two can only be compared once the sibling amount has been
53
+ // scaled the same way. `applyTotalsMatchRefinements` (schema-builder.ts)
54
+ // uses this internally for `EmbeddedFieldDef.totalsMatch`; exported so a
55
+ // custom write-handler doing its own total check doesn't have to re-derive
56
+ // this unwrap-and-scale step by hand (kumiko-framework#1972 — a hand-rolled
57
+ // comparison of a raw `{amount}` against a raw minor-unit row sum is exactly
58
+ // what silently fails 100x off).
59
+ export function moneyPayloadToMinorUnits(raw: unknown): number | undefined {
60
+ if (typeof raw === "number") return toMinorUnits(raw);
61
+ if (typeof raw === "object" && raw !== null && "amount" in raw) {
62
+ const amount = (raw as { amount: unknown }).amount;
63
+ if (typeof amount === "number") return toMinorUnits(amount);
64
+ }
65
+ return undefined;
66
+ }
67
+
47
68
  /**
48
69
  * API → DB: money-Felder zu zwei flachen Spalten flatten.
49
70
  *
@@ -101,6 +122,15 @@ export function flattenMoney(
101
122
  return result;
102
123
  }
103
124
 
125
+ /** Shape of a single rehydrated money field — {amount major, currency,
126
+ * amountMinor exact integer cents}. Exported so consumers type their own
127
+ * copy against this instead of re-declaring the shape by hand. */
128
+ export type MoneyRead = {
129
+ readonly amount: number;
130
+ readonly currency: string;
131
+ readonly amountMinor: number;
132
+ };
133
+
104
134
  /**
105
135
  * DB → API: zwei flache Spalten zu combined { amount, currency } rehydraten.
106
136
  *
@@ -134,15 +164,15 @@ export function rehydrateMoney(
134
164
  amountMinor = amountRaw;
135
165
  } else if (typeof amountRaw === "bigint") {
136
166
  amountMinor = Number(amountRaw);
137
- if (Number.isNaN(amountMinor)) {
138
- throw new Error(`rehydrateMoney: field "${name}" bigint amount is not a number`);
167
+ if (!Number.isSafeInteger(amountMinor)) {
168
+ throw new Error(`rehydrateMoney: field "${name}" bigint amount is not a safe integer`);
139
169
  }
140
170
  } else if (typeof amountRaw === "string" && amountRaw !== "") {
141
171
  // PG-driver liefert BIGINT manchmal als String (>2^53 sicher).
142
172
  amountMinor = Number(amountRaw);
143
- if (Number.isNaN(amountMinor)) {
173
+ if (!Number.isSafeInteger(amountMinor)) {
144
174
  throw new Error(
145
- `rehydrateMoney: field "${name}" amount string "${amountRaw}" is not a number — DB corruption?`,
175
+ `rehydrateMoney: field "${name}" amount string "${amountRaw}" is not a safe integer — DB corruption?`,
146
176
  );
147
177
  }
148
178
  } else {
@@ -33,8 +33,8 @@ describe("variantSuffix", () => {
33
33
  expect(variantSuffix("thumb", spec)).not.toBe(variantSuffix("card", spec));
34
34
  });
35
35
 
36
- test("shape is `<name>-<8 hex chars>`", () => {
37
- expect(variantSuffix("thumb", { maxEdge: 512 })).toMatch(/^thumb-[0-9a-f]{8}$/);
36
+ test("shape is `<name>-<16 hex chars>`", () => {
37
+ expect(variantSuffix("thumb", { maxEdge: 512 })).toMatch(/^thumb-[0-9a-f]{16}$/);
38
38
  });
39
39
 
40
40
  test("a path-traversal name is rejected — it would escape the tenant prefix in the derived key", () => {
@@ -94,6 +94,7 @@ describe("GET /api/files/:id/variant/:name", () => {
94
94
 
95
95
  expect(res.status).toBe(200);
96
96
  expect(res.headers.get("Content-Type")).toBe("image/webp");
97
+ expect(res.headers.get("Cache-Control")).toBe("private, max-age=31536000, immutable");
97
98
  expect(new Uint8Array(await res.arrayBuffer())).toEqual(VARIANT_BYTES);
98
99
  });
99
100
 
@@ -166,4 +167,31 @@ describe("GET /api/files/:id/variant/:name", () => {
166
167
 
167
168
  expect(res.status).toBe(401);
168
169
  });
170
+
171
+ // createImageField has no `accept` restriction here, so validateFile
172
+ // never rejects a non-image upload against this field — the source
173
+ // mimeType a renderer has to handle is client-controlled, not just a
174
+ // deployment-time mount decision.
175
+ test("a source mimeType with no matching renderer (client uploaded a non-image) returns 415, not a 500", async () => {
176
+ const token = await stack.jwt.sign(user);
177
+ const fd = new FormData();
178
+ fd.append("file", new File([Buffer.from([1, 2, 3])], "doc.pdf", { type: "application/pdf" }));
179
+ fd.append("entityType", "photo");
180
+ fd.append("fieldName", "avatar");
181
+ const { body, contentType } = await buildMultipartBody(fd);
182
+ const uploadRes = await stack.app.request("/api/files", {
183
+ method: "POST",
184
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
185
+ body,
186
+ });
187
+ expect(uploadRes.status).toBe(201);
188
+ const { id: fileId } = (await uploadRes.json()) as { id: string };
189
+
190
+ const res = await stack.app.request(`/api/files/${fileId}/variant/thumb`, {
191
+ headers: { Authorization: `Bearer ${token}` },
192
+ });
193
+
194
+ expect(res.status).toBe(415);
195
+ expect(await res.json()).toEqual({ error: "unsupported_media_type" });
196
+ });
169
197
  });
@@ -22,7 +22,7 @@ export function canonicalJson(value: unknown): string {
22
22
  // forever after a spec change; hashing the spec into the key means a
23
23
  // changed spec is automatically a new URL.
24
24
  export function specHash(spec: VariantSpec): string {
25
- return createHash("sha256").update(canonicalJson(spec)).digest("hex").slice(0, 8);
25
+ return createHash("sha256").update(canonicalJson(spec)).digest("hex").slice(0, 16);
26
26
  }
27
27
 
28
28
  export const VARIANT_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/i;
@@ -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(