@cosmicdrift/kumiko-framework 0.31.0 → 0.32.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 +1 -1
- package/src/db/__tests__/table-builder-meta-lockstep.test.ts +62 -0
- package/src/db/dialect.ts +4 -1
- package/src/db/table-builder.ts +10 -3
- package/src/engine/__tests__/boot-validator.test.ts +18 -0
- package/src/engine/boot-validator/screens-nav.ts +13 -0
- package/src/engine/types/screen.ts +12 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.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>",
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { asEntityTableMeta } from "../../bun-db/query";
|
|
3
|
+
import { createEntity } from "../../engine/factories";
|
|
4
|
+
import type { ColumnMeta, IndexMeta } from "../entity-table-meta";
|
|
5
|
+
import { buildEntityTableMeta } from "../entity-table-meta";
|
|
6
|
+
import { buildEntityTable } from "../table-builder";
|
|
7
|
+
|
|
8
|
+
// Lock-step-Guard: buildEntityTable (Runtime-/Test-Stack-Pfad, Meta am
|
|
9
|
+
// KUMIKO_META_SYMBOL) und buildEntityTableMeta (Migrations-Pfad) müssen
|
|
10
|
+
// für dieselbe EntityDefinition identische Spalten + Indexes produzieren.
|
|
11
|
+
// Drift hier = Migration und Prod-Tabelle (bzw. collectTableMetas-Output)
|
|
12
|
+
// gehen auseinander — gefunden als #255-Follow-up: select/number/bigInt
|
|
13
|
+
// verloren ihre deklarierten defaults auf dem Builder-Pfad.
|
|
14
|
+
|
|
15
|
+
const entityWithDefaults = createEntity({
|
|
16
|
+
table: "read_lockstep_probe",
|
|
17
|
+
fields: {
|
|
18
|
+
title: { type: "text", required: true, default: "untitled" },
|
|
19
|
+
active: { type: "boolean", default: true },
|
|
20
|
+
status: { type: "select", options: ["open", "done"], required: true, default: "open" },
|
|
21
|
+
tags: { type: "multiSelect", options: ["a", "b"] },
|
|
22
|
+
attempt: { type: "number", required: true, default: 1 },
|
|
23
|
+
bytes: { type: "bigInt", default: 0 },
|
|
24
|
+
price: { type: "money" },
|
|
25
|
+
meta: { type: "embedded", fields: {} },
|
|
26
|
+
startedAt: { type: "timestamp", required: true },
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function byName<T extends { name: string }>(items: readonly T[]): readonly T[] {
|
|
31
|
+
return [...items].sort((a, b) => a.name.localeCompare(b.name));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("buildEntityTable ↔ buildEntityTableMeta lock-step", () => {
|
|
35
|
+
const fromBuilder = asEntityTableMeta(buildEntityTable("lockstepProbe", entityWithDefaults));
|
|
36
|
+
const fromMeta = buildEntityTableMeta("lockstepProbe", entityWithDefaults);
|
|
37
|
+
|
|
38
|
+
test("builder table carries an EntityTableMeta", () => {
|
|
39
|
+
expect(fromBuilder).toBeDefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("identical columns (incl. declared defaults)", () => {
|
|
43
|
+
expect(byName<ColumnMeta>(fromBuilder?.columns ?? [])).toEqual(
|
|
44
|
+
byName<ColumnMeta>(fromMeta.columns),
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("identical indexes", () => {
|
|
49
|
+
expect(byName<IndexMeta>(fromBuilder?.indexes ?? [])).toEqual(
|
|
50
|
+
byName<IndexMeta>(fromMeta.indexes),
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("declared defaults survive the builder path", () => {
|
|
55
|
+
const cols = new Map((fromBuilder?.columns ?? []).map((c) => [c.name, c]));
|
|
56
|
+
expect(cols.get("status")?.defaultSql).toBe("'open'");
|
|
57
|
+
expect(cols.get("attempt")?.defaultSql).toBe("1");
|
|
58
|
+
expect(cols.get("bytes")?.defaultSql).toBe("0");
|
|
59
|
+
expect(cols.get("title")?.defaultSql).toBe("'untitled'");
|
|
60
|
+
expect(cols.get("active")?.defaultSql).toBe("true");
|
|
61
|
+
});
|
|
62
|
+
});
|
package/src/db/dialect.ts
CHANGED
|
@@ -272,8 +272,11 @@ export function instant(
|
|
|
272
272
|
}
|
|
273
273
|
|
|
274
274
|
// moneyAmount kept as a customType-style API but produces a bigint column.
|
|
275
|
+
// bigintJsMode "bigint" — money cents must round-trip as JS bigint (lock-step
|
|
276
|
+
// with entity-table-meta's money rendering; without it bun-db reads the
|
|
277
|
+
// column as number and loses precision past 2^53).
|
|
275
278
|
export const moneyAmount = (name: string): ColumnBuilder<number> =>
|
|
276
|
-
buildColumn(name, "bigint") as ColumnBuilder<number>;
|
|
279
|
+
buildColumn(name, "bigint", { bigintJsMode: "bigint" }) as ColumnBuilder<number>;
|
|
277
280
|
|
|
278
281
|
// ---- Index + primaryKey helpers ----
|
|
279
282
|
|
package/src/db/table-builder.ts
CHANGED
|
@@ -80,7 +80,12 @@ function fieldToColumns(
|
|
|
80
80
|
: boolean(snakeName),
|
|
81
81
|
};
|
|
82
82
|
case "select": {
|
|
83
|
-
|
|
83
|
+
// default() durchreichen — entity-table-meta rendert deklarierte
|
|
84
|
+
// defaults, dieser Builder MUSS lock-step bleiben (sonst trägt das
|
|
85
|
+
// Meta am buildEntityTable-Objekt eine andere Spalte als die
|
|
86
|
+
// Migration, #255-Follow-up-Befund).
|
|
87
|
+
const base = text(snakeName);
|
|
88
|
+
const col = field.default !== undefined ? base.default(field.default) : base;
|
|
84
89
|
return { [name]: field.required ? col.notNull() : col };
|
|
85
90
|
}
|
|
86
91
|
case "multiSelect":
|
|
@@ -95,7 +100,8 @@ function fieldToColumns(
|
|
|
95
100
|
// multi-select.
|
|
96
101
|
return { [name]: jsonb(snakeName).default([]).notNull() };
|
|
97
102
|
case "number": {
|
|
98
|
-
const
|
|
103
|
+
const base = integer(snakeName);
|
|
104
|
+
const col = field.default !== undefined ? base.default(field.default) : base;
|
|
99
105
|
return { [name]: field.required ? col.notNull() : col };
|
|
100
106
|
}
|
|
101
107
|
case "bigInt": {
|
|
@@ -104,7 +110,8 @@ function fieldToColumns(
|
|
|
104
110
|
// JS-`bigint` — JSON-serialisierbar, Frontend-tauglich. Wer >2^53
|
|
105
111
|
// braucht (Astronomie-Astronomie), nutzt einen Text-Field mit
|
|
106
112
|
// eigenem Codec.
|
|
107
|
-
const
|
|
113
|
+
const base = bigint(snakeName, { mode: "number" });
|
|
114
|
+
const col = field.default !== undefined ? base.default(field.default) : base;
|
|
108
115
|
return { [name]: field.required ? col.notNull() : col };
|
|
109
116
|
}
|
|
110
117
|
case "reference":
|
|
@@ -1287,6 +1287,7 @@ describe("boot-validator", () => {
|
|
|
1287
1287
|
readonly fields: readonly string[];
|
|
1288
1288
|
}>;
|
|
1289
1289
|
readonly redirect?: string;
|
|
1290
|
+
readonly cancelTarget?: string | false;
|
|
1290
1291
|
readonly extraScreens?: readonly string[];
|
|
1291
1292
|
};
|
|
1292
1293
|
|
|
@@ -1318,6 +1319,7 @@ describe("boot-validator", () => {
|
|
|
1318
1319
|
fields: fields as never,
|
|
1319
1320
|
layout: { sections: sections as never },
|
|
1320
1321
|
...(override.redirect !== undefined && { redirect: override.redirect }),
|
|
1322
|
+
...(override.cancelTarget !== undefined && { cancelTarget: override.cancelTarget }),
|
|
1321
1323
|
});
|
|
1322
1324
|
for (const extra of override.extraScreens ?? []) {
|
|
1323
1325
|
r.screen({
|
|
@@ -1387,6 +1389,22 @@ describe("boot-validator", () => {
|
|
|
1387
1389
|
);
|
|
1388
1390
|
});
|
|
1389
1391
|
|
|
1392
|
+
test("cancelTarget → existing screen-id → kein Throw", () => {
|
|
1393
|
+
expect(() =>
|
|
1394
|
+
validateBoot([makeFeature({ cancelTarget: "after-form", extraScreens: ["after-form"] })]),
|
|
1395
|
+
).not.toThrow();
|
|
1396
|
+
});
|
|
1397
|
+
|
|
1398
|
+
test("cancelTarget → unknown screen-id → Throw", () => {
|
|
1399
|
+
expect(() => validateBoot([makeFeature({ cancelTarget: "ghost-screen" })])).toThrow(
|
|
1400
|
+
/cancelTarget "ghost-screen" does not resolve to a registered screen/,
|
|
1401
|
+
);
|
|
1402
|
+
});
|
|
1403
|
+
|
|
1404
|
+
test("cancelTarget=false (Button abgeschaltet) → kein Throw", () => {
|
|
1405
|
+
expect(() => validateBoot([makeFeature({ cancelTarget: false })])).not.toThrow();
|
|
1406
|
+
});
|
|
1407
|
+
|
|
1390
1408
|
test("extension section ohne component → Throw (Parität zu entityEdit)", () => {
|
|
1391
1409
|
// synthesizeActionFormScreen reicht die layout 1:1 an RenderEdit weiter —
|
|
1392
1410
|
// eine Extension-Section ohne react/native-Marker rendert sonst stumm leer.
|
|
@@ -210,6 +210,19 @@ export function validateScreens(
|
|
|
210
210
|
);
|
|
211
211
|
}
|
|
212
212
|
}
|
|
213
|
+
if (typeof screen.cancelTarget === "string") {
|
|
214
|
+
// Gleiche Regel wie redirect — `false` (kein Cancel-Button)
|
|
215
|
+
// braucht keine Validierung.
|
|
216
|
+
const candidateQn = qualifyEntityName(feature.name, "screen", screen.cancelTarget);
|
|
217
|
+
if (!allScreenQns.has(candidateQn)) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
`[Feature ${feature.name}] Screen "${screenId}" (actionForm) cancelTarget "${screen.cancelTarget}" ` +
|
|
220
|
+
`does not resolve to a registered screen in this feature. Known screens: ${
|
|
221
|
+
[...Object.keys(feature.screens)].sort().join(", ") || "(none)"
|
|
222
|
+
}.`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
213
226
|
continue;
|
|
214
227
|
}
|
|
215
228
|
|
|
@@ -177,6 +177,11 @@ export type RowActionNavigate = {
|
|
|
177
177
|
/** Screen-id (kurz, unqualified) zu dem navigiert wird. Boot-
|
|
178
178
|
* Validator prüft Existenz im selben Feature. */
|
|
179
179
|
readonly screen: string;
|
|
180
|
+
/** Optional: Entity-Id für entityEdit-Targets — landet als Pfad-
|
|
181
|
+
* Segment (`/<workspace>/<screen>/<entityId>`). entityEdit liest die
|
|
182
|
+
* Id AUSSCHLIESSLICH aus dem Pfad; ein `?id=`-Search-Param öffnet
|
|
183
|
+
* den Create-Mode. ⚠️ Function-Form nur im Monolith-Bundle-Pattern. */
|
|
184
|
+
readonly entityId?: (row: Readonly<Record<string, unknown>>) => string;
|
|
180
185
|
/** Optional: URL-Search-Params aus row-Context. Wird in actionForm-
|
|
181
186
|
* Targets als initial values gelesen ("Edit Customer X" → URL hat
|
|
182
187
|
* `?customerId=row-uuid`, actionForm initial values pre-fillen).
|
|
@@ -359,6 +364,13 @@ export type ActionFormScreenDefinition = {
|
|
|
359
364
|
* Wenn nicht gesetzt, bleibt der User auf dem Form-Screen. Boot-
|
|
360
365
|
* Validator prüft dass die ID einen registrierten Screen meint. */
|
|
361
366
|
readonly redirect?: string;
|
|
367
|
+
/** Ziel des Abbrechen-Buttons. Default: `redirect` (historisches
|
|
368
|
+
* Verhalten — Cancel und Submit-Redirect landen dann am selben Ort).
|
|
369
|
+
* `false` = kein Abbrechen-Button; richtig für Single-Action-Screens
|
|
370
|
+
* ohne verwerfbaren Zustand (z.B. "Test-Mail senden"), wo Abbrechen
|
|
371
|
+
* nur ein zweiter Weg zum selben Ziel wäre. Boot-Validator prüft
|
|
372
|
+
* String-Targets wie `redirect`. */
|
|
373
|
+
readonly cancelTarget?: string | false;
|
|
362
374
|
readonly slots?: ScreenSlots;
|
|
363
375
|
readonly access?: AccessRule;
|
|
364
376
|
};
|