@cosmicdrift/kumiko-framework 0.93.0 → 0.95.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.93.0",
3
+ "version": "0.95.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.93.0",
184
+ "@cosmicdrift/kumiko-dispatcher-live": "0.95.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
+ });
@@ -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,
@@ -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
  };
@@ -7,7 +7,7 @@ import type { Logger } from "../../logging/types";
7
7
  import type { Meter, MetricsHandle, Tracer } from "../../observability/types";
8
8
  import type { EntityCache } from "../../pipeline/entity-cache";
9
9
  import type { SearchAdapter } from "../../search/types";
10
- import type { TzContext } from "../../time";
10
+ import type { GeoTzProvider, TzContext } from "../../time";
11
11
  import type { ConfigAccessor, ConfigAccessorFactory, ConfigResolver } from "./config";
12
12
  import type { KumikoEventTypeMap } from "./event-type-map";
13
13
 
@@ -288,6 +288,10 @@ export type AppContext = SharedContextFields & {
288
288
  readonly triggerName?: string;
289
289
  readonly _userId?: string | undefined;
290
290
  readonly _handlerType?: string | undefined;
291
+ /** Optionaler Geo→Zone-Adapter. Wenn gesetzt (via buildServer-context oder
292
+ * runProdApp/runDevApp extraContext), reicht der Dispatcher ihn an
293
+ * ctx.tz.fromCoordinates / fromAddress weiter. Ohne Provider werfen diese. */
294
+ readonly geoTzProvider?: GeoTzProvider;
291
295
  /** Tenant des aktuellen Pipeline-Calls. Wird vom Dispatcher beim Bauen
292
296
  * des HandlerContext aus `user.tenantId` gespiegelt, damit lifecycle-
293
297
  * pipeline + system-hooks den Wert ohne Zugriff auf user-Object haben.
@@ -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,
@@ -544,8 +544,12 @@ export function createDispatcher(
544
544
  // `context` can be overridden, but we want the authoritative registry
545
545
  // from the dispatcher's own closure to win.
546
546
  // ctx.tz ist immer da. Tenant + User-Defaults kommen aus dem
547
- // SessionUser sobald die Felder existieren — bis dahin "UTC".
548
- const tz = createTzContext();
547
+ // SessionUser sobald die Felder existieren — bis dahin "UTC". Ein
548
+ // app-injizierter GeoTzProvider (context.geoTzProvider) speist
549
+ // ctx.tz.fromCoordinates / fromAddress.
550
+ const tz = createTzContext(
551
+ context.geoTzProvider !== undefined ? { geoTz: context.geoTzProvider } : {},
552
+ );
549
553
 
550
554
  return {
551
555
  ...context,
@@ -0,0 +1,65 @@
1
+ // ctx.tz.fromCoordinates / fromAddress — der GeoTzProvider-Injection-Seam.
2
+ // Das Framework liefert nur Interface + Delegation; ohne Provider wird klar
3
+ // geworfen statt still zu raten.
4
+
5
+ import { beforeAll, describe, expect, test } from "bun:test";
6
+ import type { GeoTzProvider } from "../geo-tz";
7
+ import { ensureTemporalPolyfill } from "../polyfill";
8
+ import { createTzContext } from "../tz-context";
9
+
10
+ beforeAll(async () => {
11
+ await ensureTemporalPolyfill();
12
+ });
13
+
14
+ const BERLIN = { latitude: 52.52, longitude: 13.405 };
15
+
16
+ describe("ctx.tz — GeoTzProvider seam", () => {
17
+ test("fromCoordinates delegiert an den Provider (sync)", async () => {
18
+ const provider: GeoTzProvider = { fromCoordinates: () => "Europe/Berlin" };
19
+ const tz = createTzContext({ geoTz: provider });
20
+ expect(await tz.fromCoordinates(BERLIN)).toBe("Europe/Berlin");
21
+ });
22
+
23
+ test("fromCoordinates delegiert an den Provider (async)", async () => {
24
+ const provider: GeoTzProvider = { fromCoordinates: async () => "Asia/Tokyo" };
25
+ const tz = createTzContext({ geoTz: provider });
26
+ expect(await tz.fromCoordinates({ latitude: 35.68, longitude: 139.69 })).toBe("Asia/Tokyo");
27
+ });
28
+
29
+ test("fromCoordinates reicht die Koordinaten unverändert durch", async () => {
30
+ let seen: { latitude: number; longitude: number } | undefined;
31
+ const provider: GeoTzProvider = {
32
+ fromCoordinates: (c) => {
33
+ seen = c;
34
+ return "Europe/Berlin";
35
+ },
36
+ };
37
+ await createTzContext({ geoTz: provider }).fromCoordinates(BERLIN);
38
+ expect(seen).toEqual(BERLIN);
39
+ });
40
+
41
+ test("fromCoordinates wirft ohne Provider", async () => {
42
+ const tz = createTzContext();
43
+ await expect(tz.fromCoordinates(BERLIN)).rejects.toThrow(/GeoTzProvider/);
44
+ });
45
+
46
+ test("fromAddress delegiert wenn der Provider es unterstützt", async () => {
47
+ const provider: GeoTzProvider = {
48
+ fromCoordinates: () => "UTC",
49
+ fromAddress: (a) => (a.country === "PT" ? "Europe/Lisbon" : "UTC"),
50
+ };
51
+ const tz = createTzContext({ geoTz: provider });
52
+ expect(await tz.fromAddress({ country: "PT" })).toBe("Europe/Lisbon");
53
+ });
54
+
55
+ test("fromAddress wirft wenn der Provider kein fromAddress hat (offline lat/lng)", async () => {
56
+ const provider: GeoTzProvider = { fromCoordinates: () => "UTC" };
57
+ const tz = createTzContext({ geoTz: provider });
58
+ await expect(tz.fromAddress({ country: "PT" })).rejects.toThrow(/fromAddress/);
59
+ });
60
+
61
+ test("fromAddress wirft ohne Provider", async () => {
62
+ const tz = createTzContext();
63
+ await expect(tz.fromAddress({ country: "PT" })).rejects.toThrow(/GeoTzProvider/);
64
+ });
65
+ });
@@ -0,0 +1,32 @@
1
+ // GeoTzProvider — optionaler Adapter Geo-Position → IANA-Zone. Das Framework
2
+ // definiert NUR das Interface + den Injection-Seam (ctx.tz.fromCoordinates /
3
+ // fromAddress); eine konkrete Implementation kommt aus einem separaten Paket
4
+ // (z.B. ein geo-tz-basiertes Offline-Paket). v1-Default: kein Provider — die
5
+ // fromCoordinates/fromAddress-Methoden werfen klar statt still falsch zu raten.
6
+ //
7
+ // `fromCoordinates` ist die PRIMÄRE Methode: Offline-geo-tz-Libs lösen
8
+ // lat/lng → Zone (genau, offline, kostenlos) — sie kennen keine Postadressen.
9
+ // `fromAddress` ist optional, für Provider die eine Geocoding-API (Adresse →
10
+ // Zone, online) anbinden. Diese Trennung hält das Interface kompatibel mit
11
+ // beiden Provider-Klassen, statt eine Adress-Form zu erzwingen die der
12
+ // Offline-Fall gar nicht bedienen kann.
13
+
14
+ export type GeoCoordinates = {
15
+ readonly latitude: number;
16
+ readonly longitude: number;
17
+ };
18
+
19
+ export type GeoAddress = {
20
+ readonly street?: string;
21
+ readonly city?: string;
22
+ readonly region?: string;
23
+ readonly postalCode?: string;
24
+ readonly country?: string;
25
+ };
26
+
27
+ export type GeoTzProvider = {
28
+ /** Geo-Koordinaten → IANA-Zone (offline lat/lng → tz). */
29
+ readonly fromCoordinates: (coords: GeoCoordinates) => string | Promise<string>;
30
+ /** Optional: Postadresse → IANA-Zone (Geocoding-API-Provider). */
31
+ readonly fromAddress?: (address: GeoAddress) => string | Promise<string>;
32
+ };
package/src/time/index.ts CHANGED
@@ -7,11 +7,10 @@
7
7
  // - LocatedTimestampJson: API-Boundary-Form { at, tz }
8
8
  // - isValidIanaTimeZone: IANA-Zonennamen-Validierung (type:"tz"-Felder)
9
9
  // - warnIfNonUtcServerTimeZone: Boot-Warnung bei nicht-UTC Prozess-TZ
10
- //
11
- // Kommt:
12
- // - UI-Komponenten <DateTimeInput>, <LocatedDateTimePicker>, <DateInput>
10
+ // - GeoTzProvider: optionaler Geo→Zone-Adapter (ctx.tz.fromCoordinates/fromAddress)
13
11
 
14
12
  export { warnIfNonUtcServerTimeZone } from "./boot-tz-warning";
13
+ export type { GeoAddress, GeoCoordinates, GeoTzProvider } from "./geo-tz";
15
14
  export { isValidIanaTimeZone } from "./iana";
16
15
  export { ensureTemporalPolyfill, getTemporal } from "./polyfill";
17
16
  export {
@@ -14,6 +14,7 @@
14
14
  // Stand: beide default auf "UTC" — sobald tenant.timezone +
15
15
  // user.timezone Felder existieren, lese ich sie aus dem Request-Context.
16
16
 
17
+ import type { GeoAddress, GeoCoordinates, GeoTzProvider } from "./geo-tz";
17
18
  import { ensureTemporalPolyfill, getTemporal } from "./polyfill";
18
19
 
19
20
  // JSON-Form für Wall-Clock+TZ — siehe locatedTimestamp(name) Helper in
@@ -52,6 +53,14 @@ export type TzContext = {
52
53
 
53
54
  /** JSON-Pair { at, tz } → ZonedDateTime (Wall-Clock + IANA). */
54
55
  fromLocatedJson(obj: LocatedTimestampJson): Temporal.ZonedDateTime;
56
+
57
+ /** Geo-Koordinaten → IANA-Zone via konfiguriertem GeoTzProvider.
58
+ * Wirft wenn kein Provider konfiguriert ist (v1-Default). */
59
+ fromCoordinates(coords: GeoCoordinates): Promise<string>;
60
+ /** Postadresse → IANA-Zone via GeoTzProvider. Wirft wenn kein Provider
61
+ * konfiguriert ist ODER der Provider kein fromAddress unterstützt (der
62
+ * Offline-lat/lng-Provider tut das nicht). */
63
+ fromAddress(address: GeoAddress): Promise<string>;
55
64
  };
56
65
 
57
66
  export type TzContextOptions = {
@@ -59,6 +68,9 @@ export type TzContextOptions = {
59
68
  readonly tenant?: string;
60
69
  /** User-Override. Default = tenant. */
61
70
  readonly user?: string;
71
+ /** Optionaler Geo→Zone-Adapter für ctx.tz.fromCoordinates / fromAddress.
72
+ * Ohne Provider werfen diese Methoden. */
73
+ readonly geoTz?: GeoTzProvider;
62
74
  };
63
75
 
64
76
  /**
@@ -70,6 +82,7 @@ export function createTzContext(options: TzContextOptions = {}): TzContext {
70
82
  const T = getTemporal();
71
83
  const tenant = options.tenant ?? "UTC";
72
84
  const user = options.user ?? tenant;
85
+ const geoTz = options.geoTz;
73
86
 
74
87
  return {
75
88
  tenant,
@@ -93,6 +106,22 @@ export function createTzContext(options: TzContextOptions = {}): TzContext {
93
106
  tz: zdt.timeZoneId,
94
107
  }),
95
108
  fromLocatedJson: (obj) => T.PlainDateTime.from(obj.at).toZonedDateTime(obj.tz),
109
+ fromCoordinates: async (coords) => {
110
+ if (geoTz === undefined) {
111
+ throw new Error(
112
+ "ctx.tz.fromCoordinates requires a GeoTzProvider — inject one via the app context (e.g. buildServer({ context: { geoTzProvider } }) or runProdApp({ extraContext: { geoTzProvider } })) or install a provider package.",
113
+ );
114
+ }
115
+ return geoTz.fromCoordinates(coords);
116
+ },
117
+ fromAddress: async (address) => {
118
+ if (geoTz?.fromAddress === undefined) {
119
+ throw new Error(
120
+ "ctx.tz.fromAddress requires a GeoTzProvider that implements fromAddress (geocoding). Offline lat/lng providers only support fromCoordinates.",
121
+ );
122
+ }
123
+ return geoTz.fromAddress(address);
124
+ },
96
125
  };
97
126
  }
98
127