@cosmicdrift/kumiko-framework 0.176.2 → 0.178.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 +3 -3
- package/src/changes.json +7 -0
- package/src/db/__tests__/compound-types.test.ts +4 -2
- package/src/db/__tests__/money.test.ts +38 -29
- package/src/db/money.ts +34 -11
- package/src/engine/__tests__/content-collection.test.ts +212 -0
- package/src/engine/build-app-schema.ts +10 -0
- package/src/engine/define-feature.ts +1 -0
- package/src/engine/feature-builder-state.ts +3 -1
- package/src/engine/feature-ui-extensions.ts +36 -1
- package/src/engine/index.ts +1 -0
- package/src/engine/types/index.ts +1 -1
- package/src/ui-types/app-schema.ts +13 -1
- package/src/ui-types/index.ts +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.178.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>",
|
|
@@ -182,7 +182,7 @@
|
|
|
182
182
|
"./package.json": "./package.json"
|
|
183
183
|
},
|
|
184
184
|
"dependencies": {
|
|
185
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
185
|
+
"@cosmicdrift/kumiko-types": "0.178.0",
|
|
186
186
|
"bullmq": "^5.76.7",
|
|
187
187
|
"bun-types": "^1.3.13",
|
|
188
188
|
"hono": "^4.12.27",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"zod": "^4.4.3"
|
|
199
199
|
},
|
|
200
200
|
"devDependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.178.0",
|
|
202
202
|
"bun-types": "^1.3.13",
|
|
203
203
|
"pino-pretty": "^13.1.3"
|
|
204
204
|
},
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.177.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "createMoneyField's amount now converts to/from minor-unit BIGINT storage (fw#1767).",
|
|
6
|
+
"detail": "flattenMoney/rehydrateMoney used to pass the API amount straight into the BIGINT column without the minor-unit (cents) conversion the column's own doc comment always claimed. A decimal amount (e.g. 56799.16) crashed the insert (float into bigint); a plain integer major-unit amount (e.g. 45000 meaning €450.00) was silently stored as 45000 minor units — 100× too small on read-back.",
|
|
7
|
+
"migration": "amount is now always major units (ordinary decimal, e.g. 56799.16) on both write and read — DB storage stays exact-integer cents automatically, no caller change needed for that direction. If you already wrote createMoneyField data under the old (unconverted) semantics, multiply stored amounts by 100 before upgrading, or reconcile after — no known production deployment currently persists money-typed data (verified solon and phronexsis are both pre-launch before this merged)."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.167.0",
|
|
4
11
|
"type": "breaking",
|
|
@@ -49,7 +49,8 @@ describe("flattenCompoundTypes — Pipeline", () => {
|
|
|
49
49
|
expect(flat["label"]).toBe("ACME");
|
|
50
50
|
expect((flat["pickupUtc"] as Temporal.Instant).toString()).toBe("2026-04-15T09:00:00Z");
|
|
51
51
|
expect(flat["pickupTz"]).toBe("Europe/Lisbon");
|
|
52
|
-
|
|
52
|
+
// flattenMoney converts major units (API) → minor units (DB, ×100).
|
|
53
|
+
expect(flat["buyingPrice"]).toBe(4_500_000);
|
|
53
54
|
expect(flat["buyingPriceCurrency"]).toBe("EUR");
|
|
54
55
|
});
|
|
55
56
|
|
|
@@ -75,7 +76,7 @@ describe("rehydrateCompoundTypes — Pipeline", () => {
|
|
|
75
76
|
label: "ACME",
|
|
76
77
|
pickupUtc: "2026-04-15T09:00:00Z",
|
|
77
78
|
pickupTz: "Europe/Lisbon",
|
|
78
|
-
buyingPrice:
|
|
79
|
+
buyingPrice: 4_500_000,
|
|
79
80
|
buyingPriceCurrency: "EUR",
|
|
80
81
|
},
|
|
81
82
|
mixedEntity,
|
|
@@ -83,6 +84,7 @@ describe("rehydrateCompoundTypes — Pipeline", () => {
|
|
|
83
84
|
expect(out).toEqual({
|
|
84
85
|
label: "ACME",
|
|
85
86
|
pickup: { at: "2026-04-15T10:00:00", tz: "Europe/Lisbon", utc: "2026-04-15T09:00:00Z" },
|
|
87
|
+
// rehydrateMoney converts minor units (DB) → major units (API, ÷100).
|
|
86
88
|
buyingPrice: { amount: 45_000, currency: "EUR" },
|
|
87
89
|
});
|
|
88
90
|
});
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
// Pure Unit-Tests für money flatten/rehydrate Helpers.
|
|
2
|
+
//
|
|
3
|
+
// Contract: API-form amount is MAJOR units (56799.16 EUR), DB-form is
|
|
4
|
+
// MINOR units (5679916 cents) — flattenMoney/rehydrateMoney convert at the
|
|
5
|
+
// boundary (×100 / ÷100). See money.ts's file header for why this exists.
|
|
2
6
|
|
|
3
7
|
import { describe, expect, test } from "bun:test";
|
|
4
8
|
import { createEntity, createMoneyField, createTextField } from "../../engine";
|
|
@@ -21,24 +25,29 @@ const usdEntity: EntityDefinition = createEntity({
|
|
|
21
25
|
},
|
|
22
26
|
});
|
|
23
27
|
|
|
24
|
-
describe("flattenMoney — Insert/Update Convert", () => {
|
|
25
|
-
test("{ amount, currency } → { <name>: amount, <name>Currency: currency }", () => {
|
|
26
|
-
const flat = flattenMoney({ buyingPrice: { amount:
|
|
28
|
+
describe("flattenMoney — Insert/Update Convert (major units → minor units)", () => {
|
|
29
|
+
test("{ amount, currency } → { <name>: amount*100, <name>Currency: currency }", () => {
|
|
30
|
+
const flat = flattenMoney({ buyingPrice: { amount: 450, currency: "EUR" } }, orderEntity);
|
|
27
31
|
expect(flat).toEqual({ buyingPrice: 45000, buyingPriceCurrency: "EUR" });
|
|
28
32
|
});
|
|
29
33
|
|
|
34
|
+
test("decimal amount rounds to the nearest cent", () => {
|
|
35
|
+
const flat = flattenMoney({ buyingPrice: { amount: 56799.16, currency: "EUR" } }, orderEntity);
|
|
36
|
+
expect(flat).toEqual({ buyingPrice: 5679916, buyingPriceCurrency: "EUR" });
|
|
37
|
+
});
|
|
38
|
+
|
|
30
39
|
test("primitive number (legacy) wird akzeptiert + entity.defaultCurrency angehängt", () => {
|
|
31
|
-
const flat = flattenMoney({ buyingPrice:
|
|
40
|
+
const flat = flattenMoney({ buyingPrice: 450 }, orderEntity);
|
|
32
41
|
expect(flat).toEqual({ buyingPrice: 45000, buyingPriceCurrency: "EUR" });
|
|
33
42
|
});
|
|
34
43
|
|
|
35
44
|
test("primitive number nutzt USD wenn entity.defaultCurrency = USD", () => {
|
|
36
|
-
const flat = flattenMoney({ fee:
|
|
45
|
+
const flat = flattenMoney({ fee: 1.99 }, usdEntity);
|
|
37
46
|
expect(flat).toEqual({ fee: 199, feeCurrency: "USD" });
|
|
38
47
|
});
|
|
39
48
|
|
|
40
49
|
test("expliziter <name>Currency im Payload überschreibt nicht", () => {
|
|
41
|
-
const flat = flattenMoney({ buyingPrice:
|
|
50
|
+
const flat = flattenMoney({ buyingPrice: 450, buyingPriceCurrency: "USD" }, orderEntity);
|
|
42
51
|
// Wenn bereits gesetzt, nicht überschreiben — caller-explicit gewinnt
|
|
43
52
|
expect(flat["buyingPriceCurrency"]).toBe("USD");
|
|
44
53
|
});
|
|
@@ -46,8 +55,8 @@ describe("flattenMoney — Insert/Update Convert", () => {
|
|
|
46
55
|
test("mehrere money-Felder am gleichen Object", () => {
|
|
47
56
|
const flat = flattenMoney(
|
|
48
57
|
{
|
|
49
|
-
buyingPrice: { amount:
|
|
50
|
-
sellingPrice: { amount:
|
|
58
|
+
buyingPrice: { amount: 450, currency: "EUR" },
|
|
59
|
+
sellingPrice: { amount: 600, currency: "USD" },
|
|
51
60
|
},
|
|
52
61
|
orderEntity,
|
|
53
62
|
);
|
|
@@ -61,7 +70,7 @@ describe("flattenMoney — Insert/Update Convert", () => {
|
|
|
61
70
|
|
|
62
71
|
test("andere Felder bleiben unverändert", () => {
|
|
63
72
|
const flat = flattenMoney(
|
|
64
|
-
{ label: "Premium", buyingPrice: { amount:
|
|
73
|
+
{ label: "Premium", buyingPrice: { amount: 1, currency: "EUR" } },
|
|
65
74
|
orderEntity,
|
|
66
75
|
);
|
|
67
76
|
expect(flat["label"]).toBe("Premium");
|
|
@@ -77,33 +86,33 @@ describe("flattenMoney — Insert/Update Convert", () => {
|
|
|
77
86
|
const noCurrencyEntity: EntityDefinition = createEntity({
|
|
78
87
|
fields: { fee: createMoneyField() },
|
|
79
88
|
});
|
|
80
|
-
const flat = flattenMoney({ fee:
|
|
89
|
+
const flat = flattenMoney({ fee: 0.5 }, noCurrencyEntity);
|
|
81
90
|
expect(flat["feeCurrency"]).toBe("EUR");
|
|
82
91
|
});
|
|
83
92
|
|
|
84
93
|
test("ist pure — input wird nicht mutiert", () => {
|
|
85
|
-
const input = { buyingPrice: { amount:
|
|
94
|
+
const input = { buyingPrice: { amount: 450, currency: "EUR" } };
|
|
86
95
|
const before = JSON.stringify(input);
|
|
87
96
|
flattenMoney(input, orderEntity);
|
|
88
97
|
expect(JSON.stringify(input)).toBe(before);
|
|
89
98
|
});
|
|
90
99
|
});
|
|
91
100
|
|
|
92
|
-
describe("rehydrateMoney — Read Convert", () => {
|
|
93
|
-
test("{ <name>:
|
|
101
|
+
describe("rehydrateMoney — Read Convert (minor units → major units)", () => {
|
|
102
|
+
test("{ <name>: minorUnits, <name>Currency: string } → { <name>: { amount: majorUnits, currency } }", () => {
|
|
94
103
|
const out = rehydrateMoney({ buyingPrice: 45000, buyingPriceCurrency: "EUR" }, orderEntity);
|
|
95
|
-
expect(out).toEqual({ buyingPrice: { amount:
|
|
104
|
+
expect(out).toEqual({ buyingPrice: { amount: 450, currency: "EUR" } });
|
|
96
105
|
});
|
|
97
106
|
|
|
98
107
|
test("PG-BIGINT als String wird zu number gecastet", () => {
|
|
99
108
|
// Postgres-driver liefert BIGINT manchmal als String (>2^53 sicher).
|
|
100
109
|
const out = rehydrateMoney({ buyingPrice: "45000", buyingPriceCurrency: "EUR" }, orderEntity);
|
|
101
|
-
expect(out["buyingPrice"]).toEqual({ amount:
|
|
110
|
+
expect(out["buyingPrice"]).toEqual({ amount: 450, currency: "EUR" });
|
|
102
111
|
});
|
|
103
112
|
|
|
104
113
|
test("fehlende Currency-Spalte fällt auf entity.defaultCurrency", () => {
|
|
105
114
|
const out = rehydrateMoney({ buyingPrice: 45000 }, orderEntity);
|
|
106
|
-
expect(out["buyingPrice"]).toEqual({ amount:
|
|
115
|
+
expect(out["buyingPrice"]).toEqual({ amount: 450, currency: "EUR" });
|
|
107
116
|
});
|
|
108
117
|
|
|
109
118
|
test("null/undefined amount → Field wird aus Output entfernt", () => {
|
|
@@ -122,25 +131,25 @@ describe("rehydrateMoney — Read Convert", () => {
|
|
|
122
131
|
orderEntity,
|
|
123
132
|
);
|
|
124
133
|
expect(out).toEqual({
|
|
125
|
-
buyingPrice: { amount:
|
|
126
|
-
sellingPrice: { amount:
|
|
134
|
+
buyingPrice: { amount: 450, currency: "EUR" },
|
|
135
|
+
sellingPrice: { amount: 600, currency: "USD" },
|
|
127
136
|
});
|
|
128
137
|
});
|
|
129
138
|
|
|
130
|
-
test("Round-Trip: flatten dann rehydrate ergibt dasselbe", () => {
|
|
139
|
+
test("Round-Trip: flatten dann rehydrate ergibt dasselbe, inkl. Cents", () => {
|
|
131
140
|
const original = {
|
|
132
|
-
buyingPrice: { amount:
|
|
133
|
-
sellingPrice: { amount:
|
|
141
|
+
buyingPrice: { amount: 450.5, currency: "EUR" },
|
|
142
|
+
sellingPrice: { amount: 56799.16, currency: "USD" },
|
|
134
143
|
};
|
|
135
144
|
const flat = flattenMoney(original, orderEntity);
|
|
136
145
|
const rehydrated = rehydrateMoney(flat, orderEntity);
|
|
137
146
|
expect(rehydrated).toEqual(original);
|
|
138
147
|
});
|
|
139
148
|
|
|
140
|
-
test("Round-Trip primitive-Insert: flatten(
|
|
141
|
-
const flat = flattenMoney({ buyingPrice:
|
|
149
|
+
test("Round-Trip primitive-Insert: flatten(450) → rehydrate → { amount:450, currency:EUR }", () => {
|
|
150
|
+
const flat = flattenMoney({ buyingPrice: 450 }, orderEntity);
|
|
142
151
|
const out = rehydrateMoney(flat, orderEntity);
|
|
143
|
-
expect(out["buyingPrice"]).toEqual({ amount:
|
|
152
|
+
expect(out["buyingPrice"]).toEqual({ amount: 450, currency: "EUR" });
|
|
144
153
|
});
|
|
145
154
|
|
|
146
155
|
test("ist pure — input wird nicht mutiert", () => {
|
|
@@ -166,13 +175,13 @@ describe("rehydrateMoney — Read Convert", () => {
|
|
|
166
175
|
describe("Round-Trip im Update-Pfad (Helper-Verkettung wie im Executor)", () => {
|
|
167
176
|
test("Update-Changes-Payload mit money geht durch flatten + zurück durch rehydrate", () => {
|
|
168
177
|
// Simuliert was der Executor macht: changes → flatten → DB → rehydrate
|
|
169
|
-
const changes = { buyingPrice: { amount:
|
|
178
|
+
const changes = { buyingPrice: { amount: 990, currency: "USD" } };
|
|
170
179
|
const flat = flattenMoney(changes, orderEntity);
|
|
171
180
|
expect(flat).toEqual({ buyingPrice: 99_000, buyingPriceCurrency: "USD" });
|
|
172
181
|
|
|
173
182
|
// DB liefert dieselben Spalten zurück
|
|
174
183
|
const out = rehydrateMoney(flat, orderEntity);
|
|
175
|
-
expect(out).toEqual({ buyingPrice: { amount:
|
|
184
|
+
expect(out).toEqual({ buyingPrice: { amount: 990, currency: "USD" } });
|
|
176
185
|
});
|
|
177
186
|
|
|
178
187
|
test("List-Pfad: mehrere Rows hintereinander rehydraten", () => {
|
|
@@ -183,9 +192,9 @@ describe("Round-Trip im Update-Pfad (Helper-Verkettung wie im Executor)", () =>
|
|
|
183
192
|
];
|
|
184
193
|
const apiRows = dbRows.map((r) => rehydrateMoney(r, orderEntity));
|
|
185
194
|
expect(apiRows).toEqual([
|
|
186
|
-
{ buyingPrice: { amount:
|
|
187
|
-
{ buyingPrice: { amount:
|
|
188
|
-
{ buyingPrice: { amount:
|
|
195
|
+
{ buyingPrice: { amount: 1, currency: "EUR" } },
|
|
196
|
+
{ buyingPrice: { amount: 2, currency: "USD" } },
|
|
197
|
+
{ buyingPrice: { amount: 3, currency: "GBP" } },
|
|
189
198
|
]);
|
|
190
199
|
});
|
|
191
200
|
});
|
package/src/db/money.ts
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
// Auto-Convert für money-Felder im DB-Layer.
|
|
2
2
|
//
|
|
3
3
|
// Vertrag (siehe auch db/located-timestamp.ts — gleicher Compound-Type-Pattern):
|
|
4
|
-
// API-Form: { amount, currency } | number (
|
|
5
|
-
// DB-Form: <name> BIGINT + <name>Currency TEXT
|
|
6
|
-
// Read-Form: { amount, currency }
|
|
4
|
+
// API-Form: { amount, currency } | number — amount in MAJOR units (56799.16 EUR)
|
|
5
|
+
// DB-Form: <name> BIGINT (minor units, e.g. cents) + <name>Currency TEXT
|
|
6
|
+
// Read-Form: { amount, currency } — amount in MAJOR units again
|
|
7
|
+
//
|
|
8
|
+
// table-builder.ts's moneyAmount column has always documented BIGINT as
|
|
9
|
+
// "the integer minor unit" — this file used to just pass the API amount
|
|
10
|
+
// through unconverted, silently violating that contract: a caller doing
|
|
11
|
+
// the ergonomic thing (passing 56799.16) got a float into a bigint column
|
|
12
|
+
// (driver error) or, worse, an integer major-unit amount (56799) got
|
|
13
|
+
// stored as if it were already minor units — 100× too small on read back.
|
|
14
|
+
// MINOR_UNIT_SCALE fixes that at the boundary so every caller can just
|
|
15
|
+
// pass/receive ordinary decimal amounts; DB storage stays exact-integer
|
|
16
|
+
// cents (no float drift in SUM()/aggregate queries).
|
|
7
17
|
//
|
|
8
18
|
// Permissiv-Insert: primitive number wird als amount akzeptiert (Legacy aus
|
|
9
19
|
// pre-Stufe-3-Samples). Currency fällt dann auf entity.defaultCurrency
|
|
@@ -18,6 +28,19 @@ import { DEFAULT_CURRENCIES } from "../engine/types";
|
|
|
18
28
|
|
|
19
29
|
const FRAMEWORK_DEFAULT_CURRENCY = DEFAULT_CURRENCIES[0]; // "EUR"
|
|
20
30
|
|
|
31
|
+
// 2 decimal places (cents) — covers every currently-supported currency
|
|
32
|
+
// (EUR/USD/GBP/...). No ISO-4217 minor-unit table yet (JPY=0, BHD=3) —
|
|
33
|
+
// upgrade path once a currency needing a different scale actually lands.
|
|
34
|
+
const MINOR_UNIT_SCALE = 100;
|
|
35
|
+
|
|
36
|
+
function toMinorUnits(amount: number): number {
|
|
37
|
+
return Math.round(amount * MINOR_UNIT_SCALE);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function toMajorUnits(amountMinor: number): number {
|
|
41
|
+
return amountMinor / MINOR_UNIT_SCALE;
|
|
42
|
+
}
|
|
43
|
+
|
|
21
44
|
/**
|
|
22
45
|
* API → DB: money-Felder zu zwei flachen Spalten flatten.
|
|
23
46
|
*
|
|
@@ -68,7 +91,7 @@ export function flattenMoney(
|
|
|
68
91
|
}
|
|
69
92
|
|
|
70
93
|
delete result[name];
|
|
71
|
-
result[name] = amount;
|
|
94
|
+
result[name] = toMinorUnits(amount);
|
|
72
95
|
result[`${name}Currency`] = currency;
|
|
73
96
|
}
|
|
74
97
|
|
|
@@ -103,18 +126,18 @@ export function rehydrateMoney(
|
|
|
103
126
|
continue;
|
|
104
127
|
}
|
|
105
128
|
|
|
106
|
-
let
|
|
129
|
+
let amountMinor: number;
|
|
107
130
|
if (typeof amountRaw === "number") {
|
|
108
|
-
|
|
131
|
+
amountMinor = amountRaw;
|
|
109
132
|
} else if (typeof amountRaw === "bigint") {
|
|
110
|
-
|
|
111
|
-
if (Number.isNaN(
|
|
133
|
+
amountMinor = Number(amountRaw);
|
|
134
|
+
if (Number.isNaN(amountMinor)) {
|
|
112
135
|
throw new Error(`rehydrateMoney: field "${name}" bigint amount is not a number`);
|
|
113
136
|
}
|
|
114
137
|
} else if (typeof amountRaw === "string" && amountRaw !== "") {
|
|
115
138
|
// PG-driver liefert BIGINT manchmal als String (>2^53 sicher).
|
|
116
|
-
|
|
117
|
-
if (Number.isNaN(
|
|
139
|
+
amountMinor = Number(amountRaw);
|
|
140
|
+
if (Number.isNaN(amountMinor)) {
|
|
118
141
|
throw new Error(
|
|
119
142
|
`rehydrateMoney: field "${name}" amount string "${amountRaw}" is not a number — DB corruption?`,
|
|
120
143
|
);
|
|
@@ -128,7 +151,7 @@ export function rehydrateMoney(
|
|
|
128
151
|
const currency =
|
|
129
152
|
typeof currencyRaw === "string" && currencyRaw !== "" ? currencyRaw : fallbackCurrency;
|
|
130
153
|
|
|
131
|
-
result[name] = { amount, currency };
|
|
154
|
+
result[name] = { amount: toMajorUnits(amountMinor), currency };
|
|
132
155
|
}
|
|
133
156
|
|
|
134
157
|
return result;
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// r.contentCollection() — sugar over r.nav() that also records which
|
|
2
|
+
// template-resource kind the node lists, so the client can derive the tree
|
|
3
|
+
// provider instead of the app repeating navId + kind.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
|
|
7
|
+
import { validateBoot as validateBootRaw } from "../boot-validator";
|
|
8
|
+
import { buildAppSchema } from "../build-app-schema";
|
|
9
|
+
import { defineFeature } from "../define-feature";
|
|
10
|
+
import { createRegistry } from "../registry";
|
|
11
|
+
|
|
12
|
+
function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
|
|
13
|
+
validateBootRaw(withBootValidatorFixture(features));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("r.contentCollection() — registration", () => {
|
|
17
|
+
test("registers a nav entry with provider:true and returns its qualified name", () => {
|
|
18
|
+
let qn = "";
|
|
19
|
+
const feature = defineFeature("mail", (r) => {
|
|
20
|
+
qn = r.contentCollection({
|
|
21
|
+
id: "templates",
|
|
22
|
+
kind: "mail-html",
|
|
23
|
+
nav: { label: "mail:nav.templates", icon: "file" },
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(qn).toBe("mail:nav:templates");
|
|
28
|
+
// The children arrive from a runtime provider — without provider:true the
|
|
29
|
+
// node would render as an empty leaf.
|
|
30
|
+
expect(feature.navs["templates"]?.provider).toBe(true);
|
|
31
|
+
expect(feature.navs["templates"]?.label).toBe("mail:nav.templates");
|
|
32
|
+
expect(feature.navs["templates"]?.icon).toBe("file");
|
|
33
|
+
expect(feature.contentCollections?.["templates"]?.kind).toBe("mail-html");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("passes nav placement through: parent, order, access, workspaces", () => {
|
|
37
|
+
const feature = defineFeature("mail", (r) => {
|
|
38
|
+
r.nav({ id: "root", label: "mail:nav.root" });
|
|
39
|
+
r.contentCollection({
|
|
40
|
+
id: "templates",
|
|
41
|
+
kind: "mail-html",
|
|
42
|
+
nav: {
|
|
43
|
+
label: "mail:nav.templates",
|
|
44
|
+
parent: "mail:nav:root",
|
|
45
|
+
order: 20,
|
|
46
|
+
access: { roles: ["TenantAdmin"] },
|
|
47
|
+
workspaces: ["mail:workspace:ops"],
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const nav = feature.navs["templates"];
|
|
53
|
+
expect(nav?.parent).toBe("mail:nav:root");
|
|
54
|
+
expect(nav?.order).toBe(20);
|
|
55
|
+
expect(nav?.access).toEqual({ roles: ["TenantAdmin"] });
|
|
56
|
+
expect(nav?.workspaces).toEqual(["mail:workspace:ops"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("passes createAction and hover actions through to the nav entry", () => {
|
|
60
|
+
const target = { featureId: "template-resolver", action: "create", args: { folder: "" } };
|
|
61
|
+
const feature = defineFeature("mail", (r) => {
|
|
62
|
+
r.contentCollection({
|
|
63
|
+
id: "templates",
|
|
64
|
+
kind: "mail-html",
|
|
65
|
+
nav: {
|
|
66
|
+
label: "mail:nav.templates",
|
|
67
|
+
createAction: { label: "mail:action.new", icon: "plus", target },
|
|
68
|
+
actions: [{ label: "mail:action.list", icon: "list", target }],
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Without these a collection can only list what already exists — the "+"
|
|
74
|
+
// affordance is the whole authoring entry point.
|
|
75
|
+
expect(feature.navs["templates"]?.createAction?.label).toBe("mail:action.new");
|
|
76
|
+
expect(feature.navs["templates"]?.actions).toHaveLength(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("the nav node inherits the collection's access — no node the handler would refuse", () => {
|
|
80
|
+
const feature = defineFeature("mail", (r) => {
|
|
81
|
+
r.contentCollection({
|
|
82
|
+
id: "prompts",
|
|
83
|
+
kind: "ai-prompt",
|
|
84
|
+
access: { roles: ["PromptEngineer"] },
|
|
85
|
+
nav: { label: "mail:nav.prompts" },
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(feature.navs["prompts"]?.access).toEqual({ roles: ["PromptEngineer"] });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("an explicit nav.access still wins over the collection's", () => {
|
|
93
|
+
const feature = defineFeature("mail", (r) => {
|
|
94
|
+
r.contentCollection({
|
|
95
|
+
id: "prompts",
|
|
96
|
+
kind: "ai-prompt",
|
|
97
|
+
access: { roles: ["PromptEngineer"] },
|
|
98
|
+
nav: { label: "mail:nav.prompts", access: { roles: ["TenantAdmin"] } },
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(feature.navs["prompts"]?.access).toEqual({ roles: ["TenantAdmin"] });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("records ownership so the handlers can scope reads", () => {
|
|
106
|
+
const feature = defineFeature("mail", (r) => {
|
|
107
|
+
r.contentCollection({
|
|
108
|
+
id: "signatures",
|
|
109
|
+
kind: "mail-html",
|
|
110
|
+
ownership: "user",
|
|
111
|
+
nav: { label: "mail:nav.signatures" },
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(feature.contentCollections?.["signatures"]?.ownership).toBe("user");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("rejects a second collection with the same id", () => {
|
|
119
|
+
expect(() =>
|
|
120
|
+
defineFeature("mail", (r) => {
|
|
121
|
+
r.contentCollection({ id: "templates", kind: "mail-html", nav: { label: "a" } });
|
|
122
|
+
r.contentCollection({ id: "templates", kind: "ai-prompt", nav: { label: "b" } });
|
|
123
|
+
}),
|
|
124
|
+
).toThrow(/already registered/);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("rejects an id already taken by a plain r.nav()", () => {
|
|
128
|
+
expect(() =>
|
|
129
|
+
defineFeature("mail", (r) => {
|
|
130
|
+
r.nav({ id: "templates", label: "mail:nav.templates" });
|
|
131
|
+
r.contentCollection({ id: "templates", kind: "mail-html", nav: { label: "b" } });
|
|
132
|
+
}),
|
|
133
|
+
).toThrow(/already registered/);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("rejects a non-kebab id", () => {
|
|
137
|
+
expect(() =>
|
|
138
|
+
defineFeature("mail", (r) => {
|
|
139
|
+
r.contentCollection({ id: "MailTemplates", kind: "mail-html", nav: { label: "a" } });
|
|
140
|
+
}),
|
|
141
|
+
).toThrow(/kebab-case/);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("r.contentCollection() — boot validation", () => {
|
|
146
|
+
test("a collection mounted under another feature's nav passes", () => {
|
|
147
|
+
const mail = defineFeature("mail", (r) => {
|
|
148
|
+
r.nav({ id: "root", label: "mail:nav.root" });
|
|
149
|
+
});
|
|
150
|
+
const templates = defineFeature("templates", (r) => {
|
|
151
|
+
r.contentCollection({
|
|
152
|
+
id: "mail-templates",
|
|
153
|
+
kind: "mail-html",
|
|
154
|
+
nav: { label: "templates:nav.mail", parent: "mail:nav:root" },
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(() => validateBoot([mail, templates])).not.toThrow();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("a dangling parent fails boot instead of silently vanishing from the sidebar", () => {
|
|
162
|
+
const templates = defineFeature("templates", (r) => {
|
|
163
|
+
r.contentCollection({
|
|
164
|
+
id: "mail-templates",
|
|
165
|
+
kind: "mail-html",
|
|
166
|
+
nav: { label: "templates:nav.mail", parent: "mail:nav:root" },
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(() => validateBoot([templates])).toThrow(/mail:nav:root/);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("buildAppSchema — content collections", () => {
|
|
175
|
+
test("projects collections with the nav QN qualified", () => {
|
|
176
|
+
const registry = createRegistry([
|
|
177
|
+
defineFeature("mail", (r) => {
|
|
178
|
+
r.nav({ id: "root", label: "mail:nav.root" });
|
|
179
|
+
r.contentCollection({
|
|
180
|
+
id: "templates",
|
|
181
|
+
kind: "mail-html",
|
|
182
|
+
nav: { label: "mail:nav.templates", parent: "mail:nav:root" },
|
|
183
|
+
});
|
|
184
|
+
}),
|
|
185
|
+
]);
|
|
186
|
+
|
|
187
|
+
const schema = buildAppSchema(registry);
|
|
188
|
+
const mail = schema.features.find((f) => f.featureName === "mail");
|
|
189
|
+
expect(mail?.contentCollections).toEqual([
|
|
190
|
+
{
|
|
191
|
+
id: "templates",
|
|
192
|
+
kind: "mail-html",
|
|
193
|
+
nav: { label: "mail:nav.templates", parent: "mail:nav:root" },
|
|
194
|
+
navQn: "mail:nav:templates",
|
|
195
|
+
},
|
|
196
|
+
]);
|
|
197
|
+
// The nav entry itself still travels the normal route — the collection
|
|
198
|
+
// list only carries what a NavDefinition cannot express.
|
|
199
|
+
expect(mail?.navs?.map((n) => n.id)).toContain("templates");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("omits the slot for features without collections", () => {
|
|
203
|
+
const registry = createRegistry([
|
|
204
|
+
defineFeature("shop", (r) => {
|
|
205
|
+
r.nav({ id: "catalog", label: "shop:nav.catalog" });
|
|
206
|
+
}),
|
|
207
|
+
]);
|
|
208
|
+
|
|
209
|
+
const shop = buildAppSchema(registry).features.find((f) => f.featureName === "shop");
|
|
210
|
+
expect(shop?.contentCollections).toBeUndefined();
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -46,11 +46,21 @@ export function buildAppSchema(registry: Registry, options: BuildAppSchemaOption
|
|
|
46
46
|
const features: FeatureSchema[] = [];
|
|
47
47
|
for (const [featureName, feature] of registry.features) {
|
|
48
48
|
const navs = Object.values(feature.navs);
|
|
49
|
+
// The nav entry alone doesn't say which kind a collection lists, so the
|
|
50
|
+
// client can't derive its tree provider from `navs` — project the
|
|
51
|
+
// collections separately, with the nav QN already qualified.
|
|
52
|
+
const contentCollections = Object.values(feature.contentCollections ?? {}).map(
|
|
53
|
+
(collection) => ({
|
|
54
|
+
...collection,
|
|
55
|
+
navQn: `${featureName}:nav:${collection.id}`,
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
49
58
|
const featureSchema: FeatureSchema = {
|
|
50
59
|
featureName,
|
|
51
60
|
entities: projectEntities(feature.entities ?? {}),
|
|
52
61
|
screens: Object.values(feature.screens),
|
|
53
62
|
...(navs.length > 0 && { navs }),
|
|
63
|
+
...(contentCollections.length > 0 && { contentCollections }),
|
|
54
64
|
// #1059: verbatim r.translations({keys}) — see FeatureSchema.translations
|
|
55
65
|
// doc for why this must NOT go through registry.getAllTranslations()
|
|
56
66
|
// (double-prefixes features that already qualify their own keys).
|
|
@@ -152,6 +152,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
152
152
|
claimKeys: state.claimKeys,
|
|
153
153
|
screens: state.screens,
|
|
154
154
|
navs: state.navs,
|
|
155
|
+
contentCollections: state.contentCollections,
|
|
155
156
|
workspaces: state.workspaces,
|
|
156
157
|
httpRoutes: state.httpRoutes,
|
|
157
158
|
storeTables: state.storeTables,
|
|
@@ -38,7 +38,7 @@ import type {
|
|
|
38
38
|
WriteHandlerDef,
|
|
39
39
|
} from "./types";
|
|
40
40
|
import type { HttpRouteDefinition } from "./types/http-route";
|
|
41
|
-
import type { NavDefinition } from "./types/nav";
|
|
41
|
+
import type { ContentCollectionDefinition, NavDefinition } from "./types/nav";
|
|
42
42
|
import type { ScreenDefinition } from "./types/screen";
|
|
43
43
|
import type { WorkspaceDefinition } from "./types/workspace";
|
|
44
44
|
|
|
@@ -95,6 +95,7 @@ export type FeatureBuilderState = {
|
|
|
95
95
|
claimKeys: Record<string, ClaimKeyDefinition>;
|
|
96
96
|
screens: Record<string, ScreenDefinition>;
|
|
97
97
|
navs: Record<string, NavDefinition>;
|
|
98
|
+
contentCollections: Record<string, ContentCollectionDefinition>;
|
|
98
99
|
workspaces: Record<string, WorkspaceDefinition>;
|
|
99
100
|
httpRoutes: Record<string, HttpRouteDefinition>;
|
|
100
101
|
translations: TranslationKeys;
|
|
@@ -155,6 +156,7 @@ export function createInitialFeatureBuilderState(): FeatureBuilderState {
|
|
|
155
156
|
claimKeys: {},
|
|
156
157
|
screens: {},
|
|
157
158
|
navs: {},
|
|
159
|
+
contentCollections: {},
|
|
158
160
|
workspaces: {},
|
|
159
161
|
httpRoutes: {},
|
|
160
162
|
translations: {},
|
|
@@ -25,7 +25,7 @@ import type {
|
|
|
25
25
|
} from "./types";
|
|
26
26
|
import { HookPhases } from "./types";
|
|
27
27
|
import type { HttpRouteDefinition } from "./types/http-route";
|
|
28
|
-
import type { NavDefinition } from "./types/nav";
|
|
28
|
+
import type { ContentCollectionDefinition, NavDefinition } from "./types/nav";
|
|
29
29
|
import type { ScreenDefinition } from "./types/screen";
|
|
30
30
|
import type { WorkspaceDefinition } from "./types/workspace";
|
|
31
31
|
|
|
@@ -373,6 +373,41 @@ export function buildUiExtensionsMethods<TName extends string>(
|
|
|
373
373
|
nav(definition: NavDefinition): void {
|
|
374
374
|
registerNav(definition);
|
|
375
375
|
},
|
|
376
|
+
contentCollection(definition: ContentCollectionDefinition): string {
|
|
377
|
+
if (state.contentCollections[definition.id]) {
|
|
378
|
+
throw new Error(
|
|
379
|
+
`[Feature ${name}] Content collection "${definition.id}" already registered. ` +
|
|
380
|
+
`Collection ids must be unique per feature.`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
// registerNav owns the kebab + collision checks, including collisions
|
|
384
|
+
// with a plain r.nav() of the same id. Optional fields stay absent
|
|
385
|
+
// rather than explicitly undefined — buildAppSchema's JSON-safety check
|
|
386
|
+
// flags undefined values.
|
|
387
|
+
registerNav({
|
|
388
|
+
id: definition.id,
|
|
389
|
+
label: definition.nav.label,
|
|
390
|
+
...(definition.nav.icon !== undefined && { icon: definition.nav.icon }),
|
|
391
|
+
...(definition.nav.parent !== undefined && { parent: definition.nav.parent }),
|
|
392
|
+
...(definition.nav.order !== undefined && { order: definition.nav.order }),
|
|
393
|
+
// Nav visibility follows the collection's access unless the caller
|
|
394
|
+
// overrode it — a node the handler would refuse has no business in
|
|
395
|
+
// the sidebar.
|
|
396
|
+
...((definition.nav.access ?? definition.access) !== undefined && {
|
|
397
|
+
access: definition.nav.access ?? definition.access,
|
|
398
|
+
}),
|
|
399
|
+
...(definition.nav.workspaces !== undefined && { workspaces: definition.nav.workspaces }),
|
|
400
|
+
...(definition.nav.createAction !== undefined && {
|
|
401
|
+
createAction: definition.nav.createAction,
|
|
402
|
+
}),
|
|
403
|
+
...(definition.nav.actions !== undefined && { actions: definition.nav.actions }),
|
|
404
|
+
// The tree children come from a runtime provider keyed on this QN —
|
|
405
|
+
// a collection without it would render as an empty leaf.
|
|
406
|
+
provider: true,
|
|
407
|
+
});
|
|
408
|
+
state.contentCollections[definition.id] = definition;
|
|
409
|
+
return `${name}:nav:${definition.id}`;
|
|
410
|
+
},
|
|
376
411
|
workspace(definition: WorkspaceDefinition): void {
|
|
377
412
|
// Same kebab guard as r.screen / r.nav so authoring-time mistakes
|
|
378
413
|
// surface at the feature file, not deep in registry boot.
|
package/src/engine/index.ts
CHANGED
|
@@ -206,7 +206,7 @@ export {
|
|
|
206
206
|
parseTenantId,
|
|
207
207
|
SYSTEM_TENANT_ID,
|
|
208
208
|
} from "@cosmicdrift/kumiko-types/identifiers";
|
|
209
|
-
export type { NavDefinition } from "@cosmicdrift/kumiko-types/nav";
|
|
209
|
+
export type { ContentCollectionDefinition, NavDefinition } from "@cosmicdrift/kumiko-types/nav";
|
|
210
210
|
export type {
|
|
211
211
|
FromRule,
|
|
212
212
|
FromRuleKind,
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import type { TranslationKeys } from "../engine/types/config";
|
|
11
11
|
import type { EntityDefinition } from "../engine/types/fields";
|
|
12
|
-
import type { NavDefinition } from "../engine/types/nav";
|
|
12
|
+
import type { ContentCollectionDefinition, NavDefinition } from "../engine/types/nav";
|
|
13
13
|
import type { ScreenDefinition } from "../engine/types/screen";
|
|
14
14
|
import type { WorkspaceDefinition } from "../engine/types/workspace";
|
|
15
15
|
|
|
@@ -20,6 +20,12 @@ export type FeatureSchema = {
|
|
|
20
20
|
// Flat list; resolveNavigation builds the tree at render-time from
|
|
21
21
|
// the registry's indexes. Omitted when the app has no top-level nav.
|
|
22
22
|
readonly navs?: readonly NavDefinition[];
|
|
23
|
+
// Content collections declared via r.contentCollection(), each with its nav
|
|
24
|
+
// QN already qualified. The matching nav entries are in `navs` like any
|
|
25
|
+
// other; this list only carries what a NavDefinition can't express — which
|
|
26
|
+
// template-resource `kind` the node lists — so the client can build one
|
|
27
|
+
// tree provider per collection. Omitted when a feature declares none.
|
|
28
|
+
readonly contentCollections?: readonly QualifiedContentCollection[];
|
|
23
29
|
// Server-authored `r.translations({ keys })`, projected verbatim — byte-
|
|
24
30
|
// identical keys, NOT re-prefixed with featureName (unlike the registry's
|
|
25
31
|
// internal mergedTranslations, which double-prefixes features that
|
|
@@ -39,6 +45,12 @@ export type FeatureSchema = {
|
|
|
39
45
|
readonly workspaces?: readonly WorkspaceSchema[];
|
|
40
46
|
};
|
|
41
47
|
|
|
48
|
+
// A content collection as it reaches the client: the declaration plus the
|
|
49
|
+
// already-qualified nav QN, so consumers don't rebuild "<feature>:nav:<id>".
|
|
50
|
+
export type QualifiedContentCollection = ContentCollectionDefinition & {
|
|
51
|
+
readonly navQn: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
42
54
|
// Per-workspace projection of the engine's WorkspaceDefinition + the
|
|
43
55
|
// pre-resolved member nav QNs. The shell renders the switcher from
|
|
44
56
|
// `definition` and filters the nav tree using `navMembers`.
|
package/src/ui-types/index.ts
CHANGED
|
@@ -91,4 +91,9 @@ export type { TargetRef } from "../engine/types/target-ref";
|
|
|
91
91
|
export type { TreeAction, TreeNode, TreeNodeState } from "../engine/types/tree-node";
|
|
92
92
|
export type { WorkspaceDefinition } from "../engine/types/workspace";
|
|
93
93
|
export { PROJECTION_DETAIL_ENTITY } from "../i18n/required-surface-keys";
|
|
94
|
-
export type {
|
|
94
|
+
export type {
|
|
95
|
+
AppSchema,
|
|
96
|
+
FeatureSchema,
|
|
97
|
+
QualifiedContentCollection,
|
|
98
|
+
WorkspaceSchema,
|
|
99
|
+
} from "./app-schema";
|