@cosmicdrift/kumiko-framework 0.92.0 → 0.94.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.92.0",
3
+ "version": "0.94.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.92.0",
184
+ "@cosmicdrift/kumiko-dispatcher-live": "0.94.0",
185
185
  "bun-types": "^1.3.13",
186
186
  "pino-pretty": "^13.1.3"
187
187
  },
@@ -0,0 +1,126 @@
1
+ // Full-stack proof for EntityDefinition.derivedFields: a derived field is
2
+ // computed read-time by the list-query handler and appended to each row — it
3
+ // has no DB column, is never written, and the clock comes from ctx.asOf.
4
+ //
5
+ // Bun.SQL-only setup via setupTestStack.
6
+
7
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
8
+ import { asRawClient, selectMany } from "../db/query";
9
+ import { buildEntityTable } from "../db/table-builder";
10
+ import {
11
+ createDerivedField,
12
+ createEntity,
13
+ createNumberField,
14
+ createTextField,
15
+ defineEntityCreateHandler,
16
+ defineEntityListHandler,
17
+ defineFeature,
18
+ } from "../engine";
19
+ import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../stack";
20
+
21
+ // `priceCents` + `name` are stored; the three derived fields are computed from
22
+ // them (and the clock) at read-time only.
23
+ const gadgetEntity = createEntity({
24
+ table: "derived_gadgets",
25
+ fields: {
26
+ name: createTextField({ required: true }),
27
+ priceCents: createNumberField({ required: true }),
28
+ },
29
+ derivedFields: {
30
+ // Pure row→value: the canonical "one live-computed column" case.
31
+ grossCents: createDerivedField({
32
+ valueType: "number",
33
+ derive: (row) => Math.round(Number(row["priceCents"]) * 1.19),
34
+ }),
35
+ label: createDerivedField({
36
+ valueType: "text",
37
+ derive: (row) => `${String(row["name"])} (${String(row["priceCents"])})`,
38
+ }),
39
+ // Proves the clock is injected — never reads Temporal.Now itself.
40
+ asOfStamp: createDerivedField({
41
+ valueType: "text",
42
+ derive: (_row, ctx) => ctx.asOf.toString(),
43
+ }),
44
+ },
45
+ });
46
+ const gadgetTable = buildEntityTable("gadget", gadgetEntity);
47
+
48
+ const shopFeature = defineFeature("gadgetshop", (r) => {
49
+ r.entity("gadget", gadgetEntity);
50
+ r.writeHandler(
51
+ defineEntityCreateHandler("gadget", gadgetEntity, { access: { roles: ["Admin"] } }),
52
+ );
53
+ r.queryHandler(defineEntityListHandler("gadget", gadgetEntity, { access: { roles: ["Admin"] } }));
54
+ });
55
+
56
+ describe("EntityDefinition.derivedFields — read-time computed columns", () => {
57
+ let stack: TestStack;
58
+
59
+ beforeAll(async () => {
60
+ stack = await setupTestStack({ features: [shopFeature] });
61
+ await unsafeCreateEntityTable(stack.db, gadgetEntity);
62
+ });
63
+
64
+ afterAll(() => stack.cleanup());
65
+
66
+ beforeEach(async () => {
67
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${gadgetTable.tableName}"`);
68
+ });
69
+
70
+ test("list query appends the computed value (gross = net × 1.19)", async () => {
71
+ await stack.http.writeOk(
72
+ "gadgetshop:write:gadget:create",
73
+ { name: "Cable", priceCents: 1000 },
74
+ TestUsers.admin,
75
+ );
76
+ await stack.http.writeOk(
77
+ "gadgetshop:write:gadget:create",
78
+ { name: "Hub", priceCents: 4200 },
79
+ TestUsers.admin,
80
+ );
81
+
82
+ const res = await stack.http.query("gadgetshop:query:gadget:list", {}, TestUsers.admin);
83
+ expect(res.status).toBe(200);
84
+ const body = (await res.json()) as {
85
+ data: { rows: Array<Record<string, unknown>> };
86
+ };
87
+ const byName = new Map(body.data.rows.map((r) => [r["name"], r]));
88
+
89
+ expect(byName.get("Cable")?.["grossCents"]).toBe(1190);
90
+ expect(byName.get("Hub")?.["grossCents"]).toBe(4998);
91
+ expect(byName.get("Cable")?.["label"]).toBe("Cable (1000)");
92
+ });
93
+
94
+ test("derived value comes from ctx.asOf, parseable as an instant", async () => {
95
+ await stack.http.writeOk(
96
+ "gadgetshop:write:gadget:create",
97
+ { name: "Cable", priceCents: 1000 },
98
+ TestUsers.admin,
99
+ );
100
+
101
+ const res = await stack.http.query("gadgetshop:query:gadget:list", {}, TestUsers.admin);
102
+ const body = (await res.json()) as { data: { rows: Array<Record<string, unknown>> } };
103
+ const stamp = body.data.rows[0]?.["asOfStamp"];
104
+
105
+ expect(typeof stamp).toBe("string");
106
+ // A real read-time instant — Temporal parses it and it's within a minute.
107
+ const parsed = Temporal.Instant.from(String(stamp));
108
+ const skewSeconds = Math.abs(parsed.until(Temporal.Now.instant()).total("seconds"));
109
+ expect(skewSeconds).toBeLessThan(60);
110
+ });
111
+
112
+ test("derived fields produce no DB column — the stored row has only name + priceCents", async () => {
113
+ await stack.http.writeOk(
114
+ "gadgetshop:write:gadget:create",
115
+ { name: "Cable", priceCents: 1000 },
116
+ TestUsers.admin,
117
+ );
118
+
119
+ const rows = await selectMany(stack.db, gadgetTable);
120
+ expect(rows).toHaveLength(1);
121
+ expect(rows[0]).not.toHaveProperty("grossCents");
122
+ expect(rows[0]).not.toHaveProperty("label");
123
+ expect(rows[0]).not.toHaveProperty("asOfStamp");
124
+ expect(rows[0]?.["name"]).toBe("Cable");
125
+ });
126
+ });
@@ -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,7 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { validateBoot } from "../boot-validator";
3
3
  import { defineFeature } from "../define-feature";
4
- import { createEntity, createTextField } from "../factories";
4
+ import { createDerivedField, createEntity, createTextField } from "../factories";
5
5
  import { createRegistry } from "../registry";
6
6
  import type { ScreenDefinition } from "../types/screen";
7
7
 
@@ -15,6 +15,21 @@ function productEntity() {
15
15
  });
16
16
  }
17
17
 
18
+ function derivedProductEntity() {
19
+ return createEntity({
20
+ table: "derived_products",
21
+ fields: {
22
+ name: createTextField(),
23
+ },
24
+ derivedFields: {
25
+ summary: createDerivedField({
26
+ valueType: "text",
27
+ derive: (row) => String(row["name"]),
28
+ }),
29
+ },
30
+ });
31
+ }
32
+
18
33
  describe("r.screen() — registration", () => {
19
34
  test("stores an entityList screen on the FeatureDefinition", () => {
20
35
  const feature = defineFeature("shop", (r) => {
@@ -279,6 +294,33 @@ describe("validateBoot — screen validation", () => {
279
294
  expect(() => validateBoot([feature])).toThrow(/field "nonexistent"/);
280
295
  });
281
296
 
297
+ test("entityList with a derived-field column boots (derived is a valid column)", () => {
298
+ const feature = defineFeature("shop", (r) => {
299
+ r.entity("product", derivedProductEntity());
300
+ r.screen({
301
+ id: "list",
302
+ type: "entityList",
303
+ entity: "product",
304
+ columns: ["name", "summary"],
305
+ });
306
+ });
307
+ expect(() => validateBoot([feature])).not.toThrow();
308
+ });
309
+
310
+ test("entityList defaultSort on a derived field fails boot (server sort can't apply)", () => {
311
+ const feature = defineFeature("shop", (r) => {
312
+ r.entity("product", derivedProductEntity());
313
+ r.screen({
314
+ id: "list",
315
+ type: "entityList",
316
+ entity: "product",
317
+ columns: ["name", "summary"],
318
+ defaultSort: { field: "summary", dir: "asc" },
319
+ });
320
+ });
321
+ expect(() => validateBoot([feature])).toThrow(/defaultSort references unknown field "summary"/);
322
+ });
323
+
282
324
  test("entityEdit with unknown field (string form in sections) fails boot", () => {
283
325
  const feature = defineFeature("shop", (r) => {
284
326
  r.entity("product", productEntity());
@@ -309,6 +309,14 @@ export function validateScreens(
309
309
  }
310
310
 
311
311
  const fieldNames = new Set(Object.keys(entityDef.fields));
312
+ // List columns may also name a read-time derived field (not a stored
313
+ // column). Allowed for display; deliberately NOT added to `fieldNames`, so
314
+ // defaultSort/filter on a derived field still fails — server-side sort over
315
+ // a non-column is a silent no-op (see DerivedFieldDef).
316
+ const columnFieldNames =
317
+ entityDef.derivedFields !== undefined
318
+ ? new Set([...fieldNames, ...Object.keys(entityDef.derivedFields)])
319
+ : fieldNames;
312
320
  const rowMeta = rowMetaFieldNames(entityDef.softDelete ?? false);
313
321
  if (screen.type === "entityList") {
314
322
  // Empty column list would render as a blank table — almost always the
@@ -323,14 +331,14 @@ export function validateScreens(
323
331
  }
324
332
  for (const col of screen.columns) {
325
333
  const normalized = normalizeListColumn(col);
326
- if (!fieldNames.has(normalized.field)) {
334
+ if (!columnFieldNames.has(normalized.field)) {
327
335
  throw new Error(
328
336
  buildUnknownFieldMessage(
329
337
  feature.name,
330
338
  screenId,
331
339
  normalized.field,
332
340
  screen.entity,
333
- fieldNames,
341
+ columnFieldNames,
334
342
  ),
335
343
  );
336
344
  }
@@ -10,7 +10,13 @@ import { createEventStoreExecutor, type EventStoreExecutor } from "../db/event-s
10
10
  import { buildEntityTable } from "../db/table-builder";
11
11
  import { assertUnreachable } from "../utils";
12
12
  import { buildInsertSchema, buildUpdateSchema } from "./schema-builder";
13
- import type { AccessRule, EntityDefinition, QueryHandlerDef, WriteHandlerDef } from "./types";
13
+ import type {
14
+ AccessRule,
15
+ DeriveContext,
16
+ EntityDefinition,
17
+ QueryHandlerDef,
18
+ WriteHandlerDef,
19
+ } from "./types";
14
20
 
15
21
  // Convention-based handler factories for event-sourced aggregates.
16
22
  //
@@ -190,6 +196,29 @@ export function defineEntityWriteHandler(
190
196
  };
191
197
  }
192
198
 
199
+ // Append read-time derived-field values (EntityDefinition.derivedFields) to
200
+ // each row, after the SQL fetch + reference eagerload — i.e. on the row shape
201
+ // the view-model will see. The clock is the read instant; derive bodies must
202
+ // read it from ctx.asOf, never their own Date/Temporal.Now. No-op (returns the
203
+ // rows untouched) when the entity declares no derived fields.
204
+ function augmentDerivedFields(
205
+ rows: readonly Record<string, unknown>[],
206
+ entity: EntityDefinition,
207
+ ): readonly Record<string, unknown>[] {
208
+ const derived = entity.derivedFields;
209
+ if (derived === undefined) return rows;
210
+ const entries = Object.entries(derived);
211
+ if (entries.length === 0) return rows;
212
+ const ctx: DeriveContext = { asOf: Temporal.Now.instant() };
213
+ return rows.map((row) => {
214
+ const out: Record<string, unknown> = { ...row };
215
+ for (const [fieldName, def] of entries) {
216
+ out[fieldName] = def.derive(row, ctx);
217
+ }
218
+ return out;
219
+ });
220
+ }
221
+
193
222
  export function defineEntityQueryHandler(
194
223
  name: string,
195
224
  entity: EntityDefinition,
@@ -225,14 +254,15 @@ export function defineEntityQueryHandler(
225
254
  ...(ctx.searchAdapter !== undefined && { searchAdapter: ctx.searchAdapter }),
226
255
  ...(ctx.includeDeleted === true && { includeDeleted: true }),
227
256
  });
228
- if (!hasRefFields) return result;
229
- const enrichedRows = await enrichWithReferences(
230
- result.rows,
231
- entity,
232
- (name) => ctx.registry.getEntity(name),
233
- ctx.db,
234
- );
235
- return { ...result, rows: enrichedRows };
257
+ const enrichedRows = hasRefFields
258
+ ? await enrichWithReferences(
259
+ result.rows,
260
+ entity,
261
+ (name) => ctx.registry.getEntity(name),
262
+ ctx.db,
263
+ )
264
+ : result.rows;
265
+ return { ...result, rows: augmentDerivedFields(enrichedRows, entity) };
236
266
  };
237
267
  break;
238
268
  case "detail":
@@ -3,6 +3,7 @@ import type {
3
3
  BooleanFieldDef,
4
4
  DateFieldDef,
5
5
  DecimalFieldDef,
6
+ DerivedFieldDef,
6
7
  EmbeddedFieldDef,
7
8
  EntityDefinition,
8
9
  EntityIndexDef,
@@ -54,6 +55,17 @@ export function createTextField<R extends true | false = false>(
54
55
  * Setze einen explicit `maxLength` wenn du ein verirrtes Browser-Paste
55
56
  * früh ablehnen willst (z.B. 1_000_000 = 1 MB).
56
57
  */
58
+ /**
59
+ * Read-time computed field for `EntityDefinition.derivedFields` (NOT `fields`).
60
+ * The value is derived per row from the stored columns + the clock at query
61
+ * time, never persisted. Display only — server-side sort/filter/search don't
62
+ * apply and there's no client-side sort path (see DerivedFieldDef). `derive`
63
+ * must take its clock from `ctx.asOf`, never `Temporal.Now`/`Date`.
64
+ */
65
+ export function createDerivedField(spec: DerivedFieldDef): DerivedFieldDef {
66
+ return { ...spec };
67
+ }
68
+
57
69
  export function createLongTextField<R extends true | false = false>(
58
70
  overrides?: Partial<Omit<LongTextFieldDef, "type" | "required">> & { required?: R },
59
71
  ): LongTextFieldDef & { required: R } {
@@ -373,6 +385,7 @@ export function createEntity<F>(def: {
373
385
  readonly idType?: "serial" | "uuid";
374
386
  readonly access?: EntityDefinition["access"];
375
387
  readonly retention?: RetentionDef;
388
+ readonly derivedFields?: EntityDefinition["derivedFields"];
376
389
  }): F extends FieldsMap ? EntityDefinition<F> : never {
377
390
  return {
378
391
  softDelete: false,
@@ -95,6 +95,7 @@ export {
95
95
  createBooleanField,
96
96
  createDateField,
97
97
  createDecimalField,
98
+ createDerivedField,
98
99
  createEmbeddedField,
99
100
  createEntity,
100
101
  createFileField,
@@ -263,6 +264,10 @@ export type {
263
264
  DateFieldDef,
264
265
  DecimalFieldDef,
265
266
  DeleteContext,
267
+ DeriveContext,
268
+ DerivedFieldDef,
269
+ DerivedFieldsMap,
270
+ DerivedValueType,
266
271
  EditExtensionSection,
267
272
  EditFieldSpec,
268
273
  EditFieldsSection,
@@ -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. Validierung gegen Intl.supportedValuesOf("timeZone")
157
- // ist genau aber teuer (~600 Strings) wir akzeptieren den freien
158
- // String und prüfen via try/catch im Boot-Validator (kommt in
159
- // späterer Iteration).
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().min(1);
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() }),
@@ -516,6 +516,47 @@ export function isFileField(field: FieldDefinition | undefined): field is AnyFil
516
516
  );
517
517
  }
518
518
 
519
+ // --- Derived (computed) fields ---
520
+ //
521
+ // A derived field is read-time only: its value is computed from the stored row
522
+ // (and the clock) when a list/detail query runs, never persisted. It lives in
523
+ // `EntityDefinition.derivedFields` — deliberately NOT in `fields`, so it
524
+ // produces no DB column, never enters a write schema, and can't be the target
525
+ // of an entityEdit. A declarative `entityList` can name it like any column and
526
+ // the view-model renders the appended value.
527
+ //
528
+ // LIMIT: derived columns are DISPLAY ONLY. A declarative `entityList` loads its
529
+ // rows server-side and a column-header sort round-trips to the server, where
530
+ // `executor.list` sorts/filters/searches over real SQL columns — so a derived
531
+ // field (no column) silently no-ops. There is no client-side sort path. Need a
532
+ // derived value sortable/searchable? Materialize it as a stored field; it then
533
+ // rides the existing `searchable`/`sortable` machinery. Time-dependent values
534
+ // (as-of-today) can't be materialized without a daily re-index anyway.
535
+
536
+ /** Display type a derived value formats as — drives the column's renderer
537
+ * choice in the view-model, parallel to FieldDefinition["type"]. Single-column
538
+ * types only: `money` is excluded because it needs a `<name>Currency`
539
+ * companion column a derived field has no place to put — use `number`/
540
+ * `decimal` plus a `{ format: "currency" }` column renderer instead. */
541
+ export type DerivedValueType = "text" | "number" | "decimal" | "boolean" | "date" | "timestamp";
542
+
543
+ /** Clock injected into `derive` — never read `Temporal.Now`/`Date` inside a
544
+ * derive body (no-date-api guard + testability). The list-query handler passes
545
+ * the read-time instant; unit tests pass a fixed one. */
546
+ export type DeriveContext = {
547
+ readonly asOf: Temporal.Instant;
548
+ };
549
+
550
+ export type DerivedFieldDef = {
551
+ readonly valueType: DerivedValueType;
552
+ /** Pure function of the stored row + clock. Returns the JSON-safe display
553
+ * value (e.g. integer minor units for a currency column, ISO string for a
554
+ * `date`). */
555
+ readonly derive: (row: Readonly<Record<string, unknown>>, ctx: DeriveContext) => unknown;
556
+ };
557
+
558
+ export type DerivedFieldsMap = Readonly<Record<string, DerivedFieldDef>>;
559
+
519
560
  // --- Entity ---
520
561
 
521
562
  // --- State Transitions ---
@@ -613,4 +654,10 @@ export type EntityDefinition<F extends FieldsMap = FieldsMap> = {
613
654
  * Siehe docs/plans/features/core-data-retention.md.
614
655
  */
615
656
  readonly retention?: RetentionDef;
657
+ /**
658
+ * Read-time computed fields, keyed by name. Not stored, not a DB column,
659
+ * not writable — appended to each row by the list/detail query handler and
660
+ * nameable as a column in a declarative `entityList`. See DerivedFieldDef.
661
+ */
662
+ readonly derivedFields?: DerivedFieldsMap;
616
663
  };
@@ -86,6 +86,10 @@ export type {
86
86
  DateFieldDef,
87
87
  DecimalFieldDef,
88
88
  DefaultCurrency,
89
+ DeriveContext,
90
+ DerivedFieldDef,
91
+ DerivedFieldsMap,
92
+ DerivedValueType,
89
93
  EmbeddedFieldDef,
90
94
  EmbeddedSubFieldDef,
91
95
  EntityDefinition,
@@ -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
+ }
@@ -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,