@cosmicdrift/kumiko-framework 0.94.0 → 0.96.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.
|
|
3
|
+
"version": "0.96.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.96.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
|
-
|
|
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 {
|
package/src/time/tz-context.ts
CHANGED
|
@@ -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
|
|