@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.52.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
- export async function selectEventsForProjectionRebuild(
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
- ORDER BY "id" ASC`,
25
- [aggregateTypes, eventTypes],
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 a config key into the Settings-Hub via `mask`.
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", { mask: { title: "billing.platform-fee" } }),
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 { createSystemConfig, createTenantConfig, createUserConfig } from "../config-helpers";
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, plus two
10
- // exclusion probes: an UNMASKED key (internal plumbing) and a computed+masked
11
- // key (no row to set). Built through the real createRegistry so the qualified
12
- // names come from the real qn(toKebab(...)) path, not a hand-written stub.
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: tenant (stripe/currency/from), system (platform-fee), user (digest)
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
- // children: billing-tenant, notify-tenant, billing-system, notify-user
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(7); // 3 audiences + 4 children
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
- test("access: union of write (edit) roles; an `all`-writable group collapses to openToAll", () => {
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
- const billingTenant = configScreen("billing-tenant");
134
- expect(billingTenant.access).toEqual({ roles: ["TenantAdmin", "Admin", "SystemAdmin"] });
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
- const workspaces: WorkspaceSchema[] = [];
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: die Workspace NUR anhängen wenn die App schon Workspaces
75
- // nutzt. Bei einer workspace-losen App bleibt app.workspaces undefined →
76
- // der Renderer zeigt alle Navs ungefiltert, die Hub-Navs inklusive.
77
- if (appHadWorkspaces && generated.workspace !== undefined) {
78
- workspaces.push(generated.workspace);
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.