@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 +2 -2
- package/src/__tests__/derived-fields.integration.test.ts +126 -0
- package/src/engine/__tests__/screen.test.ts +43 -1
- package/src/engine/boot-validator/screens-nav.ts +10 -2
- package/src/engine/entity-handlers.ts +39 -9
- package/src/engine/factories.ts +13 -0
- package/src/engine/index.ts +5 -0
- package/src/engine/types/fields.ts +47 -0
- package/src/engine/types/handlers.ts +5 -1
- package/src/engine/types/index.ts +4 -0
- package/src/pipeline/dispatcher.ts +6 -2
- package/src/time/__tests__/geo-tz.test.ts +65 -0
- package/src/time/geo-tz.ts +32 -0
- package/src/time/index.ts +2 -3
- package/src/time/tz-context.ts +29 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "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.
|
|
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 (!
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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":
|
package/src/engine/factories.ts
CHANGED
|
@@ -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,
|
package/src/engine/index.ts
CHANGED
|
@@ -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.
|
|
@@ -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
|
|