@cosmicdrift/kumiko-framework 0.93.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.93.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.93.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
+ });
@@ -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
  };
@@ -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,