@cosmicdrift/kumiko-framework 0.94.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.94.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.94.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
  },
@@ -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.
@@ -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