@cosmicdrift/kumiko-framework 0.187.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.187.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,10 +182,10 @@
182
182
  "./package.json": "./package.json"
183
183
  },
184
184
  "dependencies": {
185
- "@cosmicdrift/kumiko-types": "0.187.0",
185
+ "@cosmicdrift/kumiko-types": "0.189.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.187.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
  },
@@ -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
@@ -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
+ });
@@ -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", {
@@ -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 }) => {
@@ -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
- if (typeof value === "boolean") {
475
- return { kind: "param", sql: "::text::jsonb", bound: JSON.stringify(value) };
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).toContain("timestamp");
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
- // pg ALTER TYPE braucht oft USING-clause für nicht-implicit-castable
275
- // type-changes. Wir emittieren das als Reviewer-Kommentar + raw cast
276
- // App-Author muss prüfen ob das gewünscht ist.
277
- out.push(
278
- `-- WARN: column-type-change ${change.typeChanged.from} ${change.typeChanged.to}. Review USING-clause if needed.`,
279
- );
280
- out.push(`ALTER TABLE ${tbl} ALTER COLUMN ${col} TYPE ${change.typeChanged.to};`);
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
  }
@@ -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
- // `type:"date"` aliased auf instant() = TIMESTAMPTZ. Echte
170
- // PlainDate-Migration (PG `date` Spalte, kein TZ) kommt später.
171
- const col = instant(snakeName);
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" | "timestamp" }
342
+ : F extends { type: "date" }
341
343
  ? F extends { required: true }
342
- ? { readonly [P in K]: Col<Temporal.Instant> }
343
- : { readonly [P in K]: NullCol<Temporal.Instant> }
344
- : F extends { type: "locatedTimestamp" }
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 `${K}Utc`]: Col<Temporal.Instant> } & {
347
- readonly [P in `${K}Tz`]: Col<string>;
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<string> }
355
- : { readonly [P in K]: NullCol<string> }
356
- : F extends { type: "files" | "images" }
357
- ? Record<never, never>
358
- : never;
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,