@cosmicdrift/kumiko-framework 0.64.0 → 0.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/__tests__/schema-cli-rebuild.integration.test.ts +176 -0
- package/src/db/__tests__/event-store-executor-list.integration.test.ts +65 -0
- package/src/db/event-store-executor.ts +51 -42
- package/src/engine/__tests__/build-app-schema-settings-hub.test.ts +49 -1
- package/src/engine/__tests__/build-app-schema.test.ts +21 -0
- package/src/engine/__tests__/build-config-feature-schema.test.ts +46 -0
- package/src/engine/__tests__/define-feature-entity-mapping.test.ts +47 -0
- package/src/engine/build-app-schema.ts +40 -5
- package/src/engine/build-config-feature-schema.ts +5 -1
- package/src/engine/entity-handlers.ts +11 -0
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
- package/src/engine/feature-ast/extractors/index.ts +1 -0
- package/src/engine/feature-ast/extractors/round5.ts +15 -0
- package/src/engine/feature-ast/parse.ts +3 -0
- package/src/engine/types/nav.ts +17 -0
- package/src/engine/types/screen.ts +8 -1
- package/src/pipeline/projection-rebuild.ts +2 -2
- package/src/schema-cli.ts +59 -1
- package/src/ui-types/index.ts +2 -0
- package/src/utils/__tests__/compare.test.ts +20 -0
- package/src/utils/__tests__/serialization.test.ts +24 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.66.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
"zod": "^4.4.3"
|
|
182
182
|
},
|
|
183
183
|
"devDependencies": {
|
|
184
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
184
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.65.0",
|
|
185
185
|
"bun-types": "^1.3.13",
|
|
186
186
|
"pino-pretty": "^13.1.3"
|
|
187
187
|
},
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// `schema apply` rebuilds the projections a freshly applied migration touched —
|
|
2
|
+
// the projection-rebuild step the per-app bin/kumiko.ts files duplicate today,
|
|
3
|
+
// folded into runSchemaCli behind the optional `features` option.
|
|
4
|
+
//
|
|
5
|
+
// Honest wiring test: seed one event, apply a migration carrying a hand-written
|
|
6
|
+
// .rebuild.json marker, assert the projection was actually replayed (the row
|
|
7
|
+
// reconstructed from the event log — not just a log line).
|
|
8
|
+
|
|
9
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { writeRebuildMarker } from "../db";
|
|
14
|
+
import { integer, table as pgTable, uuid } from "../db/dialect";
|
|
15
|
+
import { createEventStoreExecutor } from "../db/event-store-executor";
|
|
16
|
+
import { asRawClient, selectMany } from "../db/query";
|
|
17
|
+
import { buildEntityTable } from "../db/table-builder";
|
|
18
|
+
import { createTenantDb, type TenantDb } from "../db/tenant-db";
|
|
19
|
+
import { createEntity, createTextField, defineApply, defineFeature } from "../engine";
|
|
20
|
+
import type { ProjectionDefinition } from "../engine/types";
|
|
21
|
+
import { createEventsTable } from "../event-store";
|
|
22
|
+
import { createProjectionStateTable } from "../pipeline";
|
|
23
|
+
import { runSchemaCli, type SchemaCliOut } from "../schema-cli";
|
|
24
|
+
import {
|
|
25
|
+
createTestDb,
|
|
26
|
+
type TestDb,
|
|
27
|
+
TestUsers,
|
|
28
|
+
unsafeCreateEntityTable,
|
|
29
|
+
unsafePushTables,
|
|
30
|
+
} from "../stack";
|
|
31
|
+
import { ensureTemporalPolyfill } from "../time/polyfill";
|
|
32
|
+
|
|
33
|
+
const itemEntity = createEntity({
|
|
34
|
+
table: "read_apply_items",
|
|
35
|
+
fields: {
|
|
36
|
+
groupId: createTextField({ required: true }),
|
|
37
|
+
name: createTextField({ required: true }),
|
|
38
|
+
},
|
|
39
|
+
softDelete: true,
|
|
40
|
+
});
|
|
41
|
+
const itemTable = buildEntityTable("apply-item", itemEntity);
|
|
42
|
+
|
|
43
|
+
const COUNTER_TABLE = "read_apply_items_per_group";
|
|
44
|
+
const counterTable = pgTable(COUNTER_TABLE, {
|
|
45
|
+
groupId: uuid("group_id").primaryKey(),
|
|
46
|
+
tenantId: uuid("tenant_id").notNull(),
|
|
47
|
+
itemCount: integer("item_count").notNull().default(0),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
type ItemCreated = { groupId: string };
|
|
51
|
+
const counterProjection: ProjectionDefinition = {
|
|
52
|
+
name: "items-per-group",
|
|
53
|
+
source: "apply-item",
|
|
54
|
+
table: counterTable,
|
|
55
|
+
apply: {
|
|
56
|
+
"apply-item.created": defineApply<ItemCreated>(async (event, tx) => {
|
|
57
|
+
await asRawClient(tx).unsafe(
|
|
58
|
+
`INSERT INTO "${COUNTER_TABLE}" (group_id, tenant_id, item_count) VALUES ($1::uuid, $2::uuid, 1) ON CONFLICT (group_id) DO UPDATE SET item_count = ${COUNTER_TABLE}.item_count + 1`,
|
|
59
|
+
[event.payload.groupId, event.tenantId],
|
|
60
|
+
);
|
|
61
|
+
}),
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const feature = defineFeature("applyrebuildtest", (r) => {
|
|
66
|
+
r.entity("apply-item", itemEntity);
|
|
67
|
+
r.projection(counterProjection);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const admin = TestUsers.admin;
|
|
71
|
+
const executor = createEventStoreExecutor(itemTable, itemEntity, { entityName: "apply-item" });
|
|
72
|
+
|
|
73
|
+
function captureOut(): { out: SchemaCliOut; log: string[]; err: string[] } {
|
|
74
|
+
const log: string[] = [];
|
|
75
|
+
const err: string[] = [];
|
|
76
|
+
return { out: { log: (l) => log.push(l), err: (l) => err.push(l) }, log, err };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Writes an app workspace with a single trivial migration + an optional
|
|
80
|
+
// hand-written rebuild marker, bypassing the generate→managed-diff machinery
|
|
81
|
+
// (tested elsewhere) to exercise only the apply→marker→rebuild wiring.
|
|
82
|
+
function writeMigration(migrationId: string, rebuildTables: readonly string[] | null): string {
|
|
83
|
+
const appCwd = join(
|
|
84
|
+
tmpdir(),
|
|
85
|
+
`kumiko-apply-rebuild-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
86
|
+
);
|
|
87
|
+
const migrationsDir = join(appCwd, "kumiko/migrations");
|
|
88
|
+
mkdirSync(migrationsDir, { recursive: true });
|
|
89
|
+
writeFileSync(join(migrationsDir, `${migrationId}.sql`), "SELECT 1;\n");
|
|
90
|
+
if (rebuildTables) {
|
|
91
|
+
writeRebuildMarker(migrationsDir, `${migrationId}.sql`, rebuildTables);
|
|
92
|
+
}
|
|
93
|
+
return appCwd;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let testDb: TestDb;
|
|
97
|
+
let tdb: TenantDb;
|
|
98
|
+
let prevDbUrl: string | undefined;
|
|
99
|
+
|
|
100
|
+
beforeAll(async () => {
|
|
101
|
+
await ensureTemporalPolyfill();
|
|
102
|
+
testDb = await createTestDb();
|
|
103
|
+
await unsafeCreateEntityTable(testDb.db, itemEntity, "apply-item");
|
|
104
|
+
await createEventsTable(testDb.db);
|
|
105
|
+
await createProjectionStateTable(testDb.db);
|
|
106
|
+
await unsafePushTables(testDb.db, { applyItemsPerGroup: counterTable });
|
|
107
|
+
tdb = createTenantDb(testDb.db, admin.tenantId);
|
|
108
|
+
prevDbUrl = process.env["DATABASE_URL"];
|
|
109
|
+
const baseUrl =
|
|
110
|
+
process.env["TEST_DATABASE_URL"] ??
|
|
111
|
+
process.env["DATABASE_URL"] ??
|
|
112
|
+
"postgresql://kumiko:kumiko@localhost:15432/kumiko_test";
|
|
113
|
+
process.env["DATABASE_URL"] = baseUrl.replace(/\/[^/]+$/, `/${testDb.dbName}`);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
afterAll(async () => {
|
|
117
|
+
if (prevDbUrl !== undefined) process.env["DATABASE_URL"] = prevDbUrl;
|
|
118
|
+
else delete process.env["DATABASE_URL"];
|
|
119
|
+
await testDb?.cleanup();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
beforeEach(async () => {
|
|
123
|
+
await asRawClient(testDb.db).unsafe(
|
|
124
|
+
`TRUNCATE kumiko_events, read_apply_items, ${COUNTER_TABLE}, kumiko_projections RESTART IDENTITY CASCADE`,
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
async function counterFor(groupId: string): Promise<number | undefined> {
|
|
129
|
+
const [row] = await selectMany(testDb.db, counterTable, { groupId });
|
|
130
|
+
return row?.itemCount;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
describe("runSchemaCli apply — projection rebuild", () => {
|
|
134
|
+
const group = "00000000-0000-4000-8000-0000000000a1";
|
|
135
|
+
|
|
136
|
+
test("with features: applied migration's marker triggers a real rebuild", async () => {
|
|
137
|
+
await executor.create({ groupId: group, name: "x" }, admin, tdb);
|
|
138
|
+
// Pipeline not wired on this append → projection is empty until rebuild.
|
|
139
|
+
expect(await counterFor(group)).toBeUndefined();
|
|
140
|
+
|
|
141
|
+
const appCwd = writeMigration("0001_touch_counter", [COUNTER_TABLE]);
|
|
142
|
+
const cap = captureOut();
|
|
143
|
+
const code = await runSchemaCli(["apply"], appCwd, cap.out, { features: [feature] });
|
|
144
|
+
|
|
145
|
+
expect(code).toBe(0);
|
|
146
|
+
expect(cap.log.join("\n")).toContain("Rebuild 1 Projection");
|
|
147
|
+
expect(cap.log.join("\n")).toContain("(1 events");
|
|
148
|
+
// The row was reconstructed from the seeded event — not a no-op loop.
|
|
149
|
+
expect(await counterFor(group)).toBe(1);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("without features: marker present but no rebuild (dev path)", async () => {
|
|
153
|
+
await executor.create({ groupId: group, name: "x" }, admin, tdb);
|
|
154
|
+
|
|
155
|
+
const appCwd = writeMigration("0002_touch_counter", [COUNTER_TABLE]);
|
|
156
|
+
const cap = captureOut();
|
|
157
|
+
const code = await runSchemaCli(["apply"], appCwd, cap.out);
|
|
158
|
+
|
|
159
|
+
expect(code).toBe(0);
|
|
160
|
+
expect(cap.log.join("\n")).not.toContain("Rebuild");
|
|
161
|
+
// No rebuild ran → projection stays empty.
|
|
162
|
+
expect(await counterFor(group)).toBeUndefined();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("with features but no marker: apply succeeds, no rebuild", async () => {
|
|
166
|
+
await executor.create({ groupId: group, name: "x" }, admin, tdb);
|
|
167
|
+
|
|
168
|
+
const appCwd = writeMigration("0003_no_marker", null);
|
|
169
|
+
const cap = captureOut();
|
|
170
|
+
const code = await runSchemaCli(["apply"], appCwd, cap.out, { features: [feature] });
|
|
171
|
+
|
|
172
|
+
expect(code).toBe(0);
|
|
173
|
+
expect(cap.log.join("\n")).not.toContain("Rebuild");
|
|
174
|
+
expect(await counterFor(group)).toBeUndefined();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -256,6 +256,71 @@ describe("event-store-executor.list — filter (Tier 2.7c)", () => {
|
|
|
256
256
|
expect(res.rows).toHaveLength(4);
|
|
257
257
|
expect(res.total).toBe(4);
|
|
258
258
|
});
|
|
259
|
+
|
|
260
|
+
test("filters[] AND: zwei dynamische Filter werden mit AND verknüpft", async () => {
|
|
261
|
+
await seed(10);
|
|
262
|
+
const res = await exec.list(
|
|
263
|
+
{
|
|
264
|
+
limit: 50,
|
|
265
|
+
sort: "rank",
|
|
266
|
+
sortDirection: "asc",
|
|
267
|
+
filters: [
|
|
268
|
+
{ field: "rank", op: "gt", value: 2 },
|
|
269
|
+
{ field: "rank", op: "lt", value: 6 },
|
|
270
|
+
],
|
|
271
|
+
},
|
|
272
|
+
admin,
|
|
273
|
+
tdb,
|
|
274
|
+
);
|
|
275
|
+
expect(res.rows.map((r) => r["rank"])).toEqual([3, 4, 5]);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("filters[] in: Faceted-Multi-Select rank in [2,4,8]", async () => {
|
|
279
|
+
await seed(10);
|
|
280
|
+
const res = await exec.list(
|
|
281
|
+
{
|
|
282
|
+
limit: 50,
|
|
283
|
+
sort: "rank",
|
|
284
|
+
sortDirection: "asc",
|
|
285
|
+
filters: [{ field: "rank", op: "in", value: [2, 4, 8] }],
|
|
286
|
+
},
|
|
287
|
+
admin,
|
|
288
|
+
tdb,
|
|
289
|
+
);
|
|
290
|
+
expect(res.rows.map((r) => r["rank"])).toEqual([2, 4, 8]);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("statischer filter + dynamische filters[] kombinieren mit AND", async () => {
|
|
294
|
+
await seed(10);
|
|
295
|
+
const res = await exec.list(
|
|
296
|
+
{
|
|
297
|
+
limit: 50,
|
|
298
|
+
sort: "rank",
|
|
299
|
+
sortDirection: "asc",
|
|
300
|
+
filter: { field: "rank", op: "gt", value: 2 },
|
|
301
|
+
filters: [{ field: "rank", op: "in", value: [1, 3, 5, 9] }],
|
|
302
|
+
},
|
|
303
|
+
admin,
|
|
304
|
+
tdb,
|
|
305
|
+
);
|
|
306
|
+
// gt:2 AND in[1,3,5,9] → 3,5,9 (1 fällt durch gt:2 raus)
|
|
307
|
+
expect(res.rows.map((r) => r["rank"])).toEqual([3, 5, 9]);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("filters[] mit leerem in-Array: leeres Resultat (kein Match-All)", async () => {
|
|
311
|
+
await seed(5);
|
|
312
|
+
const res = await exec.list(
|
|
313
|
+
{
|
|
314
|
+
limit: 50,
|
|
315
|
+
sort: "rank",
|
|
316
|
+
sortDirection: "asc",
|
|
317
|
+
filters: [{ field: "rank", op: "in", value: [] }],
|
|
318
|
+
},
|
|
319
|
+
admin,
|
|
320
|
+
tdb,
|
|
321
|
+
);
|
|
322
|
+
expect(res.rows).toHaveLength(0);
|
|
323
|
+
});
|
|
259
324
|
});
|
|
260
325
|
|
|
261
326
|
describe("event-store-executor.list — runtime SearchAdapter (Tier 2.7e Audit-Fix #1)", () => {
|
|
@@ -200,6 +200,15 @@ export type EventStoreExecutor = {
|
|
|
200
200
|
readonly value: unknown;
|
|
201
201
|
}
|
|
202
202
|
| undefined;
|
|
203
|
+
// User-gewählte Faceted-Filter (dynamisch, additiv zum statischen
|
|
204
|
+
// `filter`). Alle werden mit AND verknüpft.
|
|
205
|
+
filters?:
|
|
206
|
+
| ReadonlyArray<{
|
|
207
|
+
readonly field: string;
|
|
208
|
+
readonly op: "eq" | "ne" | "lt" | "gt" | "in";
|
|
209
|
+
readonly value: unknown;
|
|
210
|
+
}>
|
|
211
|
+
| undefined;
|
|
203
212
|
},
|
|
204
213
|
user: SessionUser,
|
|
205
214
|
db: TenantDb,
|
|
@@ -875,45 +884,49 @@ export function createEventStoreExecutor(
|
|
|
875
884
|
whereSql.push(shifted.sqlText);
|
|
876
885
|
for (const p of shifted.params) params.push(p);
|
|
877
886
|
}
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
}
|
|
887
|
+
const applyFilter = (f: {
|
|
888
|
+
readonly field: string;
|
|
889
|
+
readonly op: "eq" | "ne" | "lt" | "gt" | "in";
|
|
890
|
+
readonly value: unknown;
|
|
891
|
+
}): void => {
|
|
892
|
+
if (table[f.field] === undefined) {
|
|
893
|
+
// skip: unknown field — not a real column, drop the filter (injection guard)
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
const screen = buildFilterWhere(f.field, f.op, f.value);
|
|
897
|
+
if (screen === null) {
|
|
898
|
+
whereSql.push("FALSE");
|
|
899
|
+
// skip: filter is unsatisfiable → emit FALSE, no params to bind
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
for (const [field, value] of Object.entries(screen)) {
|
|
903
|
+
if (Array.isArray(value)) {
|
|
904
|
+
const placeholders = value.map((v) => {
|
|
905
|
+
params.push(v);
|
|
906
|
+
return `$${params.length}`;
|
|
907
|
+
});
|
|
908
|
+
whereSql.push(`${colSql(field)} IN (${placeholders.join(", ")})`);
|
|
909
|
+
} else if (typeof value === "object" && value !== null) {
|
|
910
|
+
const opMap: Record<string, string> = {
|
|
911
|
+
gt: ">",
|
|
912
|
+
gte: ">=",
|
|
913
|
+
lt: "<",
|
|
914
|
+
lte: "<=",
|
|
915
|
+
ne: "<>",
|
|
916
|
+
};
|
|
917
|
+
for (const [opKey, opSym] of Object.entries(opMap)) {
|
|
918
|
+
if (!(opKey in value)) continue;
|
|
919
|
+
params.push((value as Record<string, unknown>)[opKey]);
|
|
920
|
+
whereSql.push(`${colSql(field)} ${opSym} $${params.length}`);
|
|
913
921
|
}
|
|
922
|
+
} else {
|
|
923
|
+
params.push(value);
|
|
924
|
+
whereSql.push(`${colSql(field)} = $${params.length}`);
|
|
914
925
|
}
|
|
915
926
|
}
|
|
916
|
-
}
|
|
927
|
+
};
|
|
928
|
+
if (payload.filter !== undefined) applyFilter(payload.filter);
|
|
929
|
+
if (payload.filters !== undefined) for (const f of payload.filters) applyFilter(f);
|
|
917
930
|
|
|
918
931
|
const orderByClause =
|
|
919
932
|
payload.sort && table[payload.sort]
|
|
@@ -930,12 +943,8 @@ export function createEventStoreExecutor(
|
|
|
930
943
|
const tableInfo = extractTableInfo(table);
|
|
931
944
|
const rows = rawRows.map((r) => coerceRow(rehydrateCompoundTypes(r, entity), tableInfo));
|
|
932
945
|
|
|
933
|
-
// list rows
|
|
934
|
-
//
|
|
935
|
-
// it's display-only and must never be used as an optimistic-lock base;
|
|
936
|
-
// edit flows reload via detail(), which reconciles the stream version.
|
|
937
|
-
// Sourcing a lock base from a list row would reintroduce the #336
|
|
938
|
-
// version_conflict. (#336)
|
|
946
|
+
// list rows carry the READ-ROW version (display-only), never an optimistic-lock
|
|
947
|
+
// base — edit flows reload via detail(), which reconciles the stream version.
|
|
939
948
|
if (entityCache && entityName && rows.length > 0) {
|
|
940
949
|
await entityCache.mset(
|
|
941
950
|
user.tenantId,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
2
|
import { validateBoot } from "../boot-validator";
|
|
3
3
|
import { buildAppSchema, findNonJsonSafePath } from "../build-app-schema";
|
|
4
4
|
import { SETTINGS_HUB_FEATURE, SETTINGS_HUB_WORKSPACE } from "../build-config-feature-schema";
|
|
@@ -199,3 +199,51 @@ describe("buildAppSchema — Settings-Hub inline placement", () => {
|
|
|
199
199
|
expect(() => validateBoot([typo])).toThrow(/references nav "config:nav:audience-systemm"/);
|
|
200
200
|
});
|
|
201
201
|
});
|
|
202
|
+
|
|
203
|
+
describe("buildAppSchema — dangling audience-ref dev-warning (#408/3)", () => {
|
|
204
|
+
// billing registriert nur system+tenant-Keys → audience-user wird NIE
|
|
205
|
+
// generiert. Eine Workspace, die config:nav:audience-user referenziert, boot't
|
|
206
|
+
// (Boot-Validator exempt't die Audience-QNs), aber der Eintrag rendert
|
|
207
|
+
// unsichtbar — der Dev muss eine Warnung sehen, nicht still nichts.
|
|
208
|
+
function warnsFor(scopes: string[]): string[] {
|
|
209
|
+
const prevEnv = process.env.NODE_ENV;
|
|
210
|
+
process.env.NODE_ENV = "development";
|
|
211
|
+
const warn = spyOn(console, "warn").mockImplementation(() => {});
|
|
212
|
+
try {
|
|
213
|
+
buildAppSchema(createRegistry([placingShell(...scopes), billing]));
|
|
214
|
+
return warn.mock.calls.map((c) => String(c[0]));
|
|
215
|
+
} finally {
|
|
216
|
+
warn.mockRestore();
|
|
217
|
+
if (prevEnv === undefined) {
|
|
218
|
+
delete process.env.NODE_ENV;
|
|
219
|
+
} else {
|
|
220
|
+
process.env.NODE_ENV = prevEnv;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
test("referencing a never-generated audience warns (dangling-ref)", () => {
|
|
226
|
+
const messages = warnsFor(["user"]);
|
|
227
|
+
expect(
|
|
228
|
+
messages.some((m) => m.includes("config:nav:audience-user") && m.includes("nie generiert")),
|
|
229
|
+
).toBe(true);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("referencing a generated audience does NOT trigger the dangling warning", () => {
|
|
233
|
+
// audience-tenant IS generated (billing has tenant keys) → kein dangling.
|
|
234
|
+
const messages = warnsFor(["tenant"]);
|
|
235
|
+
expect(messages.some((m) => m.includes("nie generiert"))).toBe(false);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("authoring warnings are suppressed under NODE_ENV=test — no CI-log noise (#408/1)", () => {
|
|
239
|
+
// bun:test setzt NODE_ENV=test; eine un-platzierte Audience (tenant bleibt
|
|
240
|
+
// bei placingShell("system") übrig) darf KEIN console.warn ins Log spülen.
|
|
241
|
+
const warn = spyOn(console, "warn").mockImplementation(() => {});
|
|
242
|
+
try {
|
|
243
|
+
buildAppSchema(createRegistry([placingShell("system"), billing]));
|
|
244
|
+
expect(warn).not.toHaveBeenCalled();
|
|
245
|
+
} finally {
|
|
246
|
+
warn.mockRestore();
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
});
|
|
@@ -134,6 +134,27 @@ describe("buildAppSchema", () => {
|
|
|
134
134
|
expect(fields["label"]?.["default"]).toBe("");
|
|
135
135
|
});
|
|
136
136
|
|
|
137
|
+
test("filterable kommt ins Client-Schema (steuert die Faceted-Filter)", () => {
|
|
138
|
+
const entity = {
|
|
139
|
+
fields: {
|
|
140
|
+
status: { type: "select", options: ["draft", "published"], filterable: true },
|
|
141
|
+
name: { type: "text" },
|
|
142
|
+
},
|
|
143
|
+
} as unknown as EntityDefinition;
|
|
144
|
+
const f = defineFeature("ent", (r) => {
|
|
145
|
+
r.entity("thing", entity);
|
|
146
|
+
});
|
|
147
|
+
const app = buildAppSchema(createRegistry([f]));
|
|
148
|
+
const fields = (
|
|
149
|
+
app.features[0]?.entities["thing"] as unknown as {
|
|
150
|
+
fields: Record<string, Record<string, unknown>>;
|
|
151
|
+
}
|
|
152
|
+
).fields;
|
|
153
|
+
expect(fields["status"]?.["filterable"]).toBe(true);
|
|
154
|
+
// Felder ohne filterable tragen den Key nicht (kein false-Müll).
|
|
155
|
+
expect(fields["name"]?.["filterable"]).toBeUndefined();
|
|
156
|
+
});
|
|
157
|
+
|
|
137
158
|
test("AppSchema ist via JSON.stringify roundtrip-sicher", () => {
|
|
138
159
|
// Echter Smoke-Test des Vertrags — wenn jemand in den project-
|
|
139
160
|
// Helper eine Function reinschmuggelt, würde das hier brennen.
|
|
@@ -201,6 +201,33 @@ describe("buildConfigFeatureSchema — per-role cascade (system > tenant > user)
|
|
|
201
201
|
});
|
|
202
202
|
});
|
|
203
203
|
|
|
204
|
+
describe("buildConfigFeatureSchema — machine-role does not leak into screen access (#406/2)", () => {
|
|
205
|
+
// Ein gemischter write-Set (machine "system" + human "SystemAdmin") entsteht
|
|
206
|
+
// nur über ein explizites write:-Override (kein Preset erzeugt den Mix). Der
|
|
207
|
+
// Screen darf trotzdem NUR die Human-Rolle gaten — "system" trägt kein User,
|
|
208
|
+
// ein Leak macht das Gate inkonsistent zum Hardening-Ziel.
|
|
209
|
+
const mixedWrite = defineFeature("mixed", (r) => {
|
|
210
|
+
r.config({
|
|
211
|
+
keys: {
|
|
212
|
+
apiKey: createSystemConfig("text", {
|
|
213
|
+
write: [...access.system, ...access.systemAdmin],
|
|
214
|
+
mask: { title: "mixed.api-key" },
|
|
215
|
+
}),
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("a key with write ['system','SystemAdmin'] yields a SystemAdmin-only screen (no 'system')", () => {
|
|
221
|
+
const s = buildConfigFeatureSchema(createRegistry([mixedWrite]));
|
|
222
|
+
const screen = s.screens.find((x) => x.id === "mixed-system");
|
|
223
|
+
if (!screen || screen.type !== "configEdit")
|
|
224
|
+
throw new Error("no mixed-system configEdit screen");
|
|
225
|
+
expect(screen.access).toEqual({ roles: ["SystemAdmin"] });
|
|
226
|
+
const roles = screen.access && "roles" in screen.access ? screen.access.roles : [];
|
|
227
|
+
expect(roles).not.toContain("system");
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
204
231
|
describe("buildConfigFeatureSchema — access + workspace", () => {
|
|
205
232
|
test("home-scope screens union write (edit) roles; an `all`-writable group collapses to openToAll", () => {
|
|
206
233
|
// billing tenant keys are createTenantConfig → write = admin roles
|
|
@@ -211,6 +238,25 @@ describe("buildConfigFeatureSchema — access + workspace", () => {
|
|
|
211
238
|
expect(configScreen("notify-user").access).toEqual({ openToAll: true });
|
|
212
239
|
});
|
|
213
240
|
|
|
241
|
+
test("a screen mixing an openToAll key with role-restricted keys collapses to openToAll", () => {
|
|
242
|
+
// unionAccessRules short-circuits: one openToAll writer opens the whole gate,
|
|
243
|
+
// even next to role-restricted keys — the mixed case the all-or-nothing tests miss.
|
|
244
|
+
const mixedWrite = defineFeature("mixedwrite", (r) => {
|
|
245
|
+
r.config({
|
|
246
|
+
keys: {
|
|
247
|
+
open: createTenantConfig("text", { write: access.all, mask: { title: "mw.open" } }),
|
|
248
|
+
gated: createTenantConfig("text", { mask: { title: "mw.gated" } }), // default admin write
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
const out = buildConfigFeatureSchema(createRegistry([mixedWrite]));
|
|
253
|
+
const screen = out.screens.find((s) => s.id === "mixedwrite-tenant");
|
|
254
|
+
if (screen?.type !== "configEdit")
|
|
255
|
+
throw new Error('expected configEdit screen "mixedwrite-tenant"');
|
|
256
|
+
expect(screen.access).toEqual({ openToAll: true });
|
|
257
|
+
expect(out.navs.find((n) => n.id === "audience-tenant")?.access).toEqual({ openToAll: true });
|
|
258
|
+
});
|
|
259
|
+
|
|
214
260
|
test("returns empty (no workspace) when no key opts into the hub via mask", () => {
|
|
215
261
|
const plain = defineFeature("plain", (r) => {
|
|
216
262
|
r.config({ keys: { secret: createSystemConfig("text", {}) } });
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { defineFeature } from "../define-feature";
|
|
4
|
+
import { createEntity, createTextField } from "../factories";
|
|
5
|
+
|
|
6
|
+
const noteEntity = createEntity({
|
|
7
|
+
table: "dfm_notes",
|
|
8
|
+
fields: { title: createTextField({ required: true }) },
|
|
9
|
+
});
|
|
10
|
+
const tagEntity = createEntity({
|
|
11
|
+
table: "dfm_tags",
|
|
12
|
+
fields: { label: createTextField({ required: true }) },
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const bareCreate = {
|
|
16
|
+
name: "create",
|
|
17
|
+
schema: z.object({ title: z.string() }),
|
|
18
|
+
access: { roles: ["User"] },
|
|
19
|
+
handler: async () => ({ isSuccess: true as const, data: {} }),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe("defineFeature — bare CRUD verb → entity mapping", () => {
|
|
23
|
+
test("single-entity fallback maps when the feature name differs from the entity name", () => {
|
|
24
|
+
const f = defineFeature("mynotes", (r) => {
|
|
25
|
+
r.entity("note", noteEntity);
|
|
26
|
+
r.writeHandler(bareCreate);
|
|
27
|
+
});
|
|
28
|
+
expect(f.handlerEntityMappings?.["create"]).toBe("note");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("feature-name match wins (the fallback is the secondary path)", () => {
|
|
32
|
+
const f = defineFeature("note", (r) => {
|
|
33
|
+
r.entity("note", noteEntity);
|
|
34
|
+
r.writeHandler(bareCreate);
|
|
35
|
+
});
|
|
36
|
+
expect(f.handlerEntityMappings?.["create"]).toBe("note");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("no fallback when the feature owns more than one entity", () => {
|
|
40
|
+
const f = defineFeature("mixed", (r) => {
|
|
41
|
+
r.entity("note", noteEntity);
|
|
42
|
+
r.entity("tag", tagEntity);
|
|
43
|
+
r.writeHandler(bareCreate);
|
|
44
|
+
});
|
|
45
|
+
expect(f.handlerEntityMappings?.["create"]).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -79,6 +79,7 @@ export function buildAppSchema(registry: Registry): AppSchema {
|
|
|
79
79
|
workspaces = placed.workspaces;
|
|
80
80
|
if (placed.standalone !== undefined) workspaces.push(placed.standalone);
|
|
81
81
|
warnUnplacedAudiences(placed.unplaced);
|
|
82
|
+
warnDanglingAudienceRefs(placed.danglingRefs);
|
|
82
83
|
}
|
|
83
84
|
}
|
|
84
85
|
|
|
@@ -133,7 +134,12 @@ function mergeSettingsHubIntoConfigFeature(
|
|
|
133
134
|
function placeSettingsHub(
|
|
134
135
|
appWorkspaces: readonly WorkspaceSchema[],
|
|
135
136
|
generated: ConfigFeatureSchema,
|
|
136
|
-
): {
|
|
137
|
+
): {
|
|
138
|
+
workspaces: WorkspaceSchema[];
|
|
139
|
+
standalone: WorkspaceSchema | undefined;
|
|
140
|
+
unplaced: string[];
|
|
141
|
+
danglingRefs: string[];
|
|
142
|
+
} {
|
|
137
143
|
const prefix = `${SETTINGS_HUB_FEATURE}:nav:`;
|
|
138
144
|
const audienceShortIds = new Set<string>();
|
|
139
145
|
const childParent = new Map<string, string>();
|
|
@@ -150,12 +156,20 @@ function placeSettingsHub(
|
|
|
150
156
|
}
|
|
151
157
|
|
|
152
158
|
const placedAudiences = new Set<string>();
|
|
159
|
+
// Config-Hub-Navs, die eine Workspace referenziert, die aber nie generiert
|
|
160
|
+
// wurden (weder Audience noch bekanntes Kind) — z.B. `config:nav:audience-user`
|
|
161
|
+
// ohne registrierte User-Scope-Config-Keys. Sonst verschwindet die Referenz
|
|
162
|
+
// lautlos (silent-skip).
|
|
163
|
+
const danglingRefs = new Set<string>();
|
|
153
164
|
const workspaces = appWorkspaces.map((ws) => {
|
|
154
165
|
const additions: string[] = [];
|
|
155
166
|
for (const member of ws.navMembers) {
|
|
156
167
|
if (!member.startsWith(prefix)) continue;
|
|
157
168
|
const shortId = member.slice(prefix.length);
|
|
158
|
-
if (!audienceShortIds.has(shortId))
|
|
169
|
+
if (!audienceShortIds.has(shortId)) {
|
|
170
|
+
if (!childParent.has(shortId)) danglingRefs.add(shortId);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
159
173
|
placedAudiences.add(shortId);
|
|
160
174
|
for (const child of childrenByAudience.get(shortId) ?? []) {
|
|
161
175
|
const childQn = `${prefix}${child}`;
|
|
@@ -180,14 +194,16 @@ function placeSettingsHub(
|
|
|
180
194
|
|
|
181
195
|
const unplaced =
|
|
182
196
|
placedAudiences.size > 0 ? [...audienceShortIds].filter((id) => !placedAudiences.has(id)) : [];
|
|
183
|
-
return { workspaces, standalone, unplaced };
|
|
197
|
+
return { workspaces, standalone, unplaced, danglingRefs: [...danglingRefs] };
|
|
184
198
|
}
|
|
185
199
|
|
|
186
200
|
function warnUnplacedAudiences(unplaced: readonly string[]): void {
|
|
187
201
|
// skip: every audience placed — nothing to warn about
|
|
188
202
|
if (unplaced.length === 0) return;
|
|
189
|
-
|
|
190
|
-
|
|
203
|
+
const env = typeof process !== "undefined" ? process.env.NODE_ENV : undefined;
|
|
204
|
+
// skip: dev-only authoring hint — silent in production and in tests
|
|
205
|
+
// (bun:test sets NODE_ENV=test) where it would only noise up CI logs.
|
|
206
|
+
if (env === "production" || env === "test") return;
|
|
191
207
|
// biome-ignore lint/suspicious/noConsole: dev-only authoring hint
|
|
192
208
|
console.warn(
|
|
193
209
|
`[kumiko] Settings-Hub: ${unplaced.join(", ")} nicht in einer App-Workspace platziert — ` +
|
|
@@ -197,6 +213,22 @@ function warnUnplacedAudiences(unplaced: readonly string[]): void {
|
|
|
197
213
|
);
|
|
198
214
|
}
|
|
199
215
|
|
|
216
|
+
function warnDanglingAudienceRefs(dangling: readonly string[]): void {
|
|
217
|
+
// skip: no dangling refs — nothing to warn about
|
|
218
|
+
if (dangling.length === 0) return;
|
|
219
|
+
const env = typeof process !== "undefined" ? process.env.NODE_ENV : undefined;
|
|
220
|
+
// skip: dev-only authoring hint — silent in production and in tests
|
|
221
|
+
if (env === "production" || env === "test") return;
|
|
222
|
+
// biome-ignore lint/suspicious/noConsole: dev-only authoring hint
|
|
223
|
+
console.warn(
|
|
224
|
+
`[kumiko] Settings-Hub: ${dangling
|
|
225
|
+
.map((id) => `${SETTINGS_HUB_FEATURE}:nav:${id}`)
|
|
226
|
+
.join(", ")} in einer Workspace referenziert, aber nie generiert — ` +
|
|
227
|
+
`keine Config-Keys für diesen Scope registriert. Tippfehler oder ` +
|
|
228
|
+
`vorzeitige Referenz? Der Eintrag rendert sonst unsichtbar.`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
200
232
|
// PlatformComponent slots ({ react, native }) legitimately hold component
|
|
201
233
|
// functions — JSON.stringify drops them at inject-time and the client
|
|
202
234
|
// re-resolves by name, so the walker treats them as opaque.
|
|
@@ -279,6 +311,9 @@ function projectField(fieldDef: FieldDefinition): FieldDefinition {
|
|
|
279
311
|
if (typeof def["type"] === "string") out["type"] = def["type"];
|
|
280
312
|
if (typeof def["required"] === "boolean") out["required"] = def["required"];
|
|
281
313
|
if (typeof def["sortable"] === "boolean") out["sortable"] = def["sortable"];
|
|
314
|
+
// filterable steuert die Faceted-Filter-Dropdowns im Renderer (select/
|
|
315
|
+
// boolean) — muss daher ins Client-Schema.
|
|
316
|
+
if (typeof def["filterable"] === "boolean") out["filterable"] = def["filterable"];
|
|
282
317
|
if (isLiteral(def["default"])) out["default"] = def["default"];
|
|
283
318
|
// Select: options-Liste ist plain JSON, durchschicken.
|
|
284
319
|
if (Array.isArray(def["options"])) out["options"] = def["options"];
|
|
@@ -141,7 +141,11 @@ function scopedKeysAt(masked: readonly MaskedKey[], scope: ConfigScope): ScopedK
|
|
|
141
141
|
const out: ScopedKey[] = [];
|
|
142
142
|
for (const key of masked) {
|
|
143
143
|
const roles = effectiveWriteRoles(key.def, scope);
|
|
144
|
-
|
|
144
|
+
// MACHINE_WRITE_ROLE aus den Screen-Rollen strippen: ein gemischter
|
|
145
|
+
// write-Set (z.B. ["system", "SystemAdmin"]) darf "system" nicht ins
|
|
146
|
+
// Screen-Access-Gate leaken — analog zum machine-gefilterten Workspace-Gate.
|
|
147
|
+
const humanRoles = roles.filter((r) => r !== MACHINE_WRITE_ROLE);
|
|
148
|
+
if (humanRoles.length > 0) out.push({ key, roles: humanRoles });
|
|
145
149
|
}
|
|
146
150
|
return out;
|
|
147
151
|
}
|
|
@@ -95,6 +95,17 @@ const listSchema = z.object({
|
|
|
95
95
|
value: z.unknown(),
|
|
96
96
|
})
|
|
97
97
|
.optional(),
|
|
98
|
+
// User-gewählte Faceted-Filter (dynamisch). Additiv zum statischen
|
|
99
|
+
// `filter` — executor.list verknüpft alle mit AND.
|
|
100
|
+
filters: z
|
|
101
|
+
.array(
|
|
102
|
+
z.object({
|
|
103
|
+
field: z.string(),
|
|
104
|
+
op: z.enum(["eq", "ne", "lt", "gt", "in"]),
|
|
105
|
+
value: z.unknown(),
|
|
106
|
+
}),
|
|
107
|
+
)
|
|
108
|
+
.optional(),
|
|
98
109
|
});
|
|
99
110
|
|
|
100
111
|
function parseHandlerName<TVerb extends string>(
|
|
@@ -59,7 +59,7 @@ const FEATURES: readonly RealFeature[] = [
|
|
|
59
59
|
path: "packages/bundled-features/src/sessions/feature.ts",
|
|
60
60
|
expectedFeatureName: "sessions",
|
|
61
61
|
recognisedKinds: ["entityHook"],
|
|
62
|
-
errorMethodNames: ["
|
|
62
|
+
errorMethodNames: ["unmanagedTable", "writeHandler", "queryHandler", "job"],
|
|
63
63
|
},
|
|
64
64
|
{
|
|
65
65
|
path: "packages/bundled-features/src/auth-email-password/feature.ts",
|
|
@@ -94,3 +94,18 @@ export function extractExposesApi(
|
|
|
94
94
|
apiName: arg.getLiteralValue(),
|
|
95
95
|
});
|
|
96
96
|
}
|
|
97
|
+
|
|
98
|
+
export function extractUnmanagedTable(
|
|
99
|
+
call: CallExpression,
|
|
100
|
+
sourceFile: SourceFile,
|
|
101
|
+
): ExtractOutput<never> {
|
|
102
|
+
// The meta argument is always a factory call (defineUnmanagedTable /
|
|
103
|
+
// buildEntityTableMeta) or a captured identifier — never an inline literal,
|
|
104
|
+
// so there is nothing to extract statically. A clean ParseError (not
|
|
105
|
+
// UnknownPattern) marks it design-time-unreadable, like entity-by-identifier.
|
|
106
|
+
return fail(
|
|
107
|
+
"unmanagedTable",
|
|
108
|
+
sourceLocationFromNode(call, sourceFile),
|
|
109
|
+
"unmanagedTable meta is a factory/identifier argument, not a static literal",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
extractTranslations,
|
|
59
59
|
extractTree,
|
|
60
60
|
extractTreeActions,
|
|
61
|
+
extractUnmanagedTable,
|
|
61
62
|
extractUseExtension,
|
|
62
63
|
extractUsesApi,
|
|
63
64
|
extractWorkspace,
|
|
@@ -358,6 +359,8 @@ function dispatchExtractor(
|
|
|
358
359
|
return extractUsesApi(call, sourceFile);
|
|
359
360
|
case "exposesApi":
|
|
360
361
|
return extractExposesApi(call, sourceFile);
|
|
362
|
+
case "unmanagedTable":
|
|
363
|
+
return extractUnmanagedTable(call, sourceFile);
|
|
361
364
|
// Round 6 — Visual-Tree patterns
|
|
362
365
|
case "treeActions":
|
|
363
366
|
return extractTreeActions(call, sourceFile);
|
package/src/engine/types/nav.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { AccessRule } from "./handlers";
|
|
2
|
+
import type { TargetRef } from "./target-ref";
|
|
3
|
+
import type { TreeAction } from "./tree-node";
|
|
2
4
|
|
|
3
5
|
// Nav entry declaration. Every feature that wants to appear in the app's
|
|
4
6
|
// navigation tree registers one or more entries via r.nav(). The engine
|
|
@@ -34,6 +36,21 @@ export type NavDefinition = {
|
|
|
34
36
|
// ("<feature>:screen:<id>"). Omit for pure grouping entries (a parent-only
|
|
35
37
|
// nav node that renders a sub-tree but has no target screen itself).
|
|
36
38
|
readonly screen?: string;
|
|
39
|
+
// Polymorphes Klick-Ziel (öffnet die EditorPanel-Maske via Target-
|
|
40
|
+
// Resolver) — Alternative zu `screen`. Ein Knoten trägt screen XOR
|
|
41
|
+
// target; der Renderer dispatcht das target statt einen Route-Link zu
|
|
42
|
+
// rendern. Gespiegelt aus dem alten Visual-Tree (TreeNode.target).
|
|
43
|
+
readonly target?: TargetRef;
|
|
44
|
+
// Hover-Actions rechts in der Zeile (VS-Code-Pattern) — erst bei Hover
|
|
45
|
+
// sichtbar. Reihenfolge wie deklariert.
|
|
46
|
+
readonly actions?: readonly TreeAction[];
|
|
47
|
+
// „+"-Affordance am Knoten. Klick dispatcht createAction.target; der
|
|
48
|
+
// Provider weiß was „leer befüllen" für ihn heißt (neuer Page-Slug etc.).
|
|
49
|
+
readonly createAction?: TreeAction;
|
|
50
|
+
// Children kommen zur Laufzeit aus einem registrierten nav-provider
|
|
51
|
+
// (lazy beim Ausklappen, SSE-live via treeEntities), keyed auf diese
|
|
52
|
+
// Nav-QN. Macht den Knoten expandable auch ohne statische children.
|
|
53
|
+
readonly provider?: boolean;
|
|
37
54
|
// Role / openToAll gate. The nav resolver hides entries the user can't
|
|
38
55
|
// reach; leave unset to always show (engine stays un-opinionated about
|
|
39
56
|
// who sees what — apps that need default-deny can set { roles: [] }).
|
|
@@ -309,7 +309,10 @@ export type EditSectionSpec = EditFieldsSection | EditExtensionSection;
|
|
|
309
309
|
|
|
310
310
|
export type EditFieldsSection = {
|
|
311
311
|
readonly kind?: "fields";
|
|
312
|
-
|
|
312
|
+
/** Optional. Ohne Titel rendert die Section nur ihre Felder (keine h3-
|
|
313
|
+
* Überschrift) — für flache Forms (Card-Titel + Felder direkt, ein
|
|
314
|
+
* einzelner Abschnitt) wie bei den meisten shadcn-Form-Mustern. */
|
|
315
|
+
readonly title?: string;
|
|
313
316
|
readonly columns?: number;
|
|
314
317
|
readonly fields: readonly EditFieldSpec[];
|
|
315
318
|
};
|
|
@@ -333,6 +336,10 @@ export type EntityEditScreenDefinition = {
|
|
|
333
336
|
readonly type: "entityEdit";
|
|
334
337
|
readonly entity: string;
|
|
335
338
|
readonly layout: EditLayout;
|
|
339
|
+
/** Optionaler i18n-Key (oder Roh-String) für den Submit-Button. Default
|
|
340
|
+
* `kumiko.actions.save`. Lässt den Auto-Edit-Screen domain-spezifische
|
|
341
|
+
* CTAs zeigen ("Save Address", "Create item") statt generisch "Speichern". */
|
|
342
|
+
readonly submitLabel?: string;
|
|
336
343
|
/** Default true. `false` für Entities deren Create über einen eigenen
|
|
337
344
|
* Lifecycle-Write läuft (z.B. incident:open mit Event-Stream + Joins)
|
|
338
345
|
* statt über `<entity>:create`: unterdrückt den automatischen
|
|
@@ -146,8 +146,8 @@ type RebuildDeps = {
|
|
|
146
146
|
// Test-only seam: fires once after the unlocked bulk drain and before the
|
|
147
147
|
// cutover fence. Lets a concurrency test inject a committed write into the
|
|
148
148
|
// replay window deterministically. The `__test_` prefix marks it test-only:
|
|
149
|
-
// a
|
|
150
|
-
//
|
|
149
|
+
// a slow callback here delays the cutover fence acquisition, widening the
|
|
150
|
+
// window in which concurrent writers can still commit below the cursor.
|
|
151
151
|
readonly __test_onBeforeFence?: () => void | Promise<void>;
|
|
152
152
|
};
|
|
153
153
|
|
package/src/schema-cli.ts
CHANGED
|
@@ -13,10 +13,12 @@ import { join, resolve as resolvePath } from "node:path";
|
|
|
13
13
|
import {
|
|
14
14
|
baselineMigrations,
|
|
15
15
|
createDbConnection,
|
|
16
|
+
type DbConnection,
|
|
16
17
|
fetchAppliedMigrations,
|
|
17
18
|
generateMigration,
|
|
18
19
|
loadMigrationsFromDir,
|
|
19
20
|
loadSnapshotJson,
|
|
21
|
+
readRebuildMarker,
|
|
20
22
|
rebuildTablesFromDiff,
|
|
21
23
|
type renderTablesDdl,
|
|
22
24
|
runMigrationsFromDir,
|
|
@@ -25,9 +27,15 @@ import {
|
|
|
25
27
|
writeSnapshotJson,
|
|
26
28
|
} from "./db";
|
|
27
29
|
import { validateBoot } from "./engine/boot-validator";
|
|
30
|
+
import { createRegistry } from "./engine/registry";
|
|
28
31
|
import type { FeatureDefinition } from "./engine/types/feature";
|
|
29
32
|
import { createEventsTable } from "./event-store";
|
|
30
|
-
import {
|
|
33
|
+
import { buildProjectionTableIndex } from "./migrations";
|
|
34
|
+
import {
|
|
35
|
+
createEventConsumerStateTable,
|
|
36
|
+
createProjectionStateTable,
|
|
37
|
+
rebuildProjection,
|
|
38
|
+
} from "./pipeline";
|
|
31
39
|
|
|
32
40
|
export type SchemaCliOut = {
|
|
33
41
|
readonly log: (line: string) => void;
|
|
@@ -63,6 +71,40 @@ function nextSequenceNumber(migrationsDir: string): number {
|
|
|
63
71
|
return max + 1;
|
|
64
72
|
}
|
|
65
73
|
|
|
74
|
+
// Maps changed tables to their projections (via the app registry) and replays
|
|
75
|
+
// the events. Tables without a registered projection are skipped.
|
|
76
|
+
async function rebuildAffectedProjections(
|
|
77
|
+
db: DbConnection,
|
|
78
|
+
changedTables: readonly string[],
|
|
79
|
+
features: readonly FeatureDefinition[],
|
|
80
|
+
out: SchemaCliOut,
|
|
81
|
+
): Promise<void> {
|
|
82
|
+
const registry = createRegistry(features);
|
|
83
|
+
const tableToProjection = buildProjectionTableIndex(registry);
|
|
84
|
+
|
|
85
|
+
const projections = new Set<string>();
|
|
86
|
+
for (const table of changedTables) {
|
|
87
|
+
const name = tableToProjection.get(table);
|
|
88
|
+
if (name) projections.add(name);
|
|
89
|
+
}
|
|
90
|
+
// skip: no changed table maps to a registered projection — nothing to rebuild.
|
|
91
|
+
if (projections.size === 0) return;
|
|
92
|
+
|
|
93
|
+
out.log(` Rebuild ${projections.size} Projection(s)…`);
|
|
94
|
+
for (const name of projections) {
|
|
95
|
+
const r = await rebuildProjection(name, { db, registry });
|
|
96
|
+
out.log(` ↻ ${name} (${r.eventsProcessed} events, ${r.durationMs}ms)`);
|
|
97
|
+
}
|
|
98
|
+
out.log("");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type RunSchemaCliOptions = {
|
|
102
|
+
/** Composed app features. When given, `apply` rebuilds the projections whose
|
|
103
|
+
* tables a freshly applied migration changed (via its `.rebuild.json`
|
|
104
|
+
* marker). Omitted (dev `kumiko schema`) → no rebuild, migrations only. */
|
|
105
|
+
readonly features?: readonly FeatureDefinition[];
|
|
106
|
+
};
|
|
107
|
+
|
|
66
108
|
/**
|
|
67
109
|
* Runs a schema-CLI subcommand. `appCwd` is the app workspace root (where
|
|
68
110
|
* `kumiko/schema.ts` + `kumiko/migrations/` live). Returns a process exit code.
|
|
@@ -71,6 +113,7 @@ export async function runSchemaCli(
|
|
|
71
113
|
argv: readonly string[],
|
|
72
114
|
appCwd: string,
|
|
73
115
|
out: SchemaCliOut,
|
|
116
|
+
options: RunSchemaCliOptions = {},
|
|
74
117
|
): Promise<number> {
|
|
75
118
|
const sub = argv[0];
|
|
76
119
|
const schemaFile = resolvePath(appCwd, "kumiko/schema.ts");
|
|
@@ -235,6 +278,21 @@ export async function runSchemaCli(
|
|
|
235
278
|
if (result.skipped.length > 0) out.log(` (${result.skipped.length} already applied)`);
|
|
236
279
|
}
|
|
237
280
|
out.log("");
|
|
281
|
+
|
|
282
|
+
// Projection-rebuild for tables a freshly applied migration changed
|
|
283
|
+
// (marker NNNN_<name>.rebuild.json from `generate`). Without it read_*
|
|
284
|
+
// projections stay stale after a schema change. Needs the composed
|
|
285
|
+
// feature set → only when the caller passed `features` (the app bin);
|
|
286
|
+
// the dev CLI omits it and applies migrations only.
|
|
287
|
+
if (options.features && result.applied.length > 0) {
|
|
288
|
+
const changedTables = new Set<string>();
|
|
289
|
+
for (const id of result.applied) {
|
|
290
|
+
for (const table of readRebuildMarker(migrationsDir, id)) changedTables.add(table);
|
|
291
|
+
}
|
|
292
|
+
if (changedTables.size > 0) {
|
|
293
|
+
await rebuildAffectedProjections(db, [...changedTables], options.features, out);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
238
296
|
return 0;
|
|
239
297
|
} catch (e) {
|
|
240
298
|
out.err("");
|
package/src/ui-types/index.ts
CHANGED
|
@@ -74,5 +74,7 @@ export {
|
|
|
74
74
|
normalizeEditField,
|
|
75
75
|
normalizeListColumn,
|
|
76
76
|
} from "../engine/types/screen";
|
|
77
|
+
export type { TargetRef } from "../engine/types/target-ref";
|
|
78
|
+
export type { TreeAction, TreeNode, TreeNodeState } from "../engine/types/tree-node";
|
|
77
79
|
export type { WorkspaceDefinition } from "../engine/types/workspace";
|
|
78
80
|
export type { AppSchema, FeatureSchema, WorkspaceSchema } from "./app-schema";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { compareByCodepoint } from "../compare";
|
|
3
|
+
|
|
4
|
+
describe("compareByCodepoint", () => {
|
|
5
|
+
test("returns -1 / 1 / 0 for less / greater / equal", () => {
|
|
6
|
+
expect(compareByCodepoint("a", "b")).toBe(-1);
|
|
7
|
+
expect(compareByCodepoint("b", "a")).toBe(1);
|
|
8
|
+
expect(compareByCodepoint("x", "x")).toBe(0);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("orders by UTF-16 code unit — uppercase sorts before lowercase", () => {
|
|
12
|
+
// 'Z' (90) < 'a' (97): the opposite of many locale collations, which is
|
|
13
|
+
// the whole point (#330 — stable across macOS dev box and Linux CI).
|
|
14
|
+
expect(compareByCodepoint("Z", "a")).toBe(-1);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("is a usable Array.sort comparator yielding codepoint order", () => {
|
|
18
|
+
expect(["b", "A", "a", "B"].sort(compareByCodepoint)).toEqual(["A", "B", "a", "b"]);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { parseRoles } from "../serialization";
|
|
3
|
+
|
|
4
|
+
describe("parseRoles", () => {
|
|
5
|
+
test("returns an array input unchanged", () => {
|
|
6
|
+
expect(parseRoles(["Admin", "Viewer"])).toEqual(["Admin", "Viewer"]);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("parses a JSON-string array", () => {
|
|
10
|
+
expect(parseRoles('["Admin","Viewer"]')).toEqual(["Admin", "Viewer"]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("falls back to [] for a malformed JSON string", () => {
|
|
14
|
+
expect(parseRoles("not json")).toEqual([]);
|
|
15
|
+
expect(parseRoles("")).toEqual([]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("falls back to [] for non-string / non-array inputs", () => {
|
|
19
|
+
expect(parseRoles(null)).toEqual([]);
|
|
20
|
+
expect(parseRoles(undefined)).toEqual([]);
|
|
21
|
+
expect(parseRoles(42)).toEqual([]);
|
|
22
|
+
expect(parseRoles({ roles: ["Admin"] })).toEqual([]);
|
|
23
|
+
});
|
|
24
|
+
});
|