@cosmicdrift/kumiko-framework 0.92.0 → 0.93.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 +2 -2
- package/src/engine/__tests__/schema-builder.test.ts +27 -0
- package/src/engine/schema-builder.ts +6 -6
- package/src/time/__tests__/boot-tz-warning.test.ts +24 -0
- package/src/time/__tests__/iana.test.ts +33 -0
- package/src/time/__tests__/tz-dateline.test.ts +36 -0
- package/src/time/boot-tz-warning.ts +26 -0
- package/src/time/iana.ts +17 -0
- package/src/time/index.ts +4 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.93.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>",
|
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
"zod": "^4.4.3"
|
|
182
182
|
},
|
|
183
183
|
"devDependencies": {
|
|
184
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
184
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.93.0",
|
|
185
185
|
"bun-types": "^1.3.13",
|
|
186
186
|
"pino-pretty": "^13.1.3"
|
|
187
187
|
},
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
createFilesField,
|
|
9
9
|
createImageField,
|
|
10
10
|
createImagesField,
|
|
11
|
+
createLocatedTimestampField,
|
|
11
12
|
createMoneyField,
|
|
12
13
|
createMultiSelectField,
|
|
13
14
|
createNumberField,
|
|
@@ -339,6 +340,32 @@ describe("buildInsertSchema", () => {
|
|
|
339
340
|
const schema = buildInsertSchema(entity);
|
|
340
341
|
expect(schema.safeParse({}).success).toBe(false);
|
|
341
342
|
});
|
|
343
|
+
|
|
344
|
+
test("tz field validates against the IANA zone list", () => {
|
|
345
|
+
const entity = createEntity({
|
|
346
|
+
table: "Test",
|
|
347
|
+
fields: { zone: { type: "tz", required: true } },
|
|
348
|
+
});
|
|
349
|
+
const schema = buildInsertSchema(entity);
|
|
350
|
+
expect(schema.safeParse({ zone: "Europe/Berlin" }).success).toBe(true);
|
|
351
|
+
expect(schema.safeParse({ zone: "UTC" }).success).toBe(true);
|
|
352
|
+
expect(schema.safeParse({ zone: "Mars/Phobos" }).success).toBe(false);
|
|
353
|
+
expect(schema.safeParse({ zone: "" }).success).toBe(false);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("locatedTimestamp validates its tz against the IANA zone list", () => {
|
|
357
|
+
const entity = createEntity({
|
|
358
|
+
table: "Test",
|
|
359
|
+
fields: { pickup: createLocatedTimestampField({ required: true }) },
|
|
360
|
+
});
|
|
361
|
+
const schema = buildInsertSchema(entity);
|
|
362
|
+
expect(
|
|
363
|
+
schema.safeParse({ pickup: { at: "2026-04-03T10:00:00", tz: "Europe/Lisbon" } }).success,
|
|
364
|
+
).toBe(true);
|
|
365
|
+
expect(
|
|
366
|
+
schema.safeParse({ pickup: { at: "2026-04-03T10:00:00", tz: "Mars/Phobos" } }).success,
|
|
367
|
+
).toBe(false);
|
|
368
|
+
});
|
|
342
369
|
});
|
|
343
370
|
|
|
344
371
|
// --- Update schema (all partial) ---
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { isValidIanaTimeZone } from "../time";
|
|
2
3
|
import { assertUnreachable } from "../utils";
|
|
3
4
|
import type { EmbeddedSubFieldDef, EntityDefinition, FieldDefinition } from "./types";
|
|
4
5
|
import { DEFAULT_CURRENCIES } from "./types";
|
|
@@ -153,11 +154,10 @@ export function fieldToZod(field: FieldDefinition, currencies: readonly string[]
|
|
|
153
154
|
return withDateBounds(schema, field.min, field.max);
|
|
154
155
|
}
|
|
155
156
|
case "tz": {
|
|
156
|
-
// IANA-Zonenname
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
return z.string().min(1);
|
|
157
|
+
// IANA-Zonenname, validiert gegen die Runtime-Zonenliste
|
|
158
|
+
// (isValidIanaTimeZone). Ein ungültiger Name failt hier am
|
|
159
|
+
// Write-Boundary statt erst später in ctx.tz.parse / Temporal.
|
|
160
|
+
return z.string().refine(isValidIanaTimeZone, { message: "invalid IANA time zone" });
|
|
161
161
|
}
|
|
162
162
|
case "locatedTimestamp": {
|
|
163
163
|
// Combined Wall-Clock+TZ Object. Beim Write akzeptieren wir entweder
|
|
@@ -167,7 +167,7 @@ export function fieldToZod(field: FieldDefinition, currencies: readonly string[]
|
|
|
167
167
|
//
|
|
168
168
|
// Hier nur die Schema-Garantie: mindestens tz + (at ODER utc).
|
|
169
169
|
const at = z.iso.datetime({ local: true });
|
|
170
|
-
const tz = z.string().
|
|
170
|
+
const tz = z.string().refine(isValidIanaTimeZone, { message: "invalid IANA time zone" });
|
|
171
171
|
const utc = z.iso.datetime();
|
|
172
172
|
return z.union([
|
|
173
173
|
z.object({ at, tz, utc: utc.optional() }),
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { warnIfNonUtcServerTimeZone } from "../boot-tz-warning";
|
|
3
|
+
|
|
4
|
+
describe("warnIfNonUtcServerTimeZone", () => {
|
|
5
|
+
test("warnt nicht wenn die Prozess-TZ UTC ist", () => {
|
|
6
|
+
const messages: string[] = [];
|
|
7
|
+
const warned = warnIfNonUtcServerTimeZone("UTC", (m) => messages.push(m));
|
|
8
|
+
expect(warned).toBe(false);
|
|
9
|
+
expect(messages).toHaveLength(0);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("warnt bei nicht-UTC Zone, nennt Zone + UTC-Hinweis", () => {
|
|
13
|
+
const messages: string[] = [];
|
|
14
|
+
const warned = warnIfNonUtcServerTimeZone("Europe/Berlin", (m) => messages.push(m));
|
|
15
|
+
expect(warned).toBe(true);
|
|
16
|
+
expect(messages).toHaveLength(1);
|
|
17
|
+
expect(messages[0]).toContain("Europe/Berlin");
|
|
18
|
+
expect(messages[0]).toContain("UTC");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("Default-Aufruf liest die Prozess-TZ ohne zu werfen", () => {
|
|
22
|
+
expect(() => warnIfNonUtcServerTimeZone(undefined, () => {})).not.toThrow();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { isValidIanaTimeZone } from "../iana";
|
|
3
|
+
|
|
4
|
+
describe("isValidIanaTimeZone", () => {
|
|
5
|
+
// Die 5 Zonen der geplanten CI-TZ-Matrix (timezones.md) müssen alle gültig
|
|
6
|
+
// sein — sonst kann die Matrix sie nicht setzen.
|
|
7
|
+
test.each([
|
|
8
|
+
"UTC",
|
|
9
|
+
"Europe/Berlin",
|
|
10
|
+
"America/Los_Angeles",
|
|
11
|
+
"Asia/Tokyo",
|
|
12
|
+
"Pacific/Apia",
|
|
13
|
+
])("akzeptiert kanonische Zone %s", (zone) => {
|
|
14
|
+
expect(isValidIanaTimeZone(zone)).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test.each([
|
|
18
|
+
"",
|
|
19
|
+
"Mars/Phobos",
|
|
20
|
+
"europe/berlin",
|
|
21
|
+
"Europe/Berlin ",
|
|
22
|
+
"GMT+2",
|
|
23
|
+
"not-a-zone",
|
|
24
|
+
])("lehnt ungültigen / nicht-kanonischen String %p ab", (value) => {
|
|
25
|
+
expect(isValidIanaTimeZone(value)).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("liefert über mehrere Aufrufe konsistent (lazy Set gecacht)", () => {
|
|
29
|
+
expect(isValidIanaTimeZone("Europe/Berlin")).toBe(true);
|
|
30
|
+
expect(isValidIanaTimeZone("Europe/Berlin")).toBe(true);
|
|
31
|
+
expect(isValidIanaTimeZone("Mars/Phobos")).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Pacific/Apia (Samoa) liegt weit östlich der Datumsgrenze bei UTC+13/+14 —
|
|
2
|
+
// eine Vormittags-Wall-Clock dort fällt in UTC auf den VORHERIGEN Kalendertag.
|
|
3
|
+
// Das Plan-Doc (timezones.md) nennt Apia explizit als Datumsgrenzen-Edge-Case
|
|
4
|
+
// der TZ-Matrix. Als Datums-Ordnungs-Eigenschaft formuliert, damit der Test
|
|
5
|
+
// unabhängig von der DST-Sicht der tzdata grün bleibt (UTC+13 wie +14 schieben
|
|
6
|
+
// 10:00 auf den Vortag).
|
|
7
|
+
|
|
8
|
+
import { beforeAll, describe, expect, test } from "bun:test";
|
|
9
|
+
import { ensureTemporalPolyfill } from "../polyfill";
|
|
10
|
+
import { createTzContext } from "../tz-context";
|
|
11
|
+
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
await ensureTemporalPolyfill();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("ctx.tz — Pacific/Apia Datumsgrenze", () => {
|
|
17
|
+
test("Vormittags-Wall-Clock in Apia mappt auf den vorherigen UTC-Kalendertag", () => {
|
|
18
|
+
const tz = createTzContext();
|
|
19
|
+
const zdt = tz.parse("2026-01-15T10:00:00", "Pacific/Apia");
|
|
20
|
+
expect(zdt.timeZoneId).toBe("Pacific/Apia");
|
|
21
|
+
|
|
22
|
+
const utcDate = zdt.toInstant().toZonedDateTimeISO("UTC").toPlainDate().toString();
|
|
23
|
+
expect(utcDate).toBe("2026-01-14");
|
|
24
|
+
|
|
25
|
+
// Offset ist ein großer positiver Wert (UTC+13 oder +14).
|
|
26
|
+
expect(zdt.offsetNanoseconds).toBeGreaterThan(12 * 3600 * 1e9);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("Round-Trip bewahrt Wall-Clock + Instant über die Datumsgrenze", () => {
|
|
30
|
+
const tz = createTzContext();
|
|
31
|
+
const original = tz.parse("2026-01-15T10:00:00", "Pacific/Apia");
|
|
32
|
+
const restored = tz.fromLocatedJson(tz.toLocatedJson(original));
|
|
33
|
+
expect(restored.toInstant().equals(original.toInstant())).toBe(true);
|
|
34
|
+
expect(restored.timeZoneId).toBe("Pacific/Apia");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Boot-Warnung wenn die Prozess-Uhr nicht in UTC läuft. Der gesamte
|
|
2
|
+
// Server-Code nimmt UTC an (System-TZ ist kein Konzept — siehe timezones.md);
|
|
3
|
+
// eine abweichende Prozess-TZ lässt versehentliche new Date()-Pfade
|
|
4
|
+
// lokal-abhängig brechen ("grün in UTC-CI, kaputt in Berlin-Prod"). Soft:
|
|
5
|
+
// nicht-UTC ist in Dev legitim, daher Warnung statt Boot-Fehler.
|
|
6
|
+
|
|
7
|
+
function resolvedServerTimeZone(): string {
|
|
8
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Warnt einmalig beim Server-Start, wenn die Prozess-TZ nicht UTC ist.
|
|
13
|
+
* Parameter sind für Tests injizierbar. Gibt zurück, ob gewarnt wurde.
|
|
14
|
+
*/
|
|
15
|
+
export function warnIfNonUtcServerTimeZone(
|
|
16
|
+
resolvedTimeZone: string = resolvedServerTimeZone(),
|
|
17
|
+
// biome-ignore lint/suspicious/noConsole: boot-time warning, no logger wired this early
|
|
18
|
+
warn: (message: string) => void = console.warn,
|
|
19
|
+
): boolean {
|
|
20
|
+
if (resolvedTimeZone === "UTC") return false;
|
|
21
|
+
warn(
|
|
22
|
+
`[kumiko] Server time zone is "${resolvedTimeZone}" — the framework assumes UTC. ` +
|
|
23
|
+
"Set TZ=UTC for the server process to avoid time-zone-dependent bugs.",
|
|
24
|
+
);
|
|
25
|
+
return true;
|
|
26
|
+
}
|
package/src/time/iana.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// IANA-Zonenname-Validierung gegen die Runtime-eigene Zonenliste
|
|
2
|
+
// (Intl.supportedValuesOf). Das Set wird lazy einmal gebaut — der Aufbau ist
|
|
3
|
+
// der teure Teil (~400 kanonische Zonen), das Lookup danach O(1).
|
|
4
|
+
|
|
5
|
+
let supportedZones: ReadonlySet<string> | undefined;
|
|
6
|
+
|
|
7
|
+
function ianaZoneSet(): ReadonlySet<string> {
|
|
8
|
+
if (supportedZones === undefined) {
|
|
9
|
+
supportedZones = new Set(Intl.supportedValuesOf("timeZone"));
|
|
10
|
+
}
|
|
11
|
+
return supportedZones;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** True wenn `value` ein gültiger kanonischer IANA-Zonenname ist (z.B. "Europe/Berlin", "UTC"). */
|
|
15
|
+
export function isValidIanaTimeZone(value: string): boolean {
|
|
16
|
+
return ianaZoneSet().has(value);
|
|
17
|
+
}
|
package/src/time/index.ts
CHANGED
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
// - getTemporal: type-safer Zugriff auf globalThis.Temporal
|
|
6
6
|
// - createTzContext: ctx.tz Factory mit now/today/parse/toLocatedJson/...
|
|
7
7
|
// - LocatedTimestampJson: API-Boundary-Form { at, tz }
|
|
8
|
+
// - isValidIanaTimeZone: IANA-Zonennamen-Validierung (type:"tz"-Felder)
|
|
9
|
+
// - warnIfNonUtcServerTimeZone: Boot-Warnung bei nicht-UTC Prozess-TZ
|
|
8
10
|
//
|
|
9
11
|
// Kommt:
|
|
10
|
-
// - DB-Wrapper (Wall-Clock+tz ↔ UTC transparent in Drizzle-Layer)
|
|
11
|
-
// - Lint-Regel "kein new Date() im Feature-Code"
|
|
12
12
|
// - UI-Komponenten <DateTimeInput>, <LocatedDateTimePicker>, <DateInput>
|
|
13
13
|
|
|
14
|
+
export { warnIfNonUtcServerTimeZone } from "./boot-tz-warning";
|
|
15
|
+
export { isValidIanaTimeZone } from "./iana";
|
|
14
16
|
export { ensureTemporalPolyfill, getTemporal } from "./polyfill";
|
|
15
17
|
export {
|
|
16
18
|
createTzContext,
|