@cosmicdrift/kumiko-framework 0.52.0 → 0.55.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/db/queries/__tests__/shadow-swap.test.ts +30 -0
- package/src/db/queries/projection-rebuild.ts +11 -3
- package/src/db/queries/shadow-swap.ts +118 -0
- package/src/engine/__tests__/build-app-schema-settings-hub.test.ts +86 -3
- package/src/engine/__tests__/build-config-feature-schema.test.ts +99 -12
- package/src/engine/__tests__/schema-builder-date-bounds.test.ts +47 -0
- package/src/engine/boot-validator/screens-nav.ts +11 -0
- package/src/engine/build-app-schema.ts +83 -6
- package/src/engine/build-config-feature-schema.ts +76 -23
- package/src/engine/schema-builder.ts +30 -2
- package/src/engine/types/fields.ts +24 -0
- package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +247 -9
- package/src/pipeline/msp-rebuild.ts +20 -12
- package/src/pipeline/projection-rebuild.ts +160 -84
- package/src/testing/__tests__/e2e-generator.test.ts +32 -0
- package/src/testing/e2e-generator.ts +5 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.55.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { EntityTableMeta } from "../../entity-table-meta";
|
|
3
|
+
import { rebuildMetaOrThrow } from "../shadow-swap";
|
|
4
|
+
|
|
5
|
+
const cleanMeta: EntityTableMeta = {
|
|
6
|
+
tableName: "read_x",
|
|
7
|
+
source: "managed",
|
|
8
|
+
columns: [{ name: "id", pgType: "uuid", notNull: true, primaryKey: true }],
|
|
9
|
+
indexes: [{ name: "read_x_tenant_id_idx", columns: ["tenant_id"] }],
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
describe("rebuildMetaOrThrow", () => {
|
|
13
|
+
test("returns the resolved meta for a clean projection table", () => {
|
|
14
|
+
expect(rebuildMetaOrThrow(cleanMeta, "feat:projection:x")).toBe(cleanMeta);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("throws when the table object carries no resolvable EntityTableMeta", () => {
|
|
18
|
+
expect(() => rebuildMetaOrThrow({}, "feat:projection:x")).toThrow(
|
|
19
|
+
/no resolvable EntityTableMeta/,
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("rejects a meta-inexpressible partial index instead of silently dropping it", () => {
|
|
24
|
+
const meta: EntityTableMeta = {
|
|
25
|
+
...cleanMeta,
|
|
26
|
+
indexes: [{ name: "read_x_active_idx", columns: ["status"], needsManualWhere: true }],
|
|
27
|
+
};
|
|
28
|
+
expect(() => rebuildMetaOrThrow(meta, "feat:projection:x")).toThrow(/partial index/);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -12,17 +12,25 @@ export async function markProjectionRebuilding(db: AnyDb, projectionName: string
|
|
|
12
12
|
);
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
// Cursor-paged read for the live-tail catch-up loop: one batch of events
|
|
16
|
+
// strictly newer than `afterId`, ascending. Each call is a fresh SELECT, so
|
|
17
|
+
// under READ COMMITTED it sees events committed by concurrent writers since the
|
|
18
|
+
// previous batch — that is what lets the rebuild drain the tail to ~0 lag.
|
|
19
|
+
export async function selectEventsForProjectionRebuildBatch(
|
|
16
20
|
db: AnyDb,
|
|
17
21
|
aggregateTypes: readonly string[],
|
|
18
22
|
eventTypes: readonly string[],
|
|
23
|
+
afterId: bigint,
|
|
24
|
+
limit: number,
|
|
19
25
|
): Promise<ReadonlyArray<Record<string, unknown>>> {
|
|
20
26
|
return (await asRawClient(db).unsafe(
|
|
21
27
|
`SELECT * FROM "kumiko_events"
|
|
22
28
|
WHERE "aggregate_type" = ANY($1::text[])
|
|
23
29
|
AND "type" = ANY($2::text[])
|
|
24
|
-
|
|
25
|
-
|
|
30
|
+
AND "id" > $3
|
|
31
|
+
ORDER BY "id" ASC
|
|
32
|
+
LIMIT $4`,
|
|
33
|
+
[aggregateTypes, eventTypes, afterId, limit],
|
|
26
34
|
)) as ReadonlyArray<Record<string, unknown>>;
|
|
27
35
|
}
|
|
28
36
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Online projection rebuild via a transient shadow schema.
|
|
2
|
+
//
|
|
3
|
+
// Why a shadow SCHEMA and not a `read_foo__rebuild` table: apply(event, tx)
|
|
4
|
+
// writes through the projection's canonical table object → an unqualified
|
|
5
|
+
// `read_foo`. A captured table reference can't be re-pointed, so we redirect
|
|
6
|
+
// NAME RESOLUTION instead of the write: build the shadow under the SAME name
|
|
7
|
+
// in a private schema, point `search_path` there for the rebuild tx, and apply
|
|
8
|
+
// lands in the shadow untouched. The live `read_foo` keeps serving reads and
|
|
9
|
+
// writes for the whole replay — only the final swap takes a brief ACCESS
|
|
10
|
+
// EXCLUSIVE lock, instead of holding it for the entire replay like an
|
|
11
|
+
// in-place TRUNCATE + replay would.
|
|
12
|
+
//
|
|
13
|
+
// The shadow-schema choice also dissolves the index-rename problem: indexes
|
|
14
|
+
// built in the shadow carry their canonical `read_foo_*` names and move intact
|
|
15
|
+
// when the table is moved to public via SET SCHEMA.
|
|
16
|
+
//
|
|
17
|
+
// Boundary: the shadow table is rebuilt from EntityTableMeta, so any index NOT
|
|
18
|
+
// expressed in meta (hand-added in a migration) is not reconstructed, and a
|
|
19
|
+
// partial index whose WHERE the renderer can't express is rejected up-front.
|
|
20
|
+
|
|
21
|
+
import type { EntityTableMeta } from "../entity-table-meta";
|
|
22
|
+
import { type AnyDb, asEntityTableMeta, asRawClient } from "../query";
|
|
23
|
+
import { renderTableDdl } from "../render-ddl";
|
|
24
|
+
import { quoteTableIdent } from "./table-ops";
|
|
25
|
+
|
|
26
|
+
export const PROJECTION_REBUILD_SCHEMA = "kumiko_rebuild";
|
|
27
|
+
|
|
28
|
+
const SCHEMA_IDENT = quoteTableIdent(PROJECTION_REBUILD_SCHEMA);
|
|
29
|
+
|
|
30
|
+
function isDuplicateSchemaError(e: unknown): boolean {
|
|
31
|
+
if (typeof e !== "object" || e === null || !("code" in e)) return false;
|
|
32
|
+
const { code } = e;
|
|
33
|
+
// 42P06 duplicate_schema, 23505 unique_violation on pg_namespace — both
|
|
34
|
+
// surface from the well-known CREATE SCHEMA IF NOT EXISTS race.
|
|
35
|
+
return code === "42P06" || code === "23505";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Idempotent. MUST run OUTSIDE the rebuild tx: CREATE SCHEMA IF NOT EXISTS is
|
|
39
|
+
// not race-free (two concurrent rebuilds of DIFFERENT projections can collide
|
|
40
|
+
// on pg_namespace), and a collision inside the rebuild tx would roll the whole
|
|
41
|
+
// replay back. The dup race is swallowed; anything else (e.g. a role without
|
|
42
|
+
// CREATE privilege) rethrows so ops sees it loud.
|
|
43
|
+
export async function ensureRebuildSchema(db: AnyDb): Promise<void> {
|
|
44
|
+
try {
|
|
45
|
+
await asRawClient(db).unsafe(`CREATE SCHEMA IF NOT EXISTS ${SCHEMA_IDENT}`);
|
|
46
|
+
} catch (e) {
|
|
47
|
+
if (!isDuplicateSchemaError(e)) throw e;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Resolve the canonical EntityTableMeta a projection's table object carries,
|
|
52
|
+
// or throw. Online rebuild needs the full column+index shape to build the
|
|
53
|
+
// shadow table; a meta-inexpressible partial index would be silently dropped
|
|
54
|
+
// by the renderer, so reject it up-front instead of swapping in a table that
|
|
55
|
+
// is missing an index.
|
|
56
|
+
export function rebuildMetaOrThrow(table: unknown, projectionName: string): EntityTableMeta {
|
|
57
|
+
const meta = asEntityTableMeta(table);
|
|
58
|
+
if (!meta) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Projection "${projectionName}" has no resolvable EntityTableMeta — online rebuild needs it to build the shadow table.`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
if (meta.indexes.some((idx) => idx.needsManualWhere === true)) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`Projection "${projectionName}" has a partial index whose WHERE clause the schema renderer can't express (drizzle sql\`…\`). Online rebuild reconstructs the table from meta and would silently drop that index. Make the WHERE renderable or rebuild this projection offline.`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return meta;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Runs INSIDE the rebuild tx, AFTER the state/consumer row lock is taken.
|
|
72
|
+
// Points search_path at the shadow schema (SET LOCAL → auto-reset on commit or
|
|
73
|
+
// rollback), drops any leftover shadow from a crashed run, then builds the
|
|
74
|
+
// shadow table + indexes under their canonical names. renderTableDdl output and
|
|
75
|
+
// apply-writes are unqualified and resolve into the shadow; kumiko_events /
|
|
76
|
+
// kumiko_projections live only in public and fall through to it.
|
|
77
|
+
//
|
|
78
|
+
// Boundary: an apply that writes to a table OTHER than its own projection (e.g.
|
|
79
|
+
// an MSP saga touching a second read-model) writes UNQUALIFIED → resolves to
|
|
80
|
+
// public, i.e. the live secondary table, not a shadow of it. Online rebuild is
|
|
81
|
+
// safe for self-table-only apply (the common case); a multi-table apply would
|
|
82
|
+
// mutate live state during replay.
|
|
83
|
+
export async function buildShadowTable(tx: AnyDb, meta: EntityTableMeta): Promise<void> {
|
|
84
|
+
const raw = asRawClient(tx);
|
|
85
|
+
await raw.unsafe(`SET LOCAL search_path TO ${SCHEMA_IDENT}, public`);
|
|
86
|
+
await raw.unsafe(`DROP TABLE IF EXISTS ${SCHEMA_IDENT}.${quoteTableIdent(meta.tableName)}`);
|
|
87
|
+
for (const stmt of renderTableDdl(meta)) {
|
|
88
|
+
await raw.unsafe(stmt);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Fence the live table before the cutover: take ACCESS EXCLUSIVE on
|
|
93
|
+
// public.<tableName> so no concurrent synchronous projection apply (a command
|
|
94
|
+
// handler's append+apply) can commit a new event-derived row past the rebuild's
|
|
95
|
+
// final catch-up. Schema-qualified so the active shadow search_path can't
|
|
96
|
+
// redirect it. lock_timeout (SET LOCAL → auto-reset) bounds the wait: under a
|
|
97
|
+
// pathological long-running writer the fence fails loud and the rebuild rolls
|
|
98
|
+
// back rather than hanging indefinitely.
|
|
99
|
+
export async function fenceLiveTable(
|
|
100
|
+
tx: AnyDb,
|
|
101
|
+
tableName: string,
|
|
102
|
+
lockTimeoutMs: number,
|
|
103
|
+
): Promise<void> {
|
|
104
|
+
const raw = asRawClient(tx);
|
|
105
|
+
await raw.unsafe(`SET LOCAL lock_timeout = ${Math.trunc(lockTimeoutMs)}`);
|
|
106
|
+
await raw.unsafe(`LOCK TABLE public.${quoteTableIdent(tableName)} IN ACCESS EXCLUSIVE MODE`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Atomic swap, INSIDE the rebuild tx, AFTER replay. Schema-qualified so the
|
|
110
|
+
// active shadow search_path can't redirect them. DROP without CASCADE: if any
|
|
111
|
+
// object depends on the live table the swap fails loud and the whole rebuild
|
|
112
|
+
// rolls back, leaving the old table untouched.
|
|
113
|
+
export async function swapShadowIntoLive(tx: AnyDb, tableName: string): Promise<void> {
|
|
114
|
+
const raw = asRawClient(tx);
|
|
115
|
+
const ident = quoteTableIdent(tableName);
|
|
116
|
+
await raw.unsafe(`DROP TABLE public.${ident}`);
|
|
117
|
+
await raw.unsafe(`ALTER TABLE ${SCHEMA_IDENT}.${ident} SET SCHEMA public`);
|
|
118
|
+
}
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { validateBoot } from "../boot-validator";
|
|
2
3
|
import { buildAppSchema, findNonJsonSafePath } from "../build-app-schema";
|
|
3
4
|
import { SETTINGS_HUB_FEATURE, SETTINGS_HUB_WORKSPACE } from "../build-config-feature-schema";
|
|
4
|
-
import { createSystemConfig, createTenantConfig } from "../config-helpers";
|
|
5
|
+
import { access, createSystemConfig, createTenantConfig } from "../config-helpers";
|
|
5
6
|
import { defineFeature } from "../define-feature";
|
|
6
7
|
import { createRegistry } from "../registry";
|
|
7
8
|
|
|
8
|
-
// A feature that opts
|
|
9
|
+
// A feature that opts config keys into the Settings-Hub via `mask`. platformFee
|
|
10
|
+
// is a system-home key with a human writer (SystemAdmin); stripeKey is tenant-
|
|
11
|
+
// home but its admin write-set cascades it up to the Plattform screen too.
|
|
9
12
|
const billing = defineFeature("billing", (r) => {
|
|
10
13
|
r.config({
|
|
11
14
|
keys: {
|
|
12
15
|
stripeKey: createTenantConfig("text", { mask: { title: "billing.stripe-key" } }),
|
|
13
|
-
platformFee: createSystemConfig("number", {
|
|
16
|
+
platformFee: createSystemConfig("number", {
|
|
17
|
+
write: access.systemAdmin,
|
|
18
|
+
mask: { title: "billing.platform-fee" },
|
|
19
|
+
}),
|
|
14
20
|
},
|
|
15
21
|
});
|
|
16
22
|
});
|
|
@@ -116,3 +122,80 @@ function shellWith(masked: ReturnType<typeof defineFeature>) {
|
|
|
116
122
|
});
|
|
117
123
|
return createRegistry([shell, masked]);
|
|
118
124
|
}
|
|
125
|
+
|
|
126
|
+
// A shell whose workspaces place the generated audience parents inline by
|
|
127
|
+
// referencing config:nav:audience-<scope> directly (the app-driven placement
|
|
128
|
+
// the renderer-side slice then expands with the audience's children).
|
|
129
|
+
function placingShell(...audienceScopes: string[]) {
|
|
130
|
+
return defineFeature("shell", (r) => {
|
|
131
|
+
r.entity("thing", { fields: { label: { type: "text" } } });
|
|
132
|
+
r.screen({ id: "home", type: "entityList", entity: "thing", columns: ["label"] });
|
|
133
|
+
r.nav({ id: "home", label: "Home", screen: "home" });
|
|
134
|
+
r.workspace({
|
|
135
|
+
id: "main",
|
|
136
|
+
label: "Main",
|
|
137
|
+
nav: ["shell:nav:home", ...audienceScopes.map((s) => `config:nav:audience-${s}`)],
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function workspaceNavs(app: ReturnType<typeof buildAppSchema>, id: string): readonly string[] {
|
|
143
|
+
const ws = app.workspaces?.find((w) => w.definition.id === id);
|
|
144
|
+
if (ws === undefined) throw new Error(`no workspace "${id}"`);
|
|
145
|
+
return ws.navMembers;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
describe("buildAppSchema — Settings-Hub inline placement", () => {
|
|
149
|
+
test("a workspace referencing an audience parent gets that audience's children expanded in", () => {
|
|
150
|
+
const app = buildAppSchema(createRegistry([placingShell("system"), billing]));
|
|
151
|
+
const main = workspaceNavs(app, "main");
|
|
152
|
+
// the app listed only the parent; the framework expands the child screen-nav
|
|
153
|
+
// so the slice doesn't drop it.
|
|
154
|
+
expect(main).toContain("config:nav:audience-system");
|
|
155
|
+
expect(main).toContain("config:nav:billing-system");
|
|
156
|
+
// the app's own nav is untouched
|
|
157
|
+
expect(main).toContain("shell:nav:home");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("placing every audience suppresses the standalone settings switcher entirely", () => {
|
|
161
|
+
// billing spans system + tenant → place both → no separate Einstellungen tab.
|
|
162
|
+
const app = buildAppSchema(createRegistry([placingShell("system", "tenant"), billing]));
|
|
163
|
+
expect(app.workspaces?.map((w) => w.definition.id).sort()).toEqual(["main"]);
|
|
164
|
+
const main = workspaceNavs(app, "main");
|
|
165
|
+
expect(main).toContain("config:nav:billing-system");
|
|
166
|
+
expect(main).toContain("config:nav:billing-tenant");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("partial placement keeps un-placed audiences in the standalone tab (nothing vanishes)", () => {
|
|
170
|
+
// place only system → the tenant audience must still be reachable via the
|
|
171
|
+
// standalone settings workspace, never silently dropped.
|
|
172
|
+
const app = buildAppSchema(createRegistry([placingShell("system"), billing]));
|
|
173
|
+
const settings = workspaceNavs(app, SETTINGS_HUB_WORKSPACE);
|
|
174
|
+
expect(settings).toContain("config:nav:audience-tenant");
|
|
175
|
+
expect(settings).toContain("config:nav:billing-tenant");
|
|
176
|
+
// the placed (system) audience is NOT duplicated into the standalone tab
|
|
177
|
+
expect(settings).not.toContain("config:nav:audience-system");
|
|
178
|
+
expect(settings).not.toContain("config:nav:billing-system");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("an app that places NO audience keeps the standalone tab whole (backward compatible)", () => {
|
|
182
|
+
const app = buildAppSchema(shellWith(billing));
|
|
183
|
+
const settings = workspaceNavs(app, SETTINGS_HUB_WORKSPACE);
|
|
184
|
+
expect(settings).toContain("config:nav:audience-system");
|
|
185
|
+
expect(settings).toContain("config:nav:audience-tenant");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("boot validation exempts the generated audience nav QNs but still catches typos", () => {
|
|
189
|
+
const ok = defineFeature("ok", (r) => {
|
|
190
|
+
r.config({ keys: { fee: createSystemConfig("number", { mask: { title: "ok.fee" } }) } });
|
|
191
|
+
r.workspace({ id: "w", label: "W", nav: ["config:nav:audience-system"] });
|
|
192
|
+
});
|
|
193
|
+
expect(() => validateBoot([ok])).not.toThrow();
|
|
194
|
+
|
|
195
|
+
const typo = defineFeature("typo", (r) => {
|
|
196
|
+
r.config({ keys: { fee: createSystemConfig("number", { mask: { title: "t.fee" } }) } });
|
|
197
|
+
r.workspace({ id: "w", label: "W", nav: ["config:nav:audience-systemm"] });
|
|
198
|
+
});
|
|
199
|
+
expect(() => validateBoot([typo])).toThrow(/references nav "config:nav:audience-systemm"/);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -1,25 +1,42 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { buildConfigFeatureSchema } from "../build-config-feature-schema";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
access,
|
|
5
|
+
createSystemConfig,
|
|
6
|
+
createTenantConfig,
|
|
7
|
+
createUserConfig,
|
|
8
|
+
} from "../config-helpers";
|
|
4
9
|
import { defineFeature } from "../define-feature";
|
|
5
10
|
import { createRegistry } from "../registry";
|
|
6
11
|
import type { NavDefinition } from "../types/nav";
|
|
7
12
|
import type { ConfigEditScreenDefinition, ScreenDefinition } from "../types/screen";
|
|
8
13
|
|
|
9
|
-
// Two features declaring masked keys across all three scopes
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
14
|
+
// Two features declaring masked keys across all three scopes. Probes:
|
|
15
|
+
// - apiKey: a SYSTEM-home key with a human writer (SystemAdmin) — the real
|
|
16
|
+
// subscription-stripe shape; renders on the Plattform screen.
|
|
17
|
+
// - platformFee: a SYSTEM-home key that keeps the default `["system"]` write
|
|
18
|
+
// (machine-only) — must NOT surface in the human hub anywhere.
|
|
19
|
+
// - internalFlag: UNMASKED (internal plumbing) — excluded.
|
|
20
|
+
// - derived: computed+masked (no row to set) — excluded.
|
|
21
|
+
// stripeKey/currency/fromAddress are TENANT-home with the admin write-set
|
|
22
|
+
// (∋ SystemAdmin), so they yield BOTH a SystemAdmin-only Plattform screen (set
|
|
23
|
+
// the platform default) AND a tenant screen with the full admin set (override).
|
|
24
|
+
// Built through the real createRegistry so the qualified names come from the
|
|
25
|
+
// real qn(toKebab(...)) path, not a hand-written stub.
|
|
13
26
|
const billing = defineFeature("billing", (r) => {
|
|
14
27
|
r.config({
|
|
15
28
|
keys: {
|
|
29
|
+
apiKey: createSystemConfig("text", {
|
|
30
|
+
write: access.systemAdmin,
|
|
31
|
+
mask: { title: "billing.api-key" },
|
|
32
|
+
}),
|
|
16
33
|
stripeKey: createTenantConfig("text", { mask: { title: "billing.stripe-key", order: 1 } }),
|
|
17
34
|
currency: createTenantConfig("select", {
|
|
18
35
|
options: ["eur", "usd"],
|
|
19
36
|
mask: { title: "billing.currency", order: 2 },
|
|
20
37
|
}),
|
|
21
38
|
internalFlag: createTenantConfig("boolean", {}), // no mask → excluded
|
|
22
|
-
platformFee: createSystemConfig("number", { mask: { title: "billing.platform-fee" } }),
|
|
39
|
+
platformFee: createSystemConfig("number", { mask: { title: "billing.platform-fee" } }), // machine-only write → excluded
|
|
23
40
|
derived: createTenantConfig("number", {
|
|
24
41
|
computed: async () => 5,
|
|
25
42
|
mask: { title: "billing.derived" }, // masked BUT computed → excluded
|
|
@@ -50,19 +67,20 @@ function configScreen(id: string): ConfigEditScreenDefinition {
|
|
|
50
67
|
|
|
51
68
|
describe("buildConfigFeatureSchema — structure", () => {
|
|
52
69
|
test("emits one audience parent per present scope + one child per (feature × scope)", () => {
|
|
53
|
-
// scopes present:
|
|
70
|
+
// scopes present: system (api-key + tenant keys elevated), tenant, user
|
|
54
71
|
expect(navById("audience-system")).toBeDefined();
|
|
55
72
|
expect(navById("audience-tenant")).toBeDefined();
|
|
56
73
|
expect(navById("audience-user")).toBeDefined();
|
|
57
74
|
|
|
58
|
-
//
|
|
75
|
+
// tenant keys (stripe/currency/from) span system too → notify-system exists.
|
|
59
76
|
expect(schema.screens.map((s) => s.id).sort()).toEqual([
|
|
60
77
|
"billing-system",
|
|
61
78
|
"billing-tenant",
|
|
79
|
+
"notify-system",
|
|
62
80
|
"notify-tenant",
|
|
63
81
|
"notify-user",
|
|
64
82
|
]);
|
|
65
|
-
expect(schema.navs).toHaveLength(
|
|
83
|
+
expect(schema.navs).toHaveLength(8); // 3 audiences + 5 children
|
|
66
84
|
});
|
|
67
85
|
|
|
68
86
|
test("audience parents are grouping nodes (no screen) ordered system<tenant<user", () => {
|
|
@@ -127,11 +145,68 @@ describe("buildConfigFeatureSchema — structure", () => {
|
|
|
127
145
|
expect(allConfigKeyValues).not.toContain("billing:config:internal-flag");
|
|
128
146
|
expect(allConfigKeyValues).not.toContain("billing:config:derived");
|
|
129
147
|
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("buildConfigFeatureSchema — per-role cascade (system > tenant > user)", () => {
|
|
151
|
+
test("a tenant-home key yields a SystemAdmin-only Plattform screen AND a full-admin tenant screen", () => {
|
|
152
|
+
// The cascade env→system→tenant lets SystemAdmin set the platform DEFAULT a
|
|
153
|
+
// tenant inherits; the tenant admin OVERRIDES at the tenant row. The two
|
|
154
|
+
// screens MUST gate differently — else a tenant admin could edit the
|
|
155
|
+
// platform default (the security crux of "smtp = sysadmin > admin").
|
|
156
|
+
const sysScreen = configScreen("notify-system");
|
|
157
|
+
expect(sysScreen.scope).toBe("system");
|
|
158
|
+
expect(sysScreen.configKeys).toEqual({ "from-address": "notify:config:from-address" });
|
|
159
|
+
expect(sysScreen.access).toEqual({ roles: ["SystemAdmin"] });
|
|
160
|
+
// TenantAdmin/Admin are NOT on the platform-default screen.
|
|
161
|
+
const sysAccess = sysScreen.access;
|
|
162
|
+
const sysRoles = sysAccess && "roles" in sysAccess ? sysAccess.roles : [];
|
|
163
|
+
expect(sysRoles).not.toContain("TenantAdmin");
|
|
164
|
+
expect(sysRoles).not.toContain("Admin");
|
|
165
|
+
|
|
166
|
+
const tenScreen = configScreen("notify-tenant");
|
|
167
|
+
expect(tenScreen.scope).toBe("tenant");
|
|
168
|
+
expect(tenScreen.configKeys).toEqual({ "from-address": "notify:config:from-address" });
|
|
169
|
+
expect(tenScreen.access).toEqual({ roles: ["TenantAdmin", "Admin", "SystemAdmin"] });
|
|
170
|
+
|
|
171
|
+
// audience-system is likewise SystemAdmin-only (parent gate union).
|
|
172
|
+
expect(navById("audience-system")?.access).toEqual({ roles: ["SystemAdmin"] });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("the Plattform screen co-groups a feature's system-home key with its elevated tenant keys", () => {
|
|
176
|
+
// billing-system carries api-key (system home, write SystemAdmin) AND the
|
|
177
|
+
// elevated tenant keys stripe-key/currency — all writable by SystemAdmin.
|
|
178
|
+
const s = configScreen("billing-system");
|
|
179
|
+
expect(Object.keys(s.configKeys).sort()).toEqual(["api-key", "currency", "stripe-key"]);
|
|
180
|
+
expect(s.access).toEqual({ roles: ["SystemAdmin"] });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("a machine-only system key (write defaults to ['system']) never reaches the human hub", () => {
|
|
184
|
+
// platform-fee keeps the default system write — no human can set it, so it
|
|
185
|
+
// must not render on any screen (would otherwise look editable but reject).
|
|
186
|
+
const allConfigKeyValues = schema.screens.flatMap((x) =>
|
|
187
|
+
x.type === "configEdit" ? Object.values(x.configKeys) : [],
|
|
188
|
+
);
|
|
189
|
+
expect(allConfigKeyValues).not.toContain("billing:config:platform-fee");
|
|
190
|
+
expect(schema.screens.some((s) => s.id === "billing-system" && "scope" in s)).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("a user-home `all`-writable key stays at the user scope only — no broader default screen", () => {
|
|
194
|
+
// digest is write `all`; no elevated role names it (all ∩ elevated = ∅), so
|
|
195
|
+
// it gets no broader (tenant/system) default screen, only the personal one.
|
|
196
|
+
expect(configScreen("notify-user").scope).toBe("user");
|
|
197
|
+
const digestPlacements = schema.screens
|
|
198
|
+
.flatMap((x) => (x.type === "configEdit" ? Object.values(x.configKeys) : []))
|
|
199
|
+
.filter((v) => v === "notify:config:digest");
|
|
200
|
+
expect(digestPlacements).toHaveLength(1);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
130
203
|
|
|
131
|
-
|
|
204
|
+
describe("buildConfigFeatureSchema — access + workspace", () => {
|
|
205
|
+
test("home-scope screens union write (edit) roles; an `all`-writable group collapses to openToAll", () => {
|
|
132
206
|
// billing tenant keys are createTenantConfig → write = admin roles
|
|
133
|
-
|
|
134
|
-
|
|
207
|
+
expect(configScreen("billing-tenant").access).toEqual({
|
|
208
|
+
roles: ["TenantAdmin", "Admin", "SystemAdmin"],
|
|
209
|
+
});
|
|
135
210
|
// digest is createUserConfig → write access.all (["all"]) → openToAll
|
|
136
211
|
expect(configScreen("notify-user").access).toEqual({ openToAll: true });
|
|
137
212
|
});
|
|
@@ -146,6 +221,18 @@ describe("buildConfigFeatureSchema — structure", () => {
|
|
|
146
221
|
expect(empty.workspace).toBeUndefined();
|
|
147
222
|
});
|
|
148
223
|
|
|
224
|
+
test("returns empty (no workspace) when every masked key is machine-only", () => {
|
|
225
|
+
// masked BUT default ["system"] write — no human writer at any scope, so the
|
|
226
|
+
// hub has nothing to show and no (empty) settings switcher is emitted.
|
|
227
|
+
const internalOnly = defineFeature("internal", (r) => {
|
|
228
|
+
r.config({ keys: { token: createSystemConfig("text", { mask: { title: "i.token" } }) } });
|
|
229
|
+
});
|
|
230
|
+
const empty = buildConfigFeatureSchema(createRegistry([internalOnly]));
|
|
231
|
+
expect(empty.screens).toHaveLength(0);
|
|
232
|
+
expect(empty.navs).toHaveLength(0);
|
|
233
|
+
expect(empty.workspace).toBeUndefined();
|
|
234
|
+
});
|
|
235
|
+
|
|
149
236
|
test("emits a settings workspace with qualified navMembers (config:nav:*) over every generated nav", () => {
|
|
150
237
|
const ws = schema.workspace;
|
|
151
238
|
expect(ws).toBeDefined();
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// #369: min/max auf date/timestamp-Feldern werden im Insert-Schema
|
|
2
|
+
// durchgesetzt (lexikografischer ISO-Vergleich, ohne Date-API). Die UI
|
|
3
|
+
// begrenzt den Picker; diese Zod-Grenze ist die Write-seitige Sicherung.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { createDateField, createEntity, createTimestampField } from "../factories";
|
|
7
|
+
import { buildInsertSchema } from "../schema-builder";
|
|
8
|
+
|
|
9
|
+
describe("date/timestamp min/max bounds", () => {
|
|
10
|
+
test("date max abgewiesen, im Rahmen akzeptiert", () => {
|
|
11
|
+
const entity = createEntity({
|
|
12
|
+
table: "T",
|
|
13
|
+
fields: { born: createDateField({ max: "2026-06-15" }) },
|
|
14
|
+
});
|
|
15
|
+
const schema = buildInsertSchema(entity);
|
|
16
|
+
expect(schema.safeParse({ born: "2026-01-01" }).success).toBe(true);
|
|
17
|
+
expect(schema.safeParse({ born: "2026-12-31" }).success).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("date min abgewiesen, im Rahmen akzeptiert", () => {
|
|
21
|
+
const entity = createEntity({
|
|
22
|
+
table: "T",
|
|
23
|
+
fields: { d: createDateField({ min: "2026-01-01" }) },
|
|
24
|
+
});
|
|
25
|
+
const schema = buildInsertSchema(entity);
|
|
26
|
+
expect(schema.safeParse({ d: "2026-05-05" }).success).toBe(true);
|
|
27
|
+
expect(schema.safeParse({ d: "2025-12-31" }).success).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("ohne bounds: jedes valide Datum passt", () => {
|
|
31
|
+
const entity = createEntity({ table: "T", fields: { d: createDateField() } });
|
|
32
|
+
const schema = buildInsertSchema(entity);
|
|
33
|
+
expect(schema.safeParse({ d: "1999-01-01" }).success).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("timestamp min/max (UTC-Instant) durchgesetzt", () => {
|
|
37
|
+
const entity = createEntity({
|
|
38
|
+
table: "T",
|
|
39
|
+
fields: {
|
|
40
|
+
at: createTimestampField({ min: "2026-01-01T00:00:00Z", max: "2026-12-31T23:59:59Z" }),
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
const schema = buildInsertSchema(entity);
|
|
44
|
+
expect(schema.safeParse({ at: "2026-06-15T10:00:00Z" }).success).toBe(true);
|
|
45
|
+
expect(schema.safeParse({ at: "2027-01-01T00:00:00Z" }).success).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SETTINGS_HUB_AUDIENCE_NAV_QNS } from "../build-config-feature-schema";
|
|
1
2
|
import { qualifyEntityName } from "../qualified-name";
|
|
2
3
|
import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
|
|
3
4
|
import type { FeatureDefinition, NavDefinition, WorkspaceDefinition } from "../types";
|
|
@@ -742,6 +743,12 @@ export function validateWorkspaces(
|
|
|
742
743
|
for (const [wsId, wsDef] of Object.entries(feature.workspaces)) {
|
|
743
744
|
if (wsDef.nav !== undefined) {
|
|
744
745
|
for (const navQn of wsDef.nav) {
|
|
746
|
+
// Settings-Hub-Audience-Navs sind generiert (buildAppSchema, nach Boot),
|
|
747
|
+
// also nie via r.nav() registriert. Eine App platziert die Settings-
|
|
748
|
+
// Gruppe inline, indem sie genau einen dieser drei QNs referenziert —
|
|
749
|
+
// hier exempt, damit der Boot nicht fälschlich wirft. Tippfehler an
|
|
750
|
+
// anderen Hub-QNs (Kinder) fängt der Render-Slice-Filter ab.
|
|
751
|
+
if (SETTINGS_HUB_AUDIENCE_NAV_QN_SET.has(navQn)) continue;
|
|
745
752
|
if (!allNavQns.has(navQn)) {
|
|
746
753
|
throw new Error(
|
|
747
754
|
`[Feature ${feature.name}] Workspace "${wsId}" references nav "${navQn}" ` +
|
|
@@ -754,6 +761,10 @@ export function validateWorkspaces(
|
|
|
754
761
|
}
|
|
755
762
|
}
|
|
756
763
|
|
|
764
|
+
const SETTINGS_HUB_AUDIENCE_NAV_QN_SET: ReadonlySet<string> = new Set(
|
|
765
|
+
SETTINGS_HUB_AUDIENCE_NAV_QNS,
|
|
766
|
+
);
|
|
767
|
+
|
|
757
768
|
// Single-default rule across the entire app. Mirrors how createApp validates
|
|
758
769
|
// roles up front — a second `default: true` is a configuration error, not a
|
|
759
770
|
// runtime fallback. Apps without any default fall back to "first workspace
|
|
@@ -53,7 +53,7 @@ export function buildAppSchema(registry: Registry): AppSchema {
|
|
|
53
53
|
// navigate({ workspaceId })). Wir gehen direkt durch `feature.workspaces`
|
|
54
54
|
// — dort sind die ids noch in der Autor-Form (short) — und ziehen die
|
|
55
55
|
// pre-resolved navMembers aus der Registry.
|
|
56
|
-
|
|
56
|
+
let workspaces: WorkspaceSchema[] = [];
|
|
57
57
|
for (const [featureName, feature] of registry.features) {
|
|
58
58
|
for (const [shortId, definition] of Object.entries(feature.workspaces)) {
|
|
59
59
|
const qualified = `${featureName}:workspace:${shortId}`;
|
|
@@ -71,11 +71,14 @@ export function buildAppSchema(registry: Registry): AppSchema {
|
|
|
71
71
|
const generated = buildConfigFeatureSchema(registry);
|
|
72
72
|
if (generated.screens.length > 0) {
|
|
73
73
|
mergeSettingsHubIntoConfigFeature(features, generated);
|
|
74
|
-
// Flip-Schutz:
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
if (appHadWorkspaces
|
|
78
|
-
workspaces
|
|
74
|
+
// Flip-Schutz: nur für Apps die schon Workspaces nutzen. Bei einer
|
|
75
|
+
// workspace-losen App bleibt app.workspaces undefined → der Renderer zeigt
|
|
76
|
+
// alle Navs ungefiltert, die Hub-Navs inklusive.
|
|
77
|
+
if (appHadWorkspaces) {
|
|
78
|
+
const placed = placeSettingsHub(workspaces, generated);
|
|
79
|
+
workspaces = placed.workspaces;
|
|
80
|
+
if (placed.standalone !== undefined) workspaces.push(placed.standalone);
|
|
81
|
+
warnUnplacedAudiences(placed.unplaced);
|
|
79
82
|
}
|
|
80
83
|
}
|
|
81
84
|
|
|
@@ -126,6 +129,80 @@ function mergeSettingsHubIntoConfigFeature(
|
|
|
126
129
|
}
|
|
127
130
|
}
|
|
128
131
|
|
|
132
|
+
// Inline-Platzierung des Settings-Hubs: Referenziert eine App-Workspace einen
|
|
133
|
+
// generierten Audience-Parent (`config:nav:audience-<scope>`) in ihren
|
|
134
|
+
// navMembers, hängen wir die Kinder dieser Audience dort an (der Slice-Filter
|
|
135
|
+
// blendet sonst Kinder aus, die nicht explizit Member sind) und zählen die
|
|
136
|
+
// Audience als „platziert". Der Standalone-Switcher behält NUR un-platzierte
|
|
137
|
+
// Audiences — so erscheint nichts doppelt und keine Audience verschwindet still
|
|
138
|
+
// (alles platziert → kein Tab; teils platziert → Rest im Tab).
|
|
139
|
+
function placeSettingsHub(
|
|
140
|
+
appWorkspaces: readonly WorkspaceSchema[],
|
|
141
|
+
generated: ConfigFeatureSchema,
|
|
142
|
+
): { workspaces: WorkspaceSchema[]; standalone: WorkspaceSchema | undefined; unplaced: string[] } {
|
|
143
|
+
const prefix = `${SETTINGS_HUB_FEATURE}:nav:`;
|
|
144
|
+
const audienceShortIds = new Set<string>();
|
|
145
|
+
const childParent = new Map<string, string>();
|
|
146
|
+
const childrenByAudience = new Map<string, string[]>();
|
|
147
|
+
for (const nav of generated.navs) {
|
|
148
|
+
if (nav.parent === undefined) {
|
|
149
|
+
audienceShortIds.add(nav.id);
|
|
150
|
+
} else {
|
|
151
|
+
childParent.set(nav.id, nav.parent);
|
|
152
|
+
const list = childrenByAudience.get(nav.parent) ?? [];
|
|
153
|
+
list.push(nav.id);
|
|
154
|
+
childrenByAudience.set(nav.parent, list);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const placedAudiences = new Set<string>();
|
|
159
|
+
const workspaces = appWorkspaces.map((ws) => {
|
|
160
|
+
const additions: string[] = [];
|
|
161
|
+
for (const member of ws.navMembers) {
|
|
162
|
+
if (!member.startsWith(prefix)) continue;
|
|
163
|
+
const shortId = member.slice(prefix.length);
|
|
164
|
+
if (!audienceShortIds.has(shortId)) continue;
|
|
165
|
+
placedAudiences.add(shortId);
|
|
166
|
+
for (const child of childrenByAudience.get(shortId) ?? []) {
|
|
167
|
+
const childQn = `${prefix}${child}`;
|
|
168
|
+
if (!ws.navMembers.includes(childQn) && !additions.includes(childQn)) {
|
|
169
|
+
additions.push(childQn);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return additions.length > 0 ? { ...ws, navMembers: [...ws.navMembers, ...additions] } : ws;
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const audienceOf = (shortId: string): string =>
|
|
177
|
+
audienceShortIds.has(shortId) ? shortId : (childParent.get(shortId) ?? shortId);
|
|
178
|
+
let standalone: WorkspaceSchema | undefined;
|
|
179
|
+
if (generated.workspace !== undefined && placedAudiences.size < audienceShortIds.size) {
|
|
180
|
+
const remaining = generated.workspace.navMembers.filter((member) => {
|
|
181
|
+
const shortId = member.startsWith(prefix) ? member.slice(prefix.length) : member;
|
|
182
|
+
return !placedAudiences.has(audienceOf(shortId));
|
|
183
|
+
});
|
|
184
|
+
if (remaining.length > 0) standalone = { ...generated.workspace, navMembers: remaining };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const unplaced =
|
|
188
|
+
placedAudiences.size > 0 ? [...audienceShortIds].filter((id) => !placedAudiences.has(id)) : [];
|
|
189
|
+
return { workspaces, standalone, unplaced };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function warnUnplacedAudiences(unplaced: readonly string[]): void {
|
|
193
|
+
// skip: every audience placed — nothing to warn about
|
|
194
|
+
if (unplaced.length === 0) return;
|
|
195
|
+
// skip: dev-only authoring hint, never in production
|
|
196
|
+
if (typeof process !== "undefined" && process.env.NODE_ENV === "production") return;
|
|
197
|
+
// biome-ignore lint/suspicious/noConsole: dev-only authoring hint
|
|
198
|
+
console.warn(
|
|
199
|
+
`[kumiko] Settings-Hub: ${unplaced.join(", ")} nicht in einer App-Workspace platziert — ` +
|
|
200
|
+
`erscheint im Standalone-"Einstellungen"-Tab. Referenziere ` +
|
|
201
|
+
`${unplaced.map((id) => `${SETTINGS_HUB_FEATURE}:nav:${id}`).join(", ")} ` +
|
|
202
|
+
`in einer r.workspace.nav, um die Gruppe inline zu zeigen.`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
129
206
|
// PlatformComponent slots ({ react, native }) legitimately hold component
|
|
130
207
|
// functions — JSON.stringify drops them at inject-time and the client
|
|
131
208
|
// re-resolves by name, so the walker treats them as opaque.
|