@cosmicdrift/kumiko-framework 0.153.0 → 0.154.1
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__/anonymous-access.integration.test.ts +25 -0
- package/src/engine/__tests__/boot-validator-action-wiring.test.ts +242 -0
- package/src/engine/__tests__/boot-validator.test.ts +7 -4
- package/src/engine/__tests__/event-migration-declarative.test.ts +9 -2
- package/src/engine/boot-validator/action-wiring.ts +99 -0
- package/src/engine/boot-validator/index.ts +7 -0
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +7 -21
- package/src/engine/feature-ast/__tests__/parse.test.ts +33 -6
- package/src/engine/feature-ast/__tests__/patch.test.ts +8 -13
- package/src/engine/feature-ast/__tests__/patcher.test.ts +4 -14
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +37 -1
- package/src/engine/feature-ast/extractors/index.ts +0 -1
- package/src/engine/feature-ast/extractors/round4.ts +92 -136
- package/src/engine/feature-ast/index.ts +0 -2
- package/src/engine/feature-ast/parse.ts +0 -3
- package/src/engine/feature-ast/patch.ts +0 -41
- package/src/engine/feature-ast/patcher.ts +14 -20
- package/src/engine/feature-ast/patterns.ts +10 -19
- package/src/engine/feature-ast/render.ts +10 -13
- package/src/engine/feature-config-events-jobs.ts +77 -51
- package/src/engine/pattern-library/__tests__/library.test.ts +0 -10
- package/src/engine/pattern-library/library.ts +0 -2
- package/src/engine/pattern-library/mixed-schemas.ts +8 -35
- package/src/engine/registry-validate.ts +6 -6
- package/src/engine/types/feature.ts +18 -17
- package/src/engine/types/handlers.ts +6 -3
- package/src/event-store/__tests__/upcaster.integration.test.ts +77 -45
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +16 -8
- package/src/stack/redis.ts +8 -0
- package/src/stack/test-stack.ts +156 -136
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.154.1",
|
|
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>",
|
|
@@ -193,7 +193,7 @@
|
|
|
193
193
|
"zod": "^4.4.3"
|
|
194
194
|
},
|
|
195
195
|
"devDependencies": {
|
|
196
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
196
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.154.1",
|
|
197
197
|
"bun-types": "^1.3.13",
|
|
198
198
|
"pino-pretty": "^13.1.3"
|
|
199
199
|
},
|
|
@@ -361,6 +361,31 @@ describe("anonymous access — resolverTrust: authoritative", () => {
|
|
|
361
361
|
const body = (await res.json()) as { error: { code: string } };
|
|
362
362
|
expect(body.error.code).toBe("tenant_mismatch");
|
|
363
363
|
});
|
|
364
|
+
|
|
365
|
+
// pr-review kumiko-framework #1052/3 — resolveTenant treats header and
|
|
366
|
+
// cookie identically, but every case above only exercised the header.
|
|
367
|
+
// These two lock in that the cookie path behaves the same way.
|
|
368
|
+
test("kumiko_tenant cookie agreeing with the resolver → accepted", async () => {
|
|
369
|
+
const res = await stack.http.raw(
|
|
370
|
+
"POST",
|
|
371
|
+
"/api/query",
|
|
372
|
+
{ type: "anonshop:query:product:list", payload: {} },
|
|
373
|
+
{ Cookie: `kumiko_tenant=${TENANT_ID}` },
|
|
374
|
+
);
|
|
375
|
+
expect(res.status).toBe(200);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("kumiko_tenant cookie disagreeing with the resolver → 400 tenant_mismatch, same as a disagreeing header", async () => {
|
|
379
|
+
const res = await stack.http.raw(
|
|
380
|
+
"POST",
|
|
381
|
+
"/api/query",
|
|
382
|
+
{ type: "anonshop:query:product:list", payload: {} },
|
|
383
|
+
{ Cookie: `kumiko_tenant=${OTHER_TENANT_ID}` },
|
|
384
|
+
);
|
|
385
|
+
expect(res.status).toBe(400);
|
|
386
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
387
|
+
expect(body.error.code).toBe("tenant_mismatch");
|
|
388
|
+
});
|
|
364
389
|
});
|
|
365
390
|
|
|
366
391
|
describe("anonymous access — resolverTrust: authoritative, resolver returns null", () => {
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { validateBoot } from "../boot-validator";
|
|
4
|
+
import { defineFeature } from "../define-feature";
|
|
5
|
+
import { createEntity, createTextField } from "../factories";
|
|
6
|
+
|
|
7
|
+
describe("validateBoot — action wiring (no function values)", () => {
|
|
8
|
+
test("rowAction writeHandler payload as function → Throw", () => {
|
|
9
|
+
const feature = defineFeature("shop", (r) => {
|
|
10
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
11
|
+
r.screen({
|
|
12
|
+
id: "product-list",
|
|
13
|
+
type: "entityList",
|
|
14
|
+
entity: "product",
|
|
15
|
+
columns: ["name"],
|
|
16
|
+
rowActions: [
|
|
17
|
+
{
|
|
18
|
+
kind: "writeHandler",
|
|
19
|
+
id: "sync",
|
|
20
|
+
label: "actions.sync",
|
|
21
|
+
handler: "shop:write:sync",
|
|
22
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
|
|
23
|
+
payload: ((row: unknown) => ({ id: row })) as any,
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
r.writeHandler("sync", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
|
|
28
|
+
access: { roles: ["Admin"] },
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
expect(() => validateBoot([feature])).toThrow(/rowAction "sync" payload is a function/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("rowAction writeHandler payload as declarative pick → kein Throw", () => {
|
|
35
|
+
const feature = defineFeature("shop", (r) => {
|
|
36
|
+
r.entity("product", createEntity({ fields: { name: createTextField({ sortable: true }) } }));
|
|
37
|
+
r.screen({
|
|
38
|
+
id: "product-list",
|
|
39
|
+
type: "entityList",
|
|
40
|
+
entity: "product",
|
|
41
|
+
columns: ["name"],
|
|
42
|
+
defaultSort: { field: "name", dir: "asc" },
|
|
43
|
+
rowActions: [
|
|
44
|
+
{
|
|
45
|
+
kind: "writeHandler",
|
|
46
|
+
id: "sync",
|
|
47
|
+
label: "actions.sync",
|
|
48
|
+
handler: "shop:write:sync",
|
|
49
|
+
payload: { pick: ["name"] },
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
r.writeHandler("sync", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
|
|
54
|
+
access: { roles: ["Admin"] },
|
|
55
|
+
});
|
|
56
|
+
r.translations({
|
|
57
|
+
keys: {
|
|
58
|
+
"screen:product-list.title": { de: "Liste", en: "List" },
|
|
59
|
+
"shop:entity:product:field:name": { de: "Name", en: "Name" },
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("rowAction navigate visible as function → Throw", () => {
|
|
67
|
+
const feature = defineFeature("shop", (r) => {
|
|
68
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
69
|
+
r.screen({
|
|
70
|
+
id: "product-list",
|
|
71
|
+
type: "entityList",
|
|
72
|
+
entity: "product",
|
|
73
|
+
columns: ["name"],
|
|
74
|
+
rowActions: [
|
|
75
|
+
{
|
|
76
|
+
kind: "navigate",
|
|
77
|
+
id: "edit",
|
|
78
|
+
label: "actions.edit",
|
|
79
|
+
screen: "product-list",
|
|
80
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
|
|
81
|
+
visible: (() => true) as any,
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
expect(() => validateBoot([feature])).toThrow(/rowAction "edit" visible is a function/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("toolbarAction writeHandler payload as function → Throw", () => {
|
|
90
|
+
const feature = defineFeature("shop", (r) => {
|
|
91
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
92
|
+
r.screen({
|
|
93
|
+
id: "product-list",
|
|
94
|
+
type: "entityList",
|
|
95
|
+
entity: "product",
|
|
96
|
+
columns: ["name"],
|
|
97
|
+
toolbarActions: [
|
|
98
|
+
{
|
|
99
|
+
kind: "writeHandler",
|
|
100
|
+
id: "sync",
|
|
101
|
+
label: "actions.sync",
|
|
102
|
+
handler: "shop:write:sync",
|
|
103
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
|
|
104
|
+
payload: (() => ({})) as any,
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
});
|
|
108
|
+
r.writeHandler("sync", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
|
|
109
|
+
access: { roles: ["Admin"] },
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
expect(() => validateBoot([feature])).toThrow(/toolbarAction "sync" payload is a function/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("entityList column renderer as function → Throw", () => {
|
|
116
|
+
const feature = defineFeature("shop", (r) => {
|
|
117
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
118
|
+
r.screen({
|
|
119
|
+
id: "product-list",
|
|
120
|
+
type: "entityList",
|
|
121
|
+
entity: "product",
|
|
122
|
+
columns: [
|
|
123
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
|
|
124
|
+
{ field: "name", renderer: ((v: unknown) => String(v)) as any },
|
|
125
|
+
],
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
expect(() => validateBoot([feature])).toThrow(/column "name" renderer is a function/);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("entityEdit field visible as function → Throw", () => {
|
|
132
|
+
const feature = defineFeature("shop", (r) => {
|
|
133
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
134
|
+
r.screen({
|
|
135
|
+
id: "product-edit",
|
|
136
|
+
type: "entityEdit",
|
|
137
|
+
entity: "product",
|
|
138
|
+
layout: {
|
|
139
|
+
sections: [
|
|
140
|
+
{
|
|
141
|
+
columns: 1,
|
|
142
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
|
|
143
|
+
fields: [{ field: "name", visible: (() => true) as any }],
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
expect(() => validateBoot([feature])).toThrow(/field "name" visible is a function/);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("entityEdit field renderer as function → Throw", () => {
|
|
153
|
+
const feature = defineFeature("shop", (r) => {
|
|
154
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
155
|
+
r.screen({
|
|
156
|
+
id: "product-edit",
|
|
157
|
+
type: "entityEdit",
|
|
158
|
+
entity: "product",
|
|
159
|
+
layout: {
|
|
160
|
+
sections: [
|
|
161
|
+
{
|
|
162
|
+
columns: 1,
|
|
163
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
|
|
164
|
+
fields: [{ field: "name", renderer: ((v: unknown) => String(v)) as any }],
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
expect(() => validateBoot([feature])).toThrow(/field "name" renderer is a function/);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("entityEdit field with declarative visible/readOnly/required → kein Throw", () => {
|
|
174
|
+
const feature = defineFeature("shop", (r) => {
|
|
175
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
176
|
+
r.screen({
|
|
177
|
+
id: "product-edit",
|
|
178
|
+
type: "entityEdit",
|
|
179
|
+
entity: "product",
|
|
180
|
+
layout: {
|
|
181
|
+
sections: [
|
|
182
|
+
{
|
|
183
|
+
columns: 1,
|
|
184
|
+
fields: [
|
|
185
|
+
{
|
|
186
|
+
field: "name",
|
|
187
|
+
visible: { field: "name", ne: "" },
|
|
188
|
+
readOnly: false,
|
|
189
|
+
required: true,
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
},
|
|
193
|
+
],
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
r.translations({
|
|
197
|
+
keys: {
|
|
198
|
+
"screen:product-edit.title": { de: "Bearbeiten", en: "Edit" },
|
|
199
|
+
"shop:entity:product:field:name": { de: "Name", en: "Name" },
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("projectionList column renderer as function → Throw", () => {
|
|
207
|
+
const feature = defineFeature("shop", (r) => {
|
|
208
|
+
r.screen({
|
|
209
|
+
id: "sales-list",
|
|
210
|
+
type: "projectionList",
|
|
211
|
+
query: "shop:query:sales",
|
|
212
|
+
columns: [
|
|
213
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
|
|
214
|
+
{ field: "amount", renderer: ((v: unknown) => String(v)) as any },
|
|
215
|
+
],
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
expect(() => validateBoot([feature])).toThrow(/column "amount" renderer is a function/);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("projectionList rowAction payload as function → Throw", () => {
|
|
222
|
+
const feature = defineFeature("shop", (r) => {
|
|
223
|
+
r.screen({
|
|
224
|
+
id: "sales-list",
|
|
225
|
+
type: "projectionList",
|
|
226
|
+
query: "shop:query:sales",
|
|
227
|
+
columns: ["amount"],
|
|
228
|
+
rowActions: [
|
|
229
|
+
{
|
|
230
|
+
kind: "writeHandler",
|
|
231
|
+
id: "sync",
|
|
232
|
+
label: "actions.sync",
|
|
233
|
+
handler: "shop:write:sync",
|
|
234
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
|
|
235
|
+
payload: (() => ({})) as any,
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
expect(() => validateBoot([feature])).toThrow(/rowAction "sync" payload is a function/);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -1231,8 +1231,9 @@ describe("boot-validator", () => {
|
|
|
1231
1231
|
// --- entityList column-renderer form-check ---
|
|
1232
1232
|
// Validator akzeptiert die `{ react: { __component: "Name" } }`-Form
|
|
1233
1233
|
// (PlatformComponent → client-side Registry-Lookup) und prüft sie
|
|
1234
|
-
// strukturell.
|
|
1235
|
-
//
|
|
1234
|
+
// strukturell. null/undefined, native-only und andere Formen bleiben
|
|
1235
|
+
// opak. Function-Renderer werfen (action-wiring.ts validateFieldWiring) —
|
|
1236
|
+
// von JSON.stringify verworfen, sonst stiller No-op auf dem Client.
|
|
1236
1237
|
describe("entityList column renderer form", () => {
|
|
1237
1238
|
function shopFeature(renderer: unknown) {
|
|
1238
1239
|
return defineFeature("shop", (r) => {
|
|
@@ -1250,8 +1251,10 @@ describe("boot-validator", () => {
|
|
|
1250
1251
|
});
|
|
1251
1252
|
}
|
|
1252
1253
|
|
|
1253
|
-
test("function-renderer →
|
|
1254
|
-
expect(() => validateBoot([shopFeature((v: unknown) => String(v))])).
|
|
1254
|
+
test("function-renderer → Throw (Function wird von JSON.stringify verworfen)", () => {
|
|
1255
|
+
expect(() => validateBoot([shopFeature((v: unknown) => String(v))])).toThrow(
|
|
1256
|
+
/column "name" renderer is a function/,
|
|
1257
|
+
);
|
|
1255
1258
|
});
|
|
1256
1259
|
|
|
1257
1260
|
test("undefined renderer → kein Throw (Spalte ohne Renderer)", () => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
2
3
|
import { defineFeature } from "../define-feature";
|
|
3
4
|
import type { DeclarativeEventMigration, EventUpcastCtx } from "../types";
|
|
4
5
|
|
|
@@ -7,7 +8,10 @@ const upcastCtx = {} as EventUpcastCtx;
|
|
|
7
8
|
|
|
8
9
|
function compile(spec: DeclarativeEventMigration) {
|
|
9
10
|
const feature = defineFeature("billing", (r) => {
|
|
10
|
-
r.
|
|
11
|
+
r.defineEvent("invoicePaid", z.unknown(), {
|
|
12
|
+
version: 2,
|
|
13
|
+
migrations: [{ fromVersion: 1, toVersion: 2, transform: spec }],
|
|
14
|
+
});
|
|
11
15
|
});
|
|
12
16
|
const def = feature.eventMigrations["invoicePaid"]?.[0];
|
|
13
17
|
if (!def) throw new Error("migration not registered");
|
|
@@ -51,7 +55,10 @@ describe("declarative eventMigration", () => {
|
|
|
51
55
|
test("imperative function variant is stored untouched", () => {
|
|
52
56
|
const fn = (payload: unknown) => payload;
|
|
53
57
|
const feature = defineFeature("billing", (r) => {
|
|
54
|
-
r.
|
|
58
|
+
r.defineEvent("invoicePaid", z.unknown(), {
|
|
59
|
+
version: 2,
|
|
60
|
+
migrations: [{ fromVersion: 1, toVersion: 2, transform: fn }],
|
|
61
|
+
});
|
|
55
62
|
});
|
|
56
63
|
expect(feature.eventMigrations["invoicePaid"]?.[0]?.transform).toBe(fn);
|
|
57
64
|
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { EditFieldSpec, FeatureDefinition, RowAction, ToolbarAction } from "../types";
|
|
2
|
+
import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../types/screen";
|
|
3
|
+
|
|
4
|
+
const FUNCTION_DROPPED_HINT =
|
|
5
|
+
"functions are dropped by JSON.stringify when the screen config reaches the client " +
|
|
6
|
+
"bundle — the action/field silently no-ops at runtime instead of failing loudly.";
|
|
7
|
+
|
|
8
|
+
function throwIfFunction(value: unknown, message: string): void {
|
|
9
|
+
if (typeof value === "function") {
|
|
10
|
+
throw new Error(message);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// rowActions/toolbarActions carry row-context extractors (payload/params),
|
|
15
|
+
// static visibility conditions and navigate entityId — all declarative-DSL-
|
|
16
|
+
// only fields (RowFieldExtractor's `{ pick }`/`{ map }`, FieldCondition's
|
|
17
|
+
// `{ field, eq }`/`{ field, ne }`, plain strings). A function literal here
|
|
18
|
+
// compiles fine (the DSL types are structural, not branded) but is silently
|
|
19
|
+
// dropped once the feature config is serialized for the client.
|
|
20
|
+
const ACTION_FUNCTION_FIELDS = ["payload", "params", "entityId", "visible"] as const;
|
|
21
|
+
|
|
22
|
+
function validateActionNoFunctions(
|
|
23
|
+
featureName: string,
|
|
24
|
+
screenId: string,
|
|
25
|
+
actionKind: "rowAction" | "toolbarAction",
|
|
26
|
+
action: RowAction | ToolbarAction,
|
|
27
|
+
): void {
|
|
28
|
+
const record = action as unknown as Record<string, unknown>;
|
|
29
|
+
for (const field of ACTION_FUNCTION_FIELDS) {
|
|
30
|
+
throwIfFunction(
|
|
31
|
+
record[field],
|
|
32
|
+
`[Feature ${featureName}] Screen "${screenId}" ${actionKind} "${action.id}" ${field} ` +
|
|
33
|
+
`is a function — ${FUNCTION_DROPPED_HINT} Use the declarative DSL ({ pick }, { map }, ` +
|
|
34
|
+
`"fieldName", { field, eq }) instead.`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function validateActionWiring(feature: FeatureDefinition): void {
|
|
40
|
+
for (const screen of Object.values(feature.screens)) {
|
|
41
|
+
if (screen.type !== "entityList" && screen.type !== "projectionList") continue;
|
|
42
|
+
for (const action of screen.rowActions ?? []) {
|
|
43
|
+
validateActionNoFunctions(feature.name, screen.id, "rowAction", action);
|
|
44
|
+
}
|
|
45
|
+
for (const action of screen.toolbarActions ?? []) {
|
|
46
|
+
validateActionNoFunctions(feature.name, screen.id, "toolbarAction", action);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// EditFieldSpec's visible/readOnly/required are the same FieldCondition DSL
|
|
52
|
+
// as rowActions — same footgun, different screen type (entityEdit layouts).
|
|
53
|
+
const EDIT_FIELD_FUNCTION_FIELDS = ["visible", "readOnly", "required"] as const;
|
|
54
|
+
|
|
55
|
+
function validateEditFieldNoFunctions(
|
|
56
|
+
featureName: string,
|
|
57
|
+
screenId: string,
|
|
58
|
+
fieldSpec: EditFieldSpec,
|
|
59
|
+
): void {
|
|
60
|
+
const normalized = normalizeEditField(fieldSpec);
|
|
61
|
+
const record = normalized as unknown as Record<string, unknown>;
|
|
62
|
+
for (const key of EDIT_FIELD_FUNCTION_FIELDS) {
|
|
63
|
+
throwIfFunction(
|
|
64
|
+
record[key],
|
|
65
|
+
`[Feature ${featureName}] Screen "${screenId}" (entityEdit) field "${normalized.field}" ` +
|
|
66
|
+
`${key} is a function — ${FUNCTION_DROPPED_HINT} Use a FieldCondition ` +
|
|
67
|
+
`(boolean or { field, eq }/{ field, ne }) instead.`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
throwIfFunction(
|
|
71
|
+
normalized.renderer,
|
|
72
|
+
`[Feature ${featureName}] Screen "${screenId}" (entityEdit) field "${normalized.field}" ` +
|
|
73
|
+
`renderer is a function — ${FUNCTION_DROPPED_HINT} Use a FormatSpec ({ format: "..." }) instead.`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function validateFieldWiring(feature: FeatureDefinition): void {
|
|
78
|
+
for (const screen of Object.values(feature.screens)) {
|
|
79
|
+
if (screen.type === "entityList" || screen.type === "projectionList") {
|
|
80
|
+
for (const col of screen.columns) {
|
|
81
|
+
const normalized = normalizeListColumn(col);
|
|
82
|
+
throwIfFunction(
|
|
83
|
+
normalized.renderer,
|
|
84
|
+
`[Feature ${feature.name}] Screen "${screen.id}" (${screen.type}) column ` +
|
|
85
|
+
`"${normalized.field}" renderer is a function — ${FUNCTION_DROPPED_HINT} ` +
|
|
86
|
+
`Use a FormatSpec ({ format: "..." }) instead.`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (screen.type !== "entityEdit") continue;
|
|
92
|
+
for (const section of screen.layout.sections) {
|
|
93
|
+
if (isExtensionEditSection(section)) continue;
|
|
94
|
+
for (const fieldSpec of section.fields) {
|
|
95
|
+
validateEditFieldNoFunctions(feature.name, screen.id, fieldSpec);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { validateEntityFieldEncryptionAvailable } from "../../db/entity-field-encryption";
|
|
2
2
|
import { QnTypes, qualifyEntityName } from "../qualified-name";
|
|
3
3
|
import type { ClaimKeyDefinition, FeatureDefinition } from "../types";
|
|
4
|
+
import { validateActionWiring, validateFieldWiring } from "./action-wiring";
|
|
4
5
|
import { validateApiExposureMatching, validateExtensionUsages } from "./api-ext";
|
|
5
6
|
import {
|
|
6
7
|
validateCircularDeps,
|
|
@@ -171,6 +172,12 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
|
|
|
171
172
|
validateConfigKeyBacking(feature);
|
|
172
173
|
validateOwnershipRules(feature, allClaimKeys, knownRoles);
|
|
173
174
|
validateMultiStreamProjections(feature);
|
|
175
|
+
// Vor validateScreens: dessen visible/entityId-Feldref-Checks werfen für
|
|
176
|
+
// einen Function-Wert bereits (mit verwirrender "unknown field undefined"-
|
|
177
|
+
// Message, weil `typeof fn !== "boolean"` true ist), bevor der klare
|
|
178
|
+
// Function-Check hier überhaupt liefe.
|
|
179
|
+
validateActionWiring(feature);
|
|
180
|
+
validateFieldWiring(feature);
|
|
174
181
|
validateScreens(
|
|
175
182
|
feature,
|
|
176
183
|
featureMap,
|
|
@@ -56,7 +56,10 @@ defineFeature("todoList", (r) => {
|
|
|
56
56
|
r.defineEvent({
|
|
57
57
|
name: "taskCompleted",
|
|
58
58
|
schema: z.object({ id: z.string() }),
|
|
59
|
-
version:
|
|
59
|
+
version: 2,
|
|
60
|
+
migrations: {
|
|
61
|
+
"1": (old) => ({ ...old, done: true }),
|
|
62
|
+
},
|
|
60
63
|
});
|
|
61
64
|
|
|
62
65
|
r.writeHandler({
|
|
@@ -127,13 +130,6 @@ defineFeature("todoList", (r) => {
|
|
|
127
130
|
|
|
128
131
|
r.useExtension({ name: "auditLog", entity: "task" });
|
|
129
132
|
|
|
130
|
-
r.eventMigration({
|
|
131
|
-
event: "taskCompleted",
|
|
132
|
-
fromVersion: 1,
|
|
133
|
-
toVersion: 2,
|
|
134
|
-
transform: (old) => ({ ...old, done: true }),
|
|
135
|
-
});
|
|
136
|
-
|
|
137
133
|
r.job({
|
|
138
134
|
name: "cleanupExpired",
|
|
139
135
|
schedule: { cron: "0 3 * * *" },
|
|
@@ -232,7 +228,6 @@ describe("Canonical Object-Form — parser akzeptiert + extrahiert", () => {
|
|
|
232
228
|
"projection",
|
|
233
229
|
"multiStreamProjection",
|
|
234
230
|
"defineEvent",
|
|
235
|
-
"eventMigration",
|
|
236
231
|
];
|
|
237
232
|
for (const e of expected) {
|
|
238
233
|
expect(kinds.has(e), `expected kind "${e}"`).toBe(true);
|
|
@@ -283,12 +278,13 @@ describe("Canonical Object-Form — parser akzeptiert + extrahiert", () => {
|
|
|
283
278
|
expect(claim).toMatchObject({ kind: "claimKey", shortName: "teamId" });
|
|
284
279
|
});
|
|
285
280
|
|
|
286
|
-
test("defineEvent: name + version aus Object-Form", () => {
|
|
281
|
+
test("defineEvent: name + version + migrations aus Object-Form", () => {
|
|
287
282
|
const ev = result.patterns.find((p) => p.kind === "defineEvent");
|
|
288
283
|
expect(ev).toMatchObject({
|
|
289
284
|
kind: "defineEvent",
|
|
290
285
|
eventName: "taskCompleted",
|
|
291
|
-
version:
|
|
286
|
+
version: 2,
|
|
287
|
+
migrations: { "1": expect.anything() },
|
|
292
288
|
});
|
|
293
289
|
});
|
|
294
290
|
|
|
@@ -334,16 +330,6 @@ describe("Canonical Object-Form — parser akzeptiert + extrahiert", () => {
|
|
|
334
330
|
});
|
|
335
331
|
});
|
|
336
332
|
|
|
337
|
-
test("eventMigration: event + fromVersion + toVersion aus Object-Form", () => {
|
|
338
|
-
const em = result.patterns.find((p) => p.kind === "eventMigration");
|
|
339
|
-
expect(em).toMatchObject({
|
|
340
|
-
kind: "eventMigration",
|
|
341
|
-
eventName: "taskCompleted",
|
|
342
|
-
fromVersion: 1,
|
|
343
|
-
toVersion: 2,
|
|
344
|
-
});
|
|
345
|
-
});
|
|
346
|
-
|
|
347
333
|
test("job: name + handlerBody.raw enthält den Closure-Body", () => {
|
|
348
334
|
const job = result.patterns.find((p) => p.kind === "job");
|
|
349
335
|
expect(job).toMatchObject({ kind: "job", jobName: "cleanupExpired" });
|
|
@@ -961,19 +961,46 @@ defineFeature("f", (r) => {
|
|
|
961
961
|
});
|
|
962
962
|
});
|
|
963
963
|
|
|
964
|
-
describe("extractEventMigration", () => {
|
|
965
|
-
test("
|
|
964
|
+
describe("extractDefineEvent — migrations (formerly extractEventMigration)", () => {
|
|
965
|
+
test("positional-form migrations array captures the fromVersion→transform step", () => {
|
|
966
966
|
const result = parseInline(`
|
|
967
967
|
defineFeature("f", (r) => {
|
|
968
|
-
r.
|
|
968
|
+
r.defineEvent("incidentOpened", z.object({ id: z.string() }), {
|
|
969
|
+
version: 2,
|
|
970
|
+
migrations: [
|
|
971
|
+
{ fromVersion: 1, toVersion: 2, transform: (payload) => ({ ...payload, severity: "low" }) },
|
|
972
|
+
],
|
|
973
|
+
});
|
|
969
974
|
});
|
|
970
975
|
`);
|
|
971
976
|
|
|
972
977
|
expect(result.patterns[0]).toMatchObject({
|
|
973
|
-
kind: "
|
|
978
|
+
kind: "defineEvent",
|
|
974
979
|
eventName: "incidentOpened",
|
|
975
|
-
|
|
976
|
-
|
|
980
|
+
version: 2,
|
|
981
|
+
migrations: { "1": expect.anything() },
|
|
982
|
+
});
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
test("object-form migrations map captures the fromVersion→transform step", () => {
|
|
986
|
+
const result = parseInline(`
|
|
987
|
+
defineFeature("f", (r) => {
|
|
988
|
+
r.defineEvent({
|
|
989
|
+
name: "incidentOpened",
|
|
990
|
+
schema: z.object({ id: z.string() }),
|
|
991
|
+
version: 2,
|
|
992
|
+
migrations: {
|
|
993
|
+
"1": (payload) => ({ ...payload, severity: "low" }),
|
|
994
|
+
},
|
|
995
|
+
});
|
|
996
|
+
});
|
|
997
|
+
`);
|
|
998
|
+
|
|
999
|
+
expect(result.patterns[0]).toMatchObject({
|
|
1000
|
+
kind: "defineEvent",
|
|
1001
|
+
eventName: "incidentOpened",
|
|
1002
|
+
version: 2,
|
|
1003
|
+
migrations: { "1": expect.anything() },
|
|
977
1004
|
});
|
|
978
1005
|
});
|
|
979
1006
|
});
|
|
@@ -301,21 +301,16 @@ describe("patch coverage for the remaining pattern-kinds", () => {
|
|
|
301
301
|
expect(parseSourceFile(sf).patterns.find((p) => p.kind === "authClaims")).toBeUndefined();
|
|
302
302
|
});
|
|
303
303
|
|
|
304
|
-
test("add + remove via PatternId for
|
|
304
|
+
test("add + remove via PatternId for defineEvent with migrations", () => {
|
|
305
305
|
const sf = makeSourceFile(STARTER);
|
|
306
|
-
createFeaturePatcher(sf).
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
});
|
|
312
|
-
removePattern(sf, {
|
|
313
|
-
kind: "eventMigration",
|
|
314
|
-
eventName: "itemCreated",
|
|
315
|
-
fromVersion: 1,
|
|
316
|
-
toVersion: 2,
|
|
306
|
+
createFeaturePatcher(sf).addDefineEvent({
|
|
307
|
+
name: "itemCreated",
|
|
308
|
+
schemaSource: "z.object({ id: z.string() })",
|
|
309
|
+
version: 2,
|
|
310
|
+
migrations: { "1": "(old) => old" },
|
|
317
311
|
});
|
|
318
|
-
|
|
312
|
+
removePattern(sf, { kind: "defineEvent", eventName: "itemCreated" });
|
|
313
|
+
expect(parseSourceFile(sf).patterns.find((p) => p.kind === "defineEvent")).toBeUndefined();
|
|
319
314
|
});
|
|
320
315
|
});
|
|
321
316
|
|
|
@@ -172,33 +172,23 @@ describe("FeaturePatcher — typed add helpers for mixed (closure-bearing) patte
|
|
|
172
172
|
});
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
-
test("addDefineEvent
|
|
175
|
+
test("addDefineEvent with migrations forms a complete event-versioning chain", () => {
|
|
176
176
|
const sf = makeSourceFile(STARTER);
|
|
177
177
|
const p = createFeaturePatcher(sf);
|
|
178
178
|
p.addDefineEvent({
|
|
179
179
|
name: "stepCompleted",
|
|
180
180
|
schemaSource: "z.object({ id: z.string() })",
|
|
181
181
|
version: 2,
|
|
182
|
-
|
|
183
|
-
p.addEventMigration({
|
|
184
|
-
event: "stepCompleted",
|
|
185
|
-
fromVersion: 1,
|
|
186
|
-
toVersion: 2,
|
|
187
|
-
transformSource: '(old) => ({ id: old.id ?? "" })',
|
|
182
|
+
migrations: { "1": '(old) => ({ id: old.id ?? "" })' },
|
|
188
183
|
});
|
|
189
184
|
const result = parseSourceFile(sf);
|
|
190
185
|
expect(result.errors).toEqual([]);
|
|
191
|
-
expect(result.patterns).toHaveLength(
|
|
186
|
+
expect(result.patterns).toHaveLength(1);
|
|
192
187
|
expect(result.patterns[0]).toMatchObject({
|
|
193
188
|
kind: "defineEvent",
|
|
194
189
|
eventName: "stepCompleted",
|
|
195
190
|
version: 2,
|
|
196
|
-
|
|
197
|
-
expect(result.patterns[1]).toMatchObject({
|
|
198
|
-
kind: "eventMigration",
|
|
199
|
-
eventName: "stepCompleted",
|
|
200
|
-
fromVersion: 1,
|
|
201
|
-
toVersion: 2,
|
|
191
|
+
migrations: { "1": expect.anything() },
|
|
202
192
|
});
|
|
203
193
|
});
|
|
204
194
|
});
|