@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 +2 -2
- package/src/__tests__/derived-fields.integration.test.ts +126 -0
- package/src/engine/__tests__/schema-builder.test.ts +27 -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/schema-builder.ts +6 -6
- package/src/engine/types/fields.ts +47 -0
- package/src/engine/types/index.ts +4 -0
- package/src/time/__tests__/boot-tz-warning.test.ts +24 -0
- package/src/time/__tests__/iana.test.ts +33 -0
- package/src/time/__tests__/tz-dateline.test.ts +36 -0
- package/src/time/boot-tz-warning.ts +26 -0
- package/src/time/iana.ts +17 -0
- package/src/time/index.ts +4 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "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.
|
|
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 (!
|
|
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,
|
|
@@ -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
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
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().
|
|
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
|
};
|
|
@@ -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
|
+
}
|
package/src/time/iana.ts
ADDED
|
@@ -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,
|