@cosmicdrift/kumiko-framework 0.188.0 → 0.189.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 +3 -3
- package/src/bun-db/__tests__/coerce-row-plain-date.test.ts +70 -0
- package/src/bun-db/__tests__/where-patterns.integration.test.ts +28 -0
- package/src/bun-db/query.ts +84 -14
- package/src/db/__tests__/column-ddl.integration.test.ts +9 -0
- package/src/db/__tests__/migrate-generator.test.ts +23 -0
- package/src/db/__tests__/schema-migration.integration.test.ts +61 -1
- package/src/db/dialect.ts +10 -0
- package/src/db/entity-table-meta.ts +3 -0
- package/src/db/migrate-generator.ts +20 -7
- package/src/db/table-builder.ts +25 -19
- package/src/engine/__tests__/boot-validator.test.ts +277 -0
- package/src/engine/boot-validator/screens.ts +81 -17
- package/src/errors/__tests__/classes.test.ts +39 -0
- package/src/errors/zod-bridge.ts +18 -1
- package/src/event-store/__tests__/perf.integration.test.ts +33 -17
- package/src/utils/__tests__/safe-json-temporal.test.ts +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.189.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.189.0",
|
|
186
186
|
"bullmq": "^5.76.7",
|
|
187
187
|
"bun-types": "^1.3.13",
|
|
188
188
|
"hono": "^4.13.1",
|
|
@@ -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.189.0",
|
|
202
202
|
"bun-types": "^1.3.13",
|
|
203
203
|
"pino-pretty": "^13.1.3"
|
|
204
204
|
},
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// kumiko-framework#1924: `type:"date"` used to alias onto instant()
|
|
2
|
+
// (TIMESTAMPTZ) and round-trip through Temporal.Instant, making the
|
|
3
|
+
// read value depend on the process/session timezone. This test pins the
|
|
4
|
+
// fixed coercion: a `date` pgType column reads back as Temporal.PlainDate,
|
|
5
|
+
// and — the part that actually matters — the calendar day survives a
|
|
6
|
+
// non-UTC process TZ, because Bun.SQL hands back a Date anchored at UTC
|
|
7
|
+
// midnight and the coercion must read it via that anchor, not local getters.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { Temporal } from "temporal-polyfill";
|
|
11
|
+
import { coerceRow, type TableInfo } from "../query";
|
|
12
|
+
|
|
13
|
+
function dateTableInfo(): TableInfo {
|
|
14
|
+
return {
|
|
15
|
+
name: "probe",
|
|
16
|
+
columnOf: (f) => f,
|
|
17
|
+
pgTypeOf: (c) => (c === "published_at" ? "date" : undefined),
|
|
18
|
+
bigintJsModeOf: () => undefined,
|
|
19
|
+
fieldOf: (c) => c,
|
|
20
|
+
hasColumn: () => true,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("coerceRow — date → Temporal.PlainDate", () => {
|
|
25
|
+
test("coerces a driver Date (UTC-midnight anchored) to the same calendar day", () => {
|
|
26
|
+
// Simulates the Bun.SQL wire value for `SELECT '2026-03-15'::date` —
|
|
27
|
+
// verified empirically to be a JS Date at epoch 1773532800000
|
|
28
|
+
// (2026-03-15T00:00:00.000Z) regardless of process TZ.
|
|
29
|
+
const row = { published_at: new Date(Date.UTC(2026, 2, 15)) };
|
|
30
|
+
const result = coerceRow(row, dateTableInfo());
|
|
31
|
+
expect(result.published_at).toBeInstanceOf(Temporal.PlainDate);
|
|
32
|
+
expect((result.published_at as unknown as Temporal.PlainDate).toString()).toBe("2026-03-15");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("does not drift the day under a negative-offset process TZ", () => {
|
|
36
|
+
// Regression guard for the exact bug shape: naive `new
|
|
37
|
+
// Date(...).getDate()` reads the 14th under America/Los_Angeles for a
|
|
38
|
+
// UTC-midnight-anchored 15th. plainDateFromDriver must not do that.
|
|
39
|
+
const savedTz = process.env.TZ;
|
|
40
|
+
process.env.TZ = "America/Los_Angeles";
|
|
41
|
+
try {
|
|
42
|
+
const row = { published_at: new Date(Date.UTC(2026, 2, 15)) };
|
|
43
|
+
const result = coerceRow(row, dateTableInfo());
|
|
44
|
+
expect((result.published_at as unknown as Temporal.PlainDate).toString()).toBe("2026-03-15");
|
|
45
|
+
} finally {
|
|
46
|
+
if (savedTz === undefined) delete process.env.TZ;
|
|
47
|
+
else process.env.TZ = savedTz;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("coerces a plain 'yyyy-mm-dd' driver string", () => {
|
|
52
|
+
const row = { published_at: "2026-12-01" };
|
|
53
|
+
const result = coerceRow(row, dateTableInfo());
|
|
54
|
+
expect(result.published_at).toBeInstanceOf(Temporal.PlainDate);
|
|
55
|
+
expect((result.published_at as unknown as Temporal.PlainDate).toString()).toBe("2026-12-01");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("passes an already-PlainDate value through unchanged", () => {
|
|
59
|
+
const pd = Temporal.PlainDate.from("2026-01-01");
|
|
60
|
+
const row = { published_at: pd };
|
|
61
|
+
const result = coerceRow(row, dateTableInfo());
|
|
62
|
+
expect(result.published_at).toBe(pd);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("leaves null untouched", () => {
|
|
66
|
+
const row = { published_at: null };
|
|
67
|
+
const result = coerceRow(row, dateTableInfo());
|
|
68
|
+
expect(result.published_at).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// JSONB top-level equality: wird getestet (JSON.stringify + ::jsonb cast in prepareValue).
|
|
11
11
|
// Deep-path-queries (->>'key') sind out-of-scope für bun-db's WhereObject.
|
|
12
12
|
import { afterAll, describe, expect, test } from "bun:test";
|
|
13
|
+
import { Temporal } from "temporal-polyfill";
|
|
13
14
|
import { insertMany, selectMany } from "../query";
|
|
14
15
|
import { closeDb, withTable } from "./_helpers";
|
|
15
16
|
|
|
@@ -26,6 +27,8 @@ const boolCols = [{ name: "active", pgType: "boolean" as const, notNull: false }
|
|
|
26
27
|
|
|
27
28
|
const refCols = [{ name: "ref", pgType: "uuid" as const, notNull: false }] as const;
|
|
28
29
|
|
|
30
|
+
const dateCols = [{ name: "day", pgType: "date" as const, notNull: true }] as const;
|
|
31
|
+
|
|
29
32
|
describe("where — primitive equality", () => {
|
|
30
33
|
test("string equality", async () => {
|
|
31
34
|
await withTable(strNumCols, async ({ db, meta }) => {
|
|
@@ -165,6 +168,31 @@ describe("where — WhereOperator", () => {
|
|
|
165
168
|
});
|
|
166
169
|
});
|
|
167
170
|
|
|
171
|
+
describe("where — date column (kumiko-framework#1924)", () => {
|
|
172
|
+
test("plain 'yyyy-mm-dd' string equality matches the seeded row", async () => {
|
|
173
|
+
await withTable(dateCols, async ({ db, meta }) => {
|
|
174
|
+
await insertMany(db, meta, [{ day: "2026-03-15" }, { day: "2026-03-16" }]);
|
|
175
|
+
const rows = await selectMany(db, meta, { day: "2026-03-15" });
|
|
176
|
+
expect(rows.length).toBe(1);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("Temporal.Instant filter value still matches (compat cushion for pre-#1924 callers)", async () => {
|
|
181
|
+
// Before this fix, `date` was TIMESTAMPTZ and callers built where-filters
|
|
182
|
+
// with a Temporal.Instant (e.g. Temporal.Now.instant()). prepareValue
|
|
183
|
+
// anchors it at UTC before binding, so existing filter code doesn't
|
|
184
|
+
// start silently matching nothing (or throwing) after the column
|
|
185
|
+
// becomes a real `date`.
|
|
186
|
+
await withTable(dateCols, async ({ db, meta }) => {
|
|
187
|
+
await insertMany(db, meta, [{ day: "2026-03-15" }, { day: "2026-03-16" }]);
|
|
188
|
+
const rows = await selectMany(db, meta, {
|
|
189
|
+
day: Temporal.Instant.from("2026-03-15T00:00:00Z") as unknown as string,
|
|
190
|
+
});
|
|
191
|
+
expect(rows.length).toBe(1);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
168
196
|
describe("where — multi-field AND", () => {
|
|
169
197
|
test("zwei Felder kombiniert (UND-Semantik)", async () => {
|
|
170
198
|
await withTable(strNumCols, async ({ db, meta }) => {
|
package/src/bun-db/query.ts
CHANGED
|
@@ -376,6 +376,42 @@ function instantFromDriver(value: unknown): Temporal.Instant | null {
|
|
|
376
376
|
return null;
|
|
377
377
|
}
|
|
378
378
|
|
|
379
|
+
function isTemporalPlainDate(v: unknown): boolean {
|
|
380
|
+
return (
|
|
381
|
+
typeof v === "object" &&
|
|
382
|
+
v !== null &&
|
|
383
|
+
typeof (v as { day?: unknown }).day === "number" &&
|
|
384
|
+
typeof (v as { calendarId?: unknown }).calendarId === "string"
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Verified empirically against Bun.SQL (kumiko-framework#1924): a `date`
|
|
389
|
+
// column comes back as a native JS Date, but ALWAYS anchored at UTC
|
|
390
|
+
// midnight regardless of process TZ — epochMilliseconds is stable across
|
|
391
|
+
// zones, only the local wall-clock read of that Date would drift the day
|
|
392
|
+
// (e.g. America/Los_Angeles reads back the 14th for a stored 15th). Routing
|
|
393
|
+
// through Instant→ZonedDateTimeISO("UTC")→PlainDate reads the calendar day
|
|
394
|
+
// back deterministically without ever touching a local Date getter — the
|
|
395
|
+
// same +value idiom instantFromDriver uses (guard-no-date-api: no
|
|
396
|
+
// `.getTime()`), just anchored to a fixed zone instead of process-local.
|
|
397
|
+
function plainDateFromDriver(value: unknown): Temporal.PlainDate | null {
|
|
398
|
+
if (value === null || value === undefined) return null;
|
|
399
|
+
if (isTemporalPlainDate(value)) return value as Temporal.PlainDate;
|
|
400
|
+
if (typeof value === "string") {
|
|
401
|
+
const isoDay = /^\d{4}-\d{2}-\d{2}/.exec(value)?.[0];
|
|
402
|
+
if (isoDay === undefined) return null;
|
|
403
|
+
try {
|
|
404
|
+
return Temporal.PlainDate.from(isoDay);
|
|
405
|
+
} catch {
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (value instanceof Date) {
|
|
410
|
+
return Temporal.Instant.fromEpochMilliseconds(+value).toZonedDateTimeISO("UTC").toPlainDate();
|
|
411
|
+
}
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
|
|
379
415
|
// Walk the driver-row, applying three boundary-conversions per known column:
|
|
380
416
|
// - rename key snake_case → camelCase JS field-name (drizzle did this
|
|
381
417
|
// invisibly via its column-mapper; native dialect rebuild lost it)
|
|
@@ -400,6 +436,9 @@ export function coerceRow<T extends Record<string, unknown>>(row: T, info: Table
|
|
|
400
436
|
if (pgType === "timestamptz" || pgType === "timestamptz(3)") {
|
|
401
437
|
const t = instantFromDriver(value);
|
|
402
438
|
if (t !== null) coerced = t;
|
|
439
|
+
} else if (pgType === "date") {
|
|
440
|
+
const d = plainDateFromDriver(value);
|
|
441
|
+
if (d !== null) coerced = d;
|
|
403
442
|
} else if (pgType === "jsonb" && typeof value === "string") {
|
|
404
443
|
coerced = parseJsonSafe(value, value);
|
|
405
444
|
} else if (
|
|
@@ -466,29 +505,60 @@ function isSqlExpression(v: unknown): v is { kind: "sql-expr"; text: string } {
|
|
|
466
505
|
return typeof v === "object" && v !== null && (v as { kind?: unknown }).kind === "sql-expr";
|
|
467
506
|
}
|
|
468
507
|
|
|
508
|
+
// A `date` column takes a plain "yyyy-mm-dd" string (or PlainDate.toString())
|
|
509
|
+
// as-is — no Instant detour. Postgres parses a DATE literal without ever
|
|
510
|
+
// consulting the session timezone, so this is TZ-independent by construction
|
|
511
|
+
// (verified empirically — see plainDateFromDriver above for the read side).
|
|
512
|
+
// Compat cushion: pre-#1924 code (this field used to be an Instant) may still
|
|
513
|
+
// hand a Temporal.Instant into a where-filter or write on a `date` column —
|
|
514
|
+
// same UTC anchor as plainDateFromDriver's read side, so a filter built with
|
|
515
|
+
// `Temporal.Now.instant()` keeps working instead of binding a raw Instant the
|
|
516
|
+
// driver can't serialize.
|
|
517
|
+
function prepareDateValue(value: unknown): PreparedValue | undefined {
|
|
518
|
+
if (isTemporalPlainDate(value)) {
|
|
519
|
+
return { kind: "param", sql: "", bound: (value as Temporal.PlainDate).toString() };
|
|
520
|
+
}
|
|
521
|
+
if (isTemporalInstant(value)) {
|
|
522
|
+
const day = (value as Temporal.Instant).toZonedDateTimeISO("UTC").toPlainDate();
|
|
523
|
+
return { kind: "param", sql: "", bound: day.toString() };
|
|
524
|
+
}
|
|
525
|
+
return undefined;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// structured values bind directly with an ::jsonb cast; JSON.stringify first
|
|
529
|
+
// would produce a JSON-string scalar instead of the structured value.
|
|
530
|
+
function prepareJsonbValue(value: unknown): PreparedValue | undefined {
|
|
531
|
+
if (typeof value === "boolean") {
|
|
532
|
+
return { kind: "param", sql: "::text::jsonb", bound: JSON.stringify(value) };
|
|
533
|
+
}
|
|
534
|
+
if (typeof value !== "object" || isTemporalInstant(value)) {
|
|
535
|
+
return undefined;
|
|
536
|
+
}
|
|
537
|
+
if (!Array.isArray(value)) {
|
|
538
|
+
return { kind: "param", sql: "::jsonb", bound: value };
|
|
539
|
+
}
|
|
540
|
+
// All-boolean arrays are inferred as boolean[] by postgres-js; route via text::jsonb.
|
|
541
|
+
if (value.length > 0 && value.every((entry) => typeof entry === "boolean")) {
|
|
542
|
+
return { kind: "param", sql: "::text::jsonb", bound: JSON.stringify(value) };
|
|
543
|
+
}
|
|
544
|
+
return { kind: "param", sql: "::jsonb", bound: value };
|
|
545
|
+
}
|
|
546
|
+
|
|
469
547
|
function prepareValue(value: unknown, pgType: string | undefined): PreparedValue {
|
|
470
548
|
if (isSqlExpression(value)) {
|
|
471
549
|
return { kind: "literal", literal: value.text };
|
|
472
550
|
}
|
|
473
551
|
if (pgType === "jsonb" && value !== null) {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
}
|
|
477
|
-
if (typeof value === "object" && !isTemporalInstant(value)) {
|
|
478
|
-
// Plain objects: bind directly — JSON.stringify + ::jsonb stores a JSON string scalar.
|
|
479
|
-
if (!Array.isArray(value)) {
|
|
480
|
-
return { kind: "param", sql: "::jsonb", bound: value };
|
|
481
|
-
}
|
|
482
|
-
// All-boolean arrays are inferred as boolean[] by postgres-js; route via text::jsonb.
|
|
483
|
-
if (value.length > 0 && value.every((entry) => typeof entry === "boolean")) {
|
|
484
|
-
return { kind: "param", sql: "::text::jsonb", bound: JSON.stringify(value) };
|
|
485
|
-
}
|
|
486
|
-
return { kind: "param", sql: "::jsonb", bound: value };
|
|
487
|
-
}
|
|
552
|
+
const prepared = prepareJsonbValue(value);
|
|
553
|
+
if (prepared) return prepared;
|
|
488
554
|
}
|
|
489
555
|
if ((pgType === "timestamptz" || pgType === "timestamptz(3)") && isTemporalInstant(value)) {
|
|
490
556
|
return { kind: "param", sql: "", bound: (value as Temporal.Instant).toString() };
|
|
491
557
|
}
|
|
558
|
+
if (pgType === "date") {
|
|
559
|
+
const prepared = prepareDateValue(value);
|
|
560
|
+
if (prepared) return prepared;
|
|
561
|
+
}
|
|
492
562
|
return { kind: "param", sql: "", bound: value };
|
|
493
563
|
}
|
|
494
564
|
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
jsonb,
|
|
8
8
|
boolean as pgBoolean,
|
|
9
9
|
table as pgTable,
|
|
10
|
+
plainDate,
|
|
10
11
|
serial,
|
|
11
12
|
text,
|
|
12
13
|
timestamp,
|
|
@@ -85,6 +86,14 @@ describe("renderTableDdl — column types", () => {
|
|
|
85
86
|
expect(ddl[0]).toContain('"created" timestamp with time zone DEFAULT now() NOT NULL');
|
|
86
87
|
});
|
|
87
88
|
|
|
89
|
+
test("date notNull — real PG date, not timestamptz (kumiko-framework#1924)", () => {
|
|
90
|
+
const t = pgTable("t_date", { publishedAt: plainDate("published_at").notNull() });
|
|
91
|
+
// biome-ignore lint/suspicious/noExplicitAny: DDL test uses cast for mock tables
|
|
92
|
+
const ddl = renderTableDdl(t as any);
|
|
93
|
+
expect(ddl[0]).toContain('"published_at" date NOT NULL');
|
|
94
|
+
expect(ddl[0]).not.toContain("timestamp");
|
|
95
|
+
});
|
|
96
|
+
|
|
88
97
|
test("bigint generatedAlwaysAsIdentity primaryKey", () => {
|
|
89
98
|
const t = pgTable("t_ident", { id: bigint("id").primaryKey().generatedAlwaysAsIdentity() });
|
|
90
99
|
// biome-ignore lint/suspicious/noExplicitAny: DDL test uses cast for mock tables
|
|
@@ -142,6 +142,29 @@ describe("renderMigrationSql — managed recreate vs unmanaged in-place", () =>
|
|
|
142
142
|
expect(sql).not.toContain("WARN: destructive change");
|
|
143
143
|
});
|
|
144
144
|
|
|
145
|
+
test("unmanaged: timestamptz → date type change emits an explicit UTC-anchored USING clause (kumiko-framework#1924)", () => {
|
|
146
|
+
// A bare `ALTER COLUMN ... TYPE date` (no USING) falls back to PG's
|
|
147
|
+
// implicit ::date cast, which reads the session TimeZone — the exact
|
|
148
|
+
// non-determinism this issue fixes. Managed tables never hit this path
|
|
149
|
+
// (they DROP+CREATE + replay from events instead); unmanaged/store_*
|
|
150
|
+
// tables carry real data through an in-place ALTER, so the generator
|
|
151
|
+
// must anchor at UTC explicitly rather than emit the generic naive cast.
|
|
152
|
+
const prev = snapshotFromMetas([
|
|
153
|
+
meta("store_invoices", { name: "period_from", pgType: "timestamptz", notNull: true }),
|
|
154
|
+
]);
|
|
155
|
+
const next = snapshotFromMetas([
|
|
156
|
+
meta("store_invoices", { name: "period_from", pgType: "date", notNull: true }),
|
|
157
|
+
]);
|
|
158
|
+
const sql = renderMigrationSql(diffSnapshots(prev, next), {
|
|
159
|
+
name: "invoice-date",
|
|
160
|
+
sequenceNumber: 7,
|
|
161
|
+
});
|
|
162
|
+
expect(sql).toContain(
|
|
163
|
+
'ALTER TABLE "store_invoices" ALTER COLUMN "period_from" TYPE date USING ("period_from" AT TIME ZONE \'UTC\')::date;',
|
|
164
|
+
);
|
|
165
|
+
expect(sql).not.toContain("WARN: column-type-change");
|
|
166
|
+
});
|
|
167
|
+
|
|
145
168
|
test("managed: multiple recreate reasons at once → all named in the warning", () => {
|
|
146
169
|
const prev = snapshotFromMetas([
|
|
147
170
|
meta("read_b", { name: "old_col", pgType: "text", notNull: false }, "managed"),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
2
|
import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
|
|
3
|
+
import { Temporal } from "temporal-polyfill";
|
|
3
4
|
import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
|
|
4
5
|
import { asRawClient, selectMany } from "../../db/query";
|
|
5
6
|
import {
|
|
@@ -106,7 +107,7 @@ describe("schema migration workflows", () => {
|
|
|
106
107
|
expect(columns.get("title")?.dataType).toBe("text");
|
|
107
108
|
expect(columns.get("body")?.dataType).toBe("text");
|
|
108
109
|
expect(columns.get("view_count")?.dataType).toBe("double precision");
|
|
109
|
-
expect(columns.get("published_at")?.dataType).
|
|
110
|
+
expect(columns.get("published_at")?.dataType).toBe("date");
|
|
110
111
|
expect(columns.get("is_draft")?.dataType).toBe("boolean");
|
|
111
112
|
expect(columns.get("is_draft")?.isNullable).toBe(false); // has default → NOT NULL
|
|
112
113
|
|
|
@@ -276,3 +277,62 @@ describe("schema migration workflows", () => {
|
|
|
276
277
|
expect(productColumns.has("price")).toBe(true);
|
|
277
278
|
});
|
|
278
279
|
});
|
|
280
|
+
|
|
281
|
+
// kumiko-framework#1924: `type:"date"` used to be a TIMESTAMPTZ alias — read
|
|
282
|
+
// gave back an ISO Instant, write took a bare "yyyy-mm-dd" that bound
|
|
283
|
+
// straight to a timestamptz column via the session TZ. Both sides are now a
|
|
284
|
+
// real PG `date` column round-tripping Temporal.PlainDate. This test proves
|
|
285
|
+
// the full path (write → column DDL → read) and — since it lives under
|
|
286
|
+
// `packages/framework/src/db/__tests__/`, the same glob the CI `tz-matrix`
|
|
287
|
+
// job (.github/workflows/ci.yml) runs under America/Los_Angeles, Europe/
|
|
288
|
+
// Berlin, Asia/Tokyo and Pacific/Apia — proves it independent of process TZ.
|
|
289
|
+
describe("date field — real DATE column, no TZ dependence (kumiko-framework#1924)", () => {
|
|
290
|
+
test("column is a genuine `date`, not timestamptz", async () => {
|
|
291
|
+
const entity = createEntity({
|
|
292
|
+
table: "df1_invoices",
|
|
293
|
+
fields: { periodFrom: createDateField({ required: true }) },
|
|
294
|
+
});
|
|
295
|
+
await unsafePushTables(testDb.db, { invoice: buildEntityTable("invoice", entity) });
|
|
296
|
+
|
|
297
|
+
const columns = await getTableColumns("df1_invoices");
|
|
298
|
+
expect(columns.get("period_from")?.dataType).toBe("date");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("a written calendar day round-trips exactly, as Temporal.PlainDate", async () => {
|
|
302
|
+
const entity = createEntity({
|
|
303
|
+
table: "df2_invoices",
|
|
304
|
+
fields: { periodFrom: createDateField({ required: true }) },
|
|
305
|
+
});
|
|
306
|
+
const table = buildEntityTable("invoice", entity);
|
|
307
|
+
await unsafePushTables(testDb.db, { invoice: table });
|
|
308
|
+
|
|
309
|
+
await seedRow(testDb.db, table, {
|
|
310
|
+
tenantId: "00000000-0000-4000-8000-000000000001",
|
|
311
|
+
periodFrom: "2026-03-15",
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const rows = await selectMany(testDb.db, table);
|
|
315
|
+
expect(rows).toHaveLength(1);
|
|
316
|
+
const periodFrom = (rows[0] as { periodFrom: unknown }).periodFrom;
|
|
317
|
+
expect(periodFrom).toBeInstanceOf(Temporal.PlainDate);
|
|
318
|
+
expect((periodFrom as Temporal.PlainDate).toString()).toBe("2026-03-15");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("a Temporal.PlainDate value on write also round-trips exactly", async () => {
|
|
322
|
+
const entity = createEntity({
|
|
323
|
+
table: "df3_invoices",
|
|
324
|
+
fields: { periodFrom: createDateField({ required: true }) },
|
|
325
|
+
});
|
|
326
|
+
const table = buildEntityTable("invoice", entity);
|
|
327
|
+
await unsafePushTables(testDb.db, { invoice: table });
|
|
328
|
+
|
|
329
|
+
await seedRow(testDb.db, table, {
|
|
330
|
+
tenantId: "00000000-0000-4000-8000-000000000001",
|
|
331
|
+
periodFrom: Temporal.PlainDate.from("2026-12-31"),
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const rows = await selectMany(testDb.db, table);
|
|
335
|
+
const periodFrom = (rows[0] as { periodFrom: unknown }).periodFrom;
|
|
336
|
+
expect((periodFrom as Temporal.PlainDate).toString()).toBe("2026-12-31");
|
|
337
|
+
});
|
|
338
|
+
});
|
package/src/db/dialect.ts
CHANGED
|
@@ -74,6 +74,8 @@ export function pgTypeToSqlType(pgType: PgType): string {
|
|
|
74
74
|
return "timestamp with time zone";
|
|
75
75
|
case "timestamptz(3)":
|
|
76
76
|
return "timestamp(3) with time zone";
|
|
77
|
+
case "date":
|
|
78
|
+
return "date";
|
|
77
79
|
}
|
|
78
80
|
}
|
|
79
81
|
|
|
@@ -273,6 +275,14 @@ export function instant(
|
|
|
273
275
|
return buildColumn(name, pgType) as ColumnBuilder<Temporal.Instant>;
|
|
274
276
|
}
|
|
275
277
|
|
|
278
|
+
// PG `date` — no time-of-day, no timezone. Calendar-day fields (invoice
|
|
279
|
+
// date, lease term) round-trip as Temporal.PlainDate, never through an
|
|
280
|
+
// Instant — that detour is exactly what made `type:"date"` TZ-dependent on
|
|
281
|
+
// both read and write (kumiko-framework#1924).
|
|
282
|
+
export function plainDate(name: string): ColumnBuilder<Temporal.PlainDate> {
|
|
283
|
+
return buildColumn(name, "date") as ColumnBuilder<Temporal.PlainDate>;
|
|
284
|
+
}
|
|
285
|
+
|
|
276
286
|
// Real numeric(precision, scale) column — exact decimal storage (interest
|
|
277
287
|
// rates, percentages, ratios). pg returns numeric as a STRING; coerceRow
|
|
278
288
|
// parses it back to JS number on read (safe ≤ 2^53). Precision/scale live in
|
|
@@ -200,6 +200,9 @@ function fieldToColumnMeta(
|
|
|
200
200
|
case "jsonb":
|
|
201
201
|
return [{ name: snake, pgType: "jsonb", notNull: true, defaultSql: "'{}'::jsonb" }];
|
|
202
202
|
case "date":
|
|
203
|
+
// Real PG `date` — Temporal.PlainDate, no time-of-day/timezone
|
|
204
|
+
// component. See plainDate() in dialect.ts (kumiko-framework#1924).
|
|
205
|
+
return [{ name: snake, pgType: "date", notNull: field.required === true }];
|
|
203
206
|
case "timestamp":
|
|
204
207
|
return [{ name: snake, pgType: "timestamptz", notNull: field.required === true }];
|
|
205
208
|
case "tz":
|
|
@@ -271,13 +271,26 @@ function renderColumnChange(tableName: string, change: ColumnChange): readonly s
|
|
|
271
271
|
}
|
|
272
272
|
}
|
|
273
273
|
if (change.typeChanged) {
|
|
274
|
-
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
274
|
+
const { from, to } = change.typeChanged;
|
|
275
|
+
// timestamptz → date: PG's implicit ::date cast (what a bare ALTER ...
|
|
276
|
+
// TYPE without USING falls back to) reads the session TimeZone, so the
|
|
277
|
+
// same row migrates to a different calendar day depending on who/where
|
|
278
|
+
// runs it — exactly the non-determinism kumiko-framework#1924 fixes.
|
|
279
|
+
// Anchor explicitly at UTC instead of trusting the session.
|
|
280
|
+
if ((from === "timestamptz" || from === "timestamptz(3)") && to === "date") {
|
|
281
|
+
out.push(
|
|
282
|
+
`-- ${from} → date: explicit UTC anchor — a bare cast would use the session TimeZone (non-deterministic).`,
|
|
283
|
+
);
|
|
284
|
+
out.push(
|
|
285
|
+
`ALTER TABLE ${tbl} ALTER COLUMN ${col} TYPE date USING (${col} AT TIME ZONE 'UTC')::date;`,
|
|
286
|
+
);
|
|
287
|
+
} 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.
|
|
291
|
+
out.push(`-- WARN: column-type-change ${from} → ${to}. Review USING-clause if needed.`);
|
|
292
|
+
out.push(`ALTER TABLE ${tbl} ALTER COLUMN ${col} TYPE ${to};`);
|
|
293
|
+
}
|
|
281
294
|
}
|
|
282
295
|
return out;
|
|
283
296
|
}
|
package/src/db/table-builder.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
jsonb,
|
|
25
25
|
moneyAmount,
|
|
26
26
|
table as pgTable,
|
|
27
|
+
plainDate,
|
|
27
28
|
type SqlExpression,
|
|
28
29
|
serial,
|
|
29
30
|
sql,
|
|
@@ -166,9 +167,10 @@ function fieldToColumns(
|
|
|
166
167
|
// `customFields`-Spalte (tenant-definierte dynamische keys).
|
|
167
168
|
return { [name]: jsonb(snakeName).default({}).notNull() };
|
|
168
169
|
case "date": {
|
|
169
|
-
// `
|
|
170
|
-
//
|
|
171
|
-
|
|
170
|
+
// Real PG `date` — no time-of-day, no timezone. Temporal.PlainDate
|
|
171
|
+
// end-to-end (see plainDate() in dialect.ts). Was aliased on instant()
|
|
172
|
+
// = TIMESTAMPTZ until kumiko-framework#1924 (TZ-dependent round-trip).
|
|
173
|
+
const col = plainDate(snakeName);
|
|
172
174
|
return { [name]: field.required ? col.notNull() : col };
|
|
173
175
|
}
|
|
174
176
|
case "timestamp": {
|
|
@@ -337,25 +339,29 @@ type ColumnsForField<K extends string, F extends FieldDefinition> = F extends {
|
|
|
337
339
|
: F extends { type: "embedded" }
|
|
338
340
|
? // jsonb default `{}`, immer notNull
|
|
339
341
|
{ readonly [P in K]: Col<Readonly<Record<string, unknown>>> }
|
|
340
|
-
: F extends { type: "date"
|
|
342
|
+
: F extends { type: "date" }
|
|
341
343
|
? F extends { required: true }
|
|
342
|
-
? { readonly [P in K]: Col<Temporal.
|
|
343
|
-
: { readonly [P in K]: NullCol<Temporal.
|
|
344
|
-
: F extends { type: "
|
|
344
|
+
? { readonly [P in K]: Col<Temporal.PlainDate> }
|
|
345
|
+
: { readonly [P in K]: NullCol<Temporal.PlainDate> }
|
|
346
|
+
: F extends { type: "timestamp" }
|
|
345
347
|
? F extends { required: true }
|
|
346
|
-
? { readonly [P in
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
: { readonly [P in `${K}Utc`]: NullCol<Temporal.Instant> } & {
|
|
350
|
-
readonly [P in `${K}Tz`]: NullCol<string>;
|
|
351
|
-
}
|
|
352
|
-
: F extends { type: "file" | "image" }
|
|
348
|
+
? { readonly [P in K]: Col<Temporal.Instant> }
|
|
349
|
+
: { readonly [P in K]: NullCol<Temporal.Instant> }
|
|
350
|
+
: F extends { type: "locatedTimestamp" }
|
|
353
351
|
? F extends { required: true }
|
|
354
|
-
? { readonly [P in K]: Col<
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
352
|
+
? { readonly [P in `${K}Utc`]: Col<Temporal.Instant> } & {
|
|
353
|
+
readonly [P in `${K}Tz`]: Col<string>;
|
|
354
|
+
}
|
|
355
|
+
: { readonly [P in `${K}Utc`]: NullCol<Temporal.Instant> } & {
|
|
356
|
+
readonly [P in `${K}Tz`]: NullCol<string>;
|
|
357
|
+
}
|
|
358
|
+
: F extends { type: "file" | "image" }
|
|
359
|
+
? F extends { required: true }
|
|
360
|
+
? { readonly [P in K]: Col<string> }
|
|
361
|
+
: { readonly [P in K]: NullCol<string> }
|
|
362
|
+
: F extends { type: "files" | "images" }
|
|
363
|
+
? Record<never, never>
|
|
364
|
+
: never;
|
|
359
365
|
|
|
360
366
|
type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends (
|
|
361
367
|
k: infer I,
|
|
@@ -7,8 +7,11 @@ import { validateBoot as validateBootRaw } from "../boot-validator";
|
|
|
7
7
|
import { createSystemConfig, createTenantConfig } from "../config-helpers";
|
|
8
8
|
import {
|
|
9
9
|
createDerivedField,
|
|
10
|
+
createEmbeddedField,
|
|
10
11
|
createEmbeddedListField,
|
|
11
12
|
createEntity,
|
|
13
|
+
createFilesField,
|
|
14
|
+
createJsonbField,
|
|
12
15
|
createMultiSelectField,
|
|
13
16
|
createTextField,
|
|
14
17
|
defineFeature,
|
|
@@ -1864,6 +1867,39 @@ describe("boot-validator", () => {
|
|
|
1864
1867
|
expect(() => validateBoot([makeFeature({ cancelTarget: false })])).not.toThrow();
|
|
1865
1868
|
});
|
|
1866
1869
|
|
|
1870
|
+
// --- redirect/cancelTarget: cross-feature QN (#1946) ---
|
|
1871
|
+
test("redirect → voll-qualifizierte Cross-Feature-QN → kein Throw", () => {
|
|
1872
|
+
const consumer = defineFeature("statements", (r) => {
|
|
1873
|
+
r.screen({ id: "statement-upload-list", type: "custom", renderer: { react: "stub" } });
|
|
1874
|
+
});
|
|
1875
|
+
expect(() =>
|
|
1876
|
+
validateBoot([
|
|
1877
|
+
makeFeature({ redirect: "statements:screen:statement-upload-list" }),
|
|
1878
|
+
consumer,
|
|
1879
|
+
]),
|
|
1880
|
+
).not.toThrow();
|
|
1881
|
+
});
|
|
1882
|
+
|
|
1883
|
+
test("redirect → unbekannte Cross-Feature-QN → Throw", () => {
|
|
1884
|
+
expect(() =>
|
|
1885
|
+
validateBoot([makeFeature({ redirect: "statements:screen:ghost-screen" })]),
|
|
1886
|
+
).toThrow(
|
|
1887
|
+
/redirect "statements:screen:ghost-screen" does not resolve to a registered screen/,
|
|
1888
|
+
);
|
|
1889
|
+
});
|
|
1890
|
+
|
|
1891
|
+
test("cancelTarget → voll-qualifizierte Cross-Feature-QN → kein Throw", () => {
|
|
1892
|
+
const consumer = defineFeature("statements", (r) => {
|
|
1893
|
+
r.screen({ id: "statement-upload-list", type: "custom", renderer: { react: "stub" } });
|
|
1894
|
+
});
|
|
1895
|
+
expect(() =>
|
|
1896
|
+
validateBoot([
|
|
1897
|
+
makeFeature({ cancelTarget: "statements:screen:statement-upload-list" }),
|
|
1898
|
+
consumer,
|
|
1899
|
+
]),
|
|
1900
|
+
).not.toThrow();
|
|
1901
|
+
});
|
|
1902
|
+
|
|
1867
1903
|
test("extension section ohne component → Throw (Parität zu entityEdit)", () => {
|
|
1868
1904
|
// synthesizeActionFormScreen reicht die layout 1:1 an RenderEdit weiter —
|
|
1869
1905
|
// eine Extension-Section ohne react/native-Marker rendert sonst stumm leer.
|
|
@@ -2173,6 +2209,247 @@ describe("boot-validator", () => {
|
|
|
2173
2209
|
});
|
|
2174
2210
|
});
|
|
2175
2211
|
|
|
2212
|
+
// --- entityEdit redirect (framework#1942) ---
|
|
2213
|
+
// Post-save navigation target, same validation as actionForm's redirect:
|
|
2214
|
+
// short screen-ID, same feature, must resolve to a registered screen.
|
|
2215
|
+
describe("entityEdit redirect", () => {
|
|
2216
|
+
function makeFeature(redirect?: string, extraScreens: readonly string[] = []) {
|
|
2217
|
+
return defineFeature("shop", (r) => {
|
|
2218
|
+
r.entity(
|
|
2219
|
+
"product",
|
|
2220
|
+
createEntity({ fields: { name: createTextField(), sku: createTextField() } }),
|
|
2221
|
+
);
|
|
2222
|
+
r.screen({
|
|
2223
|
+
id: "product-edit",
|
|
2224
|
+
type: "entityEdit",
|
|
2225
|
+
entity: "product",
|
|
2226
|
+
layout: { sections: [{ fields: ["name", "sku"] }] },
|
|
2227
|
+
...(redirect !== undefined && { redirect }),
|
|
2228
|
+
});
|
|
2229
|
+
for (const extra of extraScreens) {
|
|
2230
|
+
r.screen({
|
|
2231
|
+
id: extra,
|
|
2232
|
+
type: "custom",
|
|
2233
|
+
renderer: { react: "stub" },
|
|
2234
|
+
});
|
|
2235
|
+
}
|
|
2236
|
+
});
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
test("redirect → existing screen-id im selben feature → kein Throw", () => {
|
|
2240
|
+
expect(() => validateBoot([makeFeature("product-list", ["product-list"])])).not.toThrow();
|
|
2241
|
+
});
|
|
2242
|
+
|
|
2243
|
+
test("redirect → unknown screen-id → Throw", () => {
|
|
2244
|
+
expect(() => validateBoot([makeFeature("ghost-screen")])).toThrow(
|
|
2245
|
+
/redirect "ghost-screen" does not resolve to a registered screen/,
|
|
2246
|
+
);
|
|
2247
|
+
});
|
|
2248
|
+
|
|
2249
|
+
test("kein redirect gesetzt → kein Throw (Default: Liste)", () => {
|
|
2250
|
+
expect(() => validateBoot([makeFeature()])).not.toThrow();
|
|
2251
|
+
});
|
|
2252
|
+
|
|
2253
|
+
// --- redirect: cross-feature QN (#1946) ---
|
|
2254
|
+
test("redirect → voll-qualifizierte Cross-Feature-QN → kein Throw", () => {
|
|
2255
|
+
const consumer = defineFeature("statements", (r) => {
|
|
2256
|
+
r.screen({ id: "statement-upload-list", type: "custom", renderer: { react: "stub" } });
|
|
2257
|
+
});
|
|
2258
|
+
expect(() =>
|
|
2259
|
+
validateBoot([makeFeature("statements:screen:statement-upload-list"), consumer]),
|
|
2260
|
+
).not.toThrow();
|
|
2261
|
+
});
|
|
2262
|
+
|
|
2263
|
+
test("redirect → unbekannte Cross-Feature-QN → Throw", () => {
|
|
2264
|
+
expect(() => validateBoot([makeFeature("statements:screen:ghost-screen")])).toThrow(
|
|
2265
|
+
/redirect "statements:screen:ghost-screen" does not resolve to a registered screen/,
|
|
2266
|
+
);
|
|
2267
|
+
});
|
|
2268
|
+
});
|
|
2269
|
+
|
|
2270
|
+
// --- no-widget field types + required (#1925) ---
|
|
2271
|
+
// jsonb/embedded/files/images render read-only on the auto-wired
|
|
2272
|
+
// entityEdit path (render-field.tsx) — a statically-`required: true`
|
|
2273
|
+
// field of one of these types would be unresolvable by the user, so it's
|
|
2274
|
+
// caught loudly at boot instead of silently accepting an unfillable form.
|
|
2275
|
+
// Only the static case (literal `true`, entity-level or screen-spec) is
|
|
2276
|
+
// checked; a dynamic FieldCondition can't be evaluated without runtime
|
|
2277
|
+
// form values and is a documented, accepted gap.
|
|
2278
|
+
describe("no-widget field types + required (#1925)", () => {
|
|
2279
|
+
test("embedded field, entity-level required: true → Throw", () => {
|
|
2280
|
+
const features = [
|
|
2281
|
+
defineFeature("shop", (r) => {
|
|
2282
|
+
r.entity(
|
|
2283
|
+
"order",
|
|
2284
|
+
createEntity({
|
|
2285
|
+
fields: {
|
|
2286
|
+
lines: createEmbeddedField({ note: { type: "text" } }, { required: true }),
|
|
2287
|
+
},
|
|
2288
|
+
}),
|
|
2289
|
+
);
|
|
2290
|
+
r.screen({
|
|
2291
|
+
id: "order-edit",
|
|
2292
|
+
type: "entityEdit",
|
|
2293
|
+
entity: "order",
|
|
2294
|
+
layout: { sections: [{ fields: ["lines"] }] },
|
|
2295
|
+
});
|
|
2296
|
+
}),
|
|
2297
|
+
];
|
|
2298
|
+
expect(() => validateBoot(features)).toThrow(
|
|
2299
|
+
/field "lines" is type "embedded", which renders read-only/,
|
|
2300
|
+
);
|
|
2301
|
+
});
|
|
2302
|
+
|
|
2303
|
+
test("embedded field, entity-level required: true, screen-spec required: false override → kein Throw", () => {
|
|
2304
|
+
const features = [
|
|
2305
|
+
defineFeature("shop", (r) => {
|
|
2306
|
+
r.entity(
|
|
2307
|
+
"order",
|
|
2308
|
+
createEntity({
|
|
2309
|
+
fields: {
|
|
2310
|
+
lines: createEmbeddedField({ note: { type: "text" } }, { required: true }),
|
|
2311
|
+
},
|
|
2312
|
+
}),
|
|
2313
|
+
);
|
|
2314
|
+
r.screen({
|
|
2315
|
+
id: "order-edit",
|
|
2316
|
+
type: "entityEdit",
|
|
2317
|
+
entity: "order",
|
|
2318
|
+
layout: { sections: [{ fields: [{ field: "lines", required: false }] }] },
|
|
2319
|
+
});
|
|
2320
|
+
}),
|
|
2321
|
+
];
|
|
2322
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
2323
|
+
});
|
|
2324
|
+
|
|
2325
|
+
test("embedded field, entity-level required: true, screen-spec readOnly: true → kein Throw", () => {
|
|
2326
|
+
const features = [
|
|
2327
|
+
defineFeature("shop", (r) => {
|
|
2328
|
+
r.entity(
|
|
2329
|
+
"order",
|
|
2330
|
+
createEntity({
|
|
2331
|
+
fields: {
|
|
2332
|
+
lines: createEmbeddedField({ note: { type: "text" } }, { required: true }),
|
|
2333
|
+
},
|
|
2334
|
+
}),
|
|
2335
|
+
);
|
|
2336
|
+
r.screen({
|
|
2337
|
+
id: "order-edit",
|
|
2338
|
+
type: "entityEdit",
|
|
2339
|
+
entity: "order",
|
|
2340
|
+
layout: { sections: [{ fields: [{ field: "lines", readOnly: true }] }] },
|
|
2341
|
+
});
|
|
2342
|
+
}),
|
|
2343
|
+
];
|
|
2344
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
2345
|
+
});
|
|
2346
|
+
|
|
2347
|
+
test("embedded field, entity-level required: true, dynamic screen-spec required condition → kein Throw (not statically resolvable)", () => {
|
|
2348
|
+
const features = [
|
|
2349
|
+
defineFeature("shop", (r) => {
|
|
2350
|
+
r.entity(
|
|
2351
|
+
"order",
|
|
2352
|
+
createEntity({
|
|
2353
|
+
fields: {
|
|
2354
|
+
kind: createTextField(),
|
|
2355
|
+
lines: createEmbeddedField({ note: { type: "text" } }, { required: true }),
|
|
2356
|
+
},
|
|
2357
|
+
}),
|
|
2358
|
+
);
|
|
2359
|
+
r.screen({
|
|
2360
|
+
id: "order-edit",
|
|
2361
|
+
type: "entityEdit",
|
|
2362
|
+
entity: "order",
|
|
2363
|
+
layout: {
|
|
2364
|
+
sections: [
|
|
2365
|
+
{ fields: ["kind", { field: "lines", required: { field: "kind", eq: "b2b" } }] },
|
|
2366
|
+
],
|
|
2367
|
+
},
|
|
2368
|
+
});
|
|
2369
|
+
}),
|
|
2370
|
+
];
|
|
2371
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
2372
|
+
});
|
|
2373
|
+
|
|
2374
|
+
test("jsonb field, screen-spec required: true override → Throw (JsonbFieldDef has no entity-level required)", () => {
|
|
2375
|
+
const features = [
|
|
2376
|
+
defineFeature("shop", (r) => {
|
|
2377
|
+
r.entity("order", createEntity({ fields: { data: createJsonbField() } }));
|
|
2378
|
+
r.screen({
|
|
2379
|
+
id: "order-edit",
|
|
2380
|
+
type: "entityEdit",
|
|
2381
|
+
entity: "order",
|
|
2382
|
+
layout: { sections: [{ fields: [{ field: "data", required: true }] }] },
|
|
2383
|
+
});
|
|
2384
|
+
}),
|
|
2385
|
+
];
|
|
2386
|
+
expect(() => validateBoot(features)).toThrow(
|
|
2387
|
+
/field "data" is type "jsonb", which renders read-only/,
|
|
2388
|
+
);
|
|
2389
|
+
});
|
|
2390
|
+
|
|
2391
|
+
test("files field, screen-spec required: true override → Throw (deliberately deferred, no multi-upload widget)", () => {
|
|
2392
|
+
const features = [
|
|
2393
|
+
defineFeature("shop", (r) => {
|
|
2394
|
+
r.entity("order", createEntity({ fields: { attachments: createFilesField() } }));
|
|
2395
|
+
r.screen({
|
|
2396
|
+
id: "order-edit",
|
|
2397
|
+
type: "entityEdit",
|
|
2398
|
+
entity: "order",
|
|
2399
|
+
layout: { sections: [{ fields: [{ field: "attachments", required: true }] }] },
|
|
2400
|
+
});
|
|
2401
|
+
}),
|
|
2402
|
+
];
|
|
2403
|
+
expect(() => validateBoot(features)).toThrow(
|
|
2404
|
+
/field "attachments" is type "files", which renders read-only/,
|
|
2405
|
+
);
|
|
2406
|
+
});
|
|
2407
|
+
|
|
2408
|
+
test("multiSelect field, entity-level required: true → kein Throw (has a combobox widget since #1925)", () => {
|
|
2409
|
+
const features = [
|
|
2410
|
+
defineFeature("shop", (r) => {
|
|
2411
|
+
r.entity(
|
|
2412
|
+
"product",
|
|
2413
|
+
createEntity({
|
|
2414
|
+
fields: {
|
|
2415
|
+
tags: createMultiSelectField({ options: ["a", "b"] as const, required: true }),
|
|
2416
|
+
},
|
|
2417
|
+
}),
|
|
2418
|
+
);
|
|
2419
|
+
r.screen({
|
|
2420
|
+
id: "product-edit",
|
|
2421
|
+
type: "entityEdit",
|
|
2422
|
+
entity: "product",
|
|
2423
|
+
layout: { sections: [{ fields: ["tags"] }] },
|
|
2424
|
+
});
|
|
2425
|
+
}),
|
|
2426
|
+
];
|
|
2427
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
2428
|
+
});
|
|
2429
|
+
|
|
2430
|
+
test("embedded LIST field (multiple: true), entity-level required: true → kein Throw (has an EmbeddedListField grid widget since #1838)", () => {
|
|
2431
|
+
const features = [
|
|
2432
|
+
defineFeature("shop", (r) => {
|
|
2433
|
+
r.entity(
|
|
2434
|
+
"invoice",
|
|
2435
|
+
createEntity({
|
|
2436
|
+
fields: {
|
|
2437
|
+
lines: createEmbeddedListField({ note: { type: "text" } }, { required: true }),
|
|
2438
|
+
},
|
|
2439
|
+
}),
|
|
2440
|
+
);
|
|
2441
|
+
r.screen({
|
|
2442
|
+
id: "invoice-edit",
|
|
2443
|
+
type: "entityEdit",
|
|
2444
|
+
entity: "invoice",
|
|
2445
|
+
layout: { sections: [{ fields: ["lines"] }] },
|
|
2446
|
+
});
|
|
2447
|
+
}),
|
|
2448
|
+
];
|
|
2449
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
2450
|
+
});
|
|
2451
|
+
});
|
|
2452
|
+
|
|
2176
2453
|
// --- Tier 2.7e-3: ReferenceFieldDef ---
|
|
2177
2454
|
describe("reference field (Tier 2.7e-3)", () => {
|
|
2178
2455
|
test("reference auf bestehende Entity → kein Throw", () => {
|
|
@@ -5,16 +5,17 @@
|
|
|
5
5
|
// same-folder require cycle.
|
|
6
6
|
|
|
7
7
|
import { rowMetaFieldNames } from "../../db/table-builder";
|
|
8
|
-
import { qualifyEntityName } from "../qualified-name";
|
|
8
|
+
import { isValidQn, qualifyEntityName } from "../qualified-name";
|
|
9
9
|
import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
|
|
10
10
|
import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../screen-helpers";
|
|
11
|
-
import type { FeatureDefinition } from "../types";
|
|
11
|
+
import type { EntityDefinition, FeatureDefinition } from "../types";
|
|
12
12
|
import type {
|
|
13
13
|
DashboardCustomPanel,
|
|
14
14
|
DashboardFilterDefinition,
|
|
15
15
|
DashboardPanelDefinition,
|
|
16
16
|
DashboardScreenDefinition,
|
|
17
17
|
DashboardStatGroupPanel,
|
|
18
|
+
EditFieldSpec,
|
|
18
19
|
EditLayout,
|
|
19
20
|
FieldCondition,
|
|
20
21
|
RowAction,
|
|
@@ -23,6 +24,48 @@ import type {
|
|
|
23
24
|
ToolbarAction,
|
|
24
25
|
} from "../types/screen";
|
|
25
26
|
|
|
27
|
+
// Mirrors FIELD_TYPES_WITHOUT_WIDGET in packages/renderer/src/app/form-schema.ts.
|
|
28
|
+
// Can't import it directly — renderer depends on framework, not the reverse.
|
|
29
|
+
// Keep both lists in sync when a field type gains or loses an auto-wired widget.
|
|
30
|
+
const NO_WIDGET_FIELD_TYPES = new Set(["jsonb", "embedded", "files", "images"]);
|
|
31
|
+
|
|
32
|
+
// A field type in NO_WIDGET_FIELD_TYPES renders read-only on the auto-wired
|
|
33
|
+
// entityEdit path (#1925) — a required field the user can never fill would
|
|
34
|
+
// block every save. Only the statically-resolvable case is caught here: a
|
|
35
|
+
// literal `required: true` (screen-spec override or entity-level default).
|
|
36
|
+
// A dynamic FieldCondition depends on runtime form values and can't be
|
|
37
|
+
// evaluated at boot; buildFormSchema() silently skips presence-checking it.
|
|
38
|
+
function validateNoWidgetRequiredField(
|
|
39
|
+
featureName: string,
|
|
40
|
+
screenId: string,
|
|
41
|
+
entityDef: EntityDefinition,
|
|
42
|
+
fieldSpec: Exclude<EditFieldSpec, string>,
|
|
43
|
+
): void {
|
|
44
|
+
const fieldDef = entityDef.fields[fieldSpec.field];
|
|
45
|
+
// skip: field doesn't exist or its type already has a widget — nothing to validate.
|
|
46
|
+
if (fieldDef === undefined || !NO_WIDGET_FIELD_TYPES.has(fieldDef.type)) return;
|
|
47
|
+
// Embedded LIST fields (`multiple: true`) get their own EmbeddedListField
|
|
48
|
+
// grid widget (#1838) — only plain (non-list) embedded has no widget.
|
|
49
|
+
const isEmbeddedList =
|
|
50
|
+
fieldDef.type === "embedded" &&
|
|
51
|
+
(fieldDef as unknown as { multiple?: boolean }).multiple === true;
|
|
52
|
+
// skip: list variant has a widget — not the no-widget case this guard targets.
|
|
53
|
+
if (isEmbeddedList) return;
|
|
54
|
+
// skip: already read-only by spec — no fillable widget needed regardless of type.
|
|
55
|
+
if (fieldSpec.readOnly === true) return;
|
|
56
|
+
const entityRequired = "required" in fieldDef && fieldDef.required === true;
|
|
57
|
+
const isStaticallyRequired =
|
|
58
|
+
fieldSpec.required === undefined ? entityRequired : fieldSpec.required === true;
|
|
59
|
+
// skip: not required — a read-only widget-less field is fine to leave empty.
|
|
60
|
+
if (!isStaticallyRequired) return;
|
|
61
|
+
throw new Error(
|
|
62
|
+
`[Feature ${featureName}] Screen "${screenId}" (entityEdit) field "${fieldSpec.field}" is ` +
|
|
63
|
+
`type "${fieldDef.type}", which renders read-only on the auto-wired entityEdit path — a ` +
|
|
64
|
+
`required field the user could never fill would block every save. Set required: false, ` +
|
|
65
|
+
`move the field to a custom-component section, or drop the required constraint.`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
26
69
|
// Tier 2.7e navigate rowAction → target-screen params validity. Shared by
|
|
27
70
|
// entityList and projectionList (framework#1708) — projectionList has no
|
|
28
71
|
// `screen.entity`, so there's no same-entity row["id"] auto-fill case: any
|
|
@@ -224,6 +267,15 @@ export function validateScreenShortIdCollisions(
|
|
|
224
267
|
}
|
|
225
268
|
}
|
|
226
269
|
|
|
270
|
+
// redirect/cancelTarget accept either a same-feature short id (unchanged
|
|
271
|
+
// behavior, qualified against the owning feature) or a fully-qualified
|
|
272
|
+
// cross-feature screen QN (`<feature>:screen:<id>`) given verbatim — a
|
|
273
|
+
// short id can never itself be a valid QN (QN_SEGMENT forbids colons), so
|
|
274
|
+
// the two forms don't collide.
|
|
275
|
+
function resolveScreenTargetQn(featureName: string, target: string): string {
|
|
276
|
+
return isValidQn(target) ? target : qualifyEntityName(featureName, "screen", target);
|
|
277
|
+
}
|
|
278
|
+
|
|
227
279
|
export function validateScreens(
|
|
228
280
|
feature: FeatureDefinition,
|
|
229
281
|
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
@@ -239,8 +291,9 @@ export function validateScreens(
|
|
|
239
291
|
// der Runtime-Router (create-app) löst eine bare screenId app-weit über ALLE
|
|
240
292
|
// Features auf (eine deklarative Liste im owning-Feature der Entity navigiert
|
|
241
293
|
// so zu den Custom-Editoren der Consumer-App). Der Validator spiegelt das:
|
|
242
|
-
// same-feature ODER irgendein Feature.
|
|
243
|
-
//
|
|
294
|
+
// same-feature ODER irgendein Feature. redirect/cancelTarget akzeptieren
|
|
295
|
+
// zusätzlich eine voll-qualifizierte Cross-Feature-QN (resolveScreenTargetQn,
|
|
296
|
+
// #1946) — kurze IDs bleiben same-feature wie zuvor.
|
|
244
297
|
const navTargetShortIds = screenShortIdsFrom(allScreenQns);
|
|
245
298
|
for (const [screenId, screen] of Object.entries(feature.screens)) {
|
|
246
299
|
if (screen.type === "custom") {
|
|
@@ -496,31 +549,29 @@ export function validateScreens(
|
|
|
496
549
|
}
|
|
497
550
|
validateWizardLayout(feature.name, screenId, "actionForm", screen.layout, featureMap);
|
|
498
551
|
if (screen.redirect !== undefined) {
|
|
499
|
-
// redirect ist die kurze Screen-ID (z.B.
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
const candidateQn =
|
|
552
|
+
// redirect ist entweder die kurze Screen-ID (same-feature, z.B.
|
|
553
|
+
// "item-list") oder eine voll-qualifizierte Cross-Feature-QN
|
|
554
|
+
// (`<feature>:screen:<id>`) — der Renderer strippt letztere beim
|
|
555
|
+
// Navigieren auf die kurze ID (lastSegment), die der nav-Router
|
|
556
|
+
// app-weit auflöst (#1946).
|
|
557
|
+
const candidateQn = resolveScreenTargetQn(feature.name, screen.redirect);
|
|
505
558
|
if (!allScreenQns.has(candidateQn)) {
|
|
506
559
|
throw new Error(
|
|
507
560
|
`[Feature ${feature.name}] Screen "${screenId}" (actionForm) redirect "${screen.redirect}" ` +
|
|
508
|
-
`does not resolve to a registered screen
|
|
509
|
-
|
|
510
|
-
}.`,
|
|
561
|
+
`does not resolve to a registered screen (checked "${candidateQn}"). Known screens ` +
|
|
562
|
+
`in this feature: ${[...Object.keys(feature.screens)].sort().join(", ") || "(none)"}.`,
|
|
511
563
|
);
|
|
512
564
|
}
|
|
513
565
|
}
|
|
514
566
|
if (typeof screen.cancelTarget === "string") {
|
|
515
567
|
// Gleiche Regel wie redirect — `false` (kein Cancel-Button)
|
|
516
568
|
// braucht keine Validierung.
|
|
517
|
-
const candidateQn =
|
|
569
|
+
const candidateQn = resolveScreenTargetQn(feature.name, screen.cancelTarget);
|
|
518
570
|
if (!allScreenQns.has(candidateQn)) {
|
|
519
571
|
throw new Error(
|
|
520
572
|
`[Feature ${feature.name}] Screen "${screenId}" (actionForm) cancelTarget "${screen.cancelTarget}" ` +
|
|
521
|
-
`does not resolve to a registered screen
|
|
522
|
-
|
|
523
|
-
}.`,
|
|
573
|
+
`does not resolve to a registered screen (checked "${candidateQn}"). Known screens ` +
|
|
574
|
+
`in this feature: ${[...Object.keys(feature.screens)].sort().join(", ") || "(none)"}.`,
|
|
524
575
|
);
|
|
525
576
|
}
|
|
526
577
|
}
|
|
@@ -824,9 +875,22 @@ export function validateScreens(
|
|
|
824
875
|
),
|
|
825
876
|
);
|
|
826
877
|
}
|
|
878
|
+
validateNoWidgetRequiredField(feature.name, screenId, entityDef, normalized);
|
|
827
879
|
}
|
|
828
880
|
}
|
|
829
881
|
validateWizardLayout(feature.name, screenId, "entityEdit", screen.layout, featureMap);
|
|
882
|
+
if (screen.redirect !== undefined) {
|
|
883
|
+
// Same rule as actionForm's redirect: short screen-ID (same-feature)
|
|
884
|
+
// or a fully-qualified cross-feature QN (#1946).
|
|
885
|
+
const candidateQn = resolveScreenTargetQn(feature.name, screen.redirect);
|
|
886
|
+
if (!allScreenQns.has(candidateQn)) {
|
|
887
|
+
throw new Error(
|
|
888
|
+
`[Feature ${feature.name}] Screen "${screenId}" (entityEdit) redirect "${screen.redirect}" ` +
|
|
889
|
+
`does not resolve to a registered screen (checked "${candidateQn}"). Known screens ` +
|
|
890
|
+
`in this feature: ${[...Object.keys(feature.screens)].sort().join(", ") || "(none)"}.`,
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
830
894
|
}
|
|
831
895
|
}
|
|
832
896
|
}
|
|
@@ -106,6 +106,45 @@ describe("ValidationError", () => {
|
|
|
106
106
|
expect(err.cause).toBe(result.error);
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
+
test("custom issue with params.i18nKey overrides the mechanical errors.validation.custom key", () => {
|
|
110
|
+
const schema = z.object({ name: z.string() }).superRefine((values, ctx) => {
|
|
111
|
+
if (values.name === "") {
|
|
112
|
+
ctx.addIssue({
|
|
113
|
+
code: "custom",
|
|
114
|
+
path: ["name"],
|
|
115
|
+
message: '"name" is required.',
|
|
116
|
+
params: { i18nKey: "kumiko.validation.required" },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
const result = schema.safeParse({ name: "" });
|
|
121
|
+
if (result.success) throw new Error("zod did not reject");
|
|
122
|
+
|
|
123
|
+
const err = validationErrorFromZod(result.error);
|
|
124
|
+
const fields = (err.details as { fields: Array<Record<string, unknown>> }).fields;
|
|
125
|
+
expect(fields[0]).toMatchObject({
|
|
126
|
+
code: "custom",
|
|
127
|
+
i18nKey: "kumiko.validation.required",
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("custom issue without params.i18nKey still falls back to errors.validation.custom", () => {
|
|
132
|
+
const schema = z.object({ name: z.string() }).superRefine((values, ctx) => {
|
|
133
|
+
if (values.name === "bad") {
|
|
134
|
+
ctx.addIssue({ code: "custom", path: ["name"], message: "not allowed" });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
const result = schema.safeParse({ name: "bad" });
|
|
138
|
+
if (result.success) throw new Error("zod did not reject");
|
|
139
|
+
|
|
140
|
+
const err = validationErrorFromZod(result.error);
|
|
141
|
+
const fields = (err.details as { fields: Array<Record<string, unknown>> }).fields;
|
|
142
|
+
expect(fields[0]).toMatchObject({
|
|
143
|
+
code: "custom",
|
|
144
|
+
i18nKey: "errors.validation.custom",
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
109
148
|
test('root-level zod issue maps to path "(root)"', () => {
|
|
110
149
|
const schema = z.string();
|
|
111
150
|
const result = schema.safeParse(123);
|
package/src/errors/zod-bridge.ts
CHANGED
|
@@ -30,13 +30,30 @@ export function validationErrorFromZod(error: ZodError): ValidationError {
|
|
|
30
30
|
return {
|
|
31
31
|
path: issue.path.map(String).join(".") || "(root)",
|
|
32
32
|
code: issue.code,
|
|
33
|
-
i18nKey:
|
|
33
|
+
i18nKey: resolveI18nKey(issue),
|
|
34
34
|
...(params && { params }),
|
|
35
35
|
};
|
|
36
36
|
});
|
|
37
37
|
return new ValidationError({ fields }, { cause: error });
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// Every zod code maps mechanically to `errors.validation.<code>` — except
|
|
41
|
+
// `code: "custom"`, which is zod's one-size-fits-all bucket for every
|
|
42
|
+
// `superRefine`/`refine` check in the codebase (e.g. schema-builder.ts's
|
|
43
|
+
// totalsMatch check). Left mechanical, ALL of them would collapse onto the
|
|
44
|
+
// same `errors.validation.custom` ("Invalid value.") key. A `superRefine`
|
|
45
|
+
// that needs its own key sets `params.i18nKey` on the issue; this is the one
|
|
46
|
+
// place that honors it. Keep in sync with the client-side mirror
|
|
47
|
+
// (packages/headless/src/form/zod-bridge.ts) — a superRefine can run on
|
|
48
|
+
// either side.
|
|
49
|
+
function resolveI18nKey(issue: ZodIssue): string {
|
|
50
|
+
if (issue.code === "custom") {
|
|
51
|
+
const override = issue.params?.["i18nKey"];
|
|
52
|
+
if (typeof override === "string") return override;
|
|
53
|
+
}
|
|
54
|
+
return `errors.validation.${issue.code}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
40
57
|
function extractIssueParams(issue: ZodIssue): Readonly<Record<string, unknown>> | undefined {
|
|
41
58
|
// ZodIssue is a discriminated union with variant-specific params (minimum,
|
|
42
59
|
// maximum, expected, …); reading them generically requires widening since
|
|
@@ -3,16 +3,26 @@
|
|
|
3
3
|
// the raw-SQL spike used as proof before the ES pivot.
|
|
4
4
|
//
|
|
5
5
|
// Targets (from docs/plans/architecture/event-sourcing-spike-1.md):
|
|
6
|
-
// - Write-Latency
|
|
7
|
-
// - Read-Latency
|
|
8
|
-
// - Update-Latency
|
|
6
|
+
// - Write-Latency p95 < 30ms (append a single event)
|
|
7
|
+
// - Read-Latency p95 < 25ms (loadAggregate for a single aggregate)
|
|
8
|
+
// - Update-Latency p95 < 30ms (append with predecessor-check WHERE EXISTS)
|
|
9
9
|
// - Snapshot-Load < 50ms (1000-event aggregate, snapshot @ 900)
|
|
10
10
|
//
|
|
11
11
|
// Workload is sequential against local Docker Postgres — no network
|
|
12
12
|
// latency, single-node PG. Production deploys are slower; these numbers
|
|
13
|
-
// are the ceiling.
|
|
13
|
+
// are the ceiling.
|
|
14
14
|
//
|
|
15
|
-
// Isolated from bulk integration via `bun run test:integration:perf`.
|
|
15
|
+
// Isolated from bulk integration via `bun run test:integration:perf`. Used
|
|
16
|
+
// to run inside the `integration` CI job, right after the ~213-test bulk
|
|
17
|
+
// suite, and flaked up to 3.4x under that (30-102ms vs the 25-30ms budgets
|
|
18
|
+
// above, #1940). Moved to its own `event-store-perf` CI job
|
|
19
|
+
// (test:integration:perf:eventstore) — but re-measuring against a fresh
|
|
20
|
+
// container per run (mirroring that job) showed the real cause wasn't job
|
|
21
|
+
// contention: p50 sits at 1-3ms in every run, and single-sample p99 spikes
|
|
22
|
+
// to 47-73ms even fully isolated on an idle machine, from cold-Postgres
|
|
23
|
+
// connection/cache warm-up. Gate switched from p99 (the single worst-of-200
|
|
24
|
+
// sample) to p95 (drops the top 10), which absorbs that cold-start outlier
|
|
25
|
+
// while still catching a real order-of-magnitude regression.
|
|
16
26
|
|
|
17
27
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
18
28
|
import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
|
|
@@ -60,7 +70,7 @@ async function measure<T>(op: () => Promise<T>): Promise<number> {
|
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
describe("event-store performance — Gate A", () => {
|
|
63
|
-
test("write-latency
|
|
73
|
+
test("write-latency p95 < 30ms over 200 sequential appends", async () => {
|
|
64
74
|
const samples: number[] = [];
|
|
65
75
|
|
|
66
76
|
// Warm-up — Connection-Pool + Drizzle-Prepare-Overhead
|
|
@@ -95,13 +105,16 @@ describe("event-store performance — Gate A", () => {
|
|
|
95
105
|
|
|
96
106
|
samples.sort((a, b) => a - b);
|
|
97
107
|
const p50 = percentile(samples, 0.5);
|
|
108
|
+
const p95 = percentile(samples, 0.95);
|
|
98
109
|
const p99 = percentile(samples, 0.99);
|
|
99
|
-
console.log(
|
|
110
|
+
console.log(
|
|
111
|
+
` Write-latency: p50=${p50.toFixed(2)}ms, p95=${p95.toFixed(2)}ms, p99=${p99.toFixed(2)}ms (n=200)`,
|
|
112
|
+
);
|
|
100
113
|
|
|
101
|
-
expect(
|
|
114
|
+
expect(p95).toBeLessThan(30);
|
|
102
115
|
});
|
|
103
116
|
|
|
104
|
-
test("read-latency
|
|
117
|
+
test("read-latency p95 < 25ms for loadAggregate detail reads", async () => {
|
|
105
118
|
// Seed 200 single-event aggregates
|
|
106
119
|
const ids: string[] = [];
|
|
107
120
|
for (let i = 0; i < 200; i++) {
|
|
@@ -130,18 +143,18 @@ describe("event-store performance — Gate A", () => {
|
|
|
130
143
|
|
|
131
144
|
samples.sort((a, b) => a - b);
|
|
132
145
|
const p50 = percentile(samples, 0.5);
|
|
146
|
+
const p95 = percentile(samples, 0.95);
|
|
133
147
|
const p99 = percentile(samples, 0.99);
|
|
134
148
|
console.log(
|
|
135
|
-
` Read-latency: p50=${p50.toFixed(2)}ms, p99=${p99.toFixed(2)}ms (n=${ids.length})`,
|
|
149
|
+
` Read-latency: p50=${p50.toFixed(2)}ms, p95=${p95.toFixed(2)}ms, p99=${p99.toFixed(2)}ms (n=${ids.length})`,
|
|
136
150
|
);
|
|
137
151
|
|
|
138
|
-
// 25ms
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
expect(p99).toBeLessThan(25);
|
|
152
|
+
// 25ms budget kept from the original spike doc's 10ms — an
|
|
153
|
+
// order-of-magnitude gate, not an idle-best-case one. Tracking: #325.
|
|
154
|
+
expect(p95).toBeLessThan(25);
|
|
142
155
|
});
|
|
143
156
|
|
|
144
|
-
test("update-latency
|
|
157
|
+
test("update-latency p95 < 30ms — exercises predecessor-check WHERE EXISTS path", async () => {
|
|
145
158
|
// Single aggregate, repeated updates — the INSERT … SELECT … WHERE EXISTS
|
|
146
159
|
// path is heavier than a simple create and adds an index lookup.
|
|
147
160
|
const aggregateId = uuid();
|
|
@@ -191,10 +204,13 @@ describe("event-store performance — Gate A", () => {
|
|
|
191
204
|
|
|
192
205
|
samples.sort((a, b) => a - b);
|
|
193
206
|
const p50 = percentile(samples, 0.5);
|
|
207
|
+
const p95 = percentile(samples, 0.95);
|
|
194
208
|
const p99 = percentile(samples, 0.99);
|
|
195
|
-
console.log(
|
|
209
|
+
console.log(
|
|
210
|
+
` Update-latency: p50=${p50.toFixed(2)}ms, p95=${p95.toFixed(2)}ms, p99=${p99.toFixed(2)}ms (n=200)`,
|
|
211
|
+
);
|
|
196
212
|
|
|
197
|
-
expect(
|
|
213
|
+
expect(p95).toBeLessThan(30);
|
|
198
214
|
});
|
|
199
215
|
|
|
200
216
|
test("snapshot-load < 50ms for 1000-event aggregate (Gate A)", async () => {
|
|
@@ -12,3 +12,17 @@ describe("stringifyJson — Temporal.Instant without ambient Temporal", () => {
|
|
|
12
12
|
});
|
|
13
13
|
});
|
|
14
14
|
});
|
|
15
|
+
|
|
16
|
+
describe("stringifyJson — Temporal.PlainDate (kumiko-framework#1924)", () => {
|
|
17
|
+
test("serializes to yyyy-mm-dd via PlainDate's own toJSON(), no special-casing needed", () => {
|
|
18
|
+
const day = PolyfillTemporal.PlainDate.from("2026-03-15");
|
|
19
|
+
expect(stringifyJson({ publishedAt: day })).toBe('{"publishedAt":"2026-03-15"}');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("serializes polyfill PlainDate when globalThis.Temporal is missing", async () => {
|
|
23
|
+
const day = PolyfillTemporal.PlainDate.from("2026-03-15");
|
|
24
|
+
await withoutAmbientTemporal(() => {
|
|
25
|
+
expect(stringifyJson({ publishedAt: day })).toBe('{"publishedAt":"2026-03-15"}');
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
});
|