@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.
@@ -56,6 +56,36 @@ export type ConfigFeatureSchema = {
56
56
 
57
57
  // Audience-Reihenfolge im Sidebar: Plattform vor Tenant vor Benutzer.
58
58
  const SCOPE_ORDER: Record<ConfigScope, number> = { system: 10, tenant: 20, user: 30 };
59
+ const SCOPES_BROAD_TO_DEEP: readonly ConfigScope[] = ["system", "tenant", "user"];
60
+
61
+ const audienceNavShortId = (scope: ConfigScope): string => `audience-${scope}`;
62
+
63
+ // Die generierten Audience-Parent-Navs (qualifiziert). Eine App platziert die
64
+ // Settings-Gruppe INLINE, indem sie einen dieser QNs in ihre `r.workspace.nav`
65
+ // aufnimmt — buildAppSchema expandiert dann die Kinder der Audience hinein und
66
+ // unterdrückt den Standalone-Switcher. Der Boot-Validator kennt genau diese drei
67
+ // QNs als Ausnahme (generiert, nicht via `r.nav()` registriert).
68
+ export const SETTINGS_HUB_AUDIENCE_NAV_QNS: readonly string[] = SCOPES_BROAD_TO_DEEP.map(
69
+ (scope) => `${SETTINGS_HUB_FEATURE}:nav:${audienceNavShortId(scope)}`,
70
+ );
71
+
72
+ // An einem Scope BREITER als der Home-Scope eines Keys darf nur eine für DIESE
73
+ // Ebene privilegierte Rolle den (Cascade-)Default setzen — SystemAdmin auf
74
+ // system, TenantAdmin/Admin auf tenant. Am Home-Scope gilt das volle write-Set
75
+ // (unverändertes Verhalten). So liefert ein tenant-Home-Key wie SMTP zusätzlich
76
+ // einen SystemAdmin-only Plattform-Screen; ein Key, dessen write-Set keine
77
+ // dieser Rollen nennt, bekommt keinen breiteren Screen (write-Set = opt-in).
78
+ const ELEVATED_ROLES: Record<ConfigScope, readonly string[]> = {
79
+ system: ["SystemAdmin"],
80
+ tenant: ["TenantAdmin", "Admin"],
81
+ user: [],
82
+ };
83
+
84
+ // Der interne Maschinen-Akteur (access.system). Ein Key, den NUR diese Rolle
85
+ // schreiben darf, ist provisioned-not-user-facing: er gehört nicht in den
86
+ // menschlichen Hub (sonst rendert ein Feld, das der sichtbare Mensch nicht
87
+ // speichern kann). `as const` für Literal-Verengung am Vergleich.
88
+ const MACHINE_WRITE_ROLE = "system" as const;
59
89
 
60
90
  type MaskedKey = {
61
91
  readonly qn: string;
@@ -64,6 +94,8 @@ type MaskedKey = {
64
94
  readonly def: ConfigKeyDefinition;
65
95
  };
66
96
 
97
+ type ScopedKey = { readonly key: MaskedKey; readonly roles: readonly string[] };
98
+
67
99
  export function buildConfigFeatureSchema(registry: Registry): ConfigFeatureSchema {
68
100
  const masked = collectMaskedKeys(registry);
69
101
  if (masked.length === 0) return { screens: [], navs: [] };
@@ -71,37 +103,60 @@ export function buildConfigFeatureSchema(registry: Registry): ConfigFeatureSchem
71
103
  const screens: ScreenDefinition[] = [];
72
104
  const navs: NavDefinition[] = [];
73
105
 
74
- for (const scope of scopesPresent(masked)) {
75
- const scopeKeys = masked.filter((k) => k.def.scope === scope);
106
+ for (const scope of SCOPES_BROAD_TO_DEEP) {
107
+ const visible = scopedKeysAt(masked, scope);
108
+ if (visible.length === 0) continue;
76
109
 
77
110
  // Audience-Parent: Gruppierungs-Knoten ohne Screen.
78
111
  navs.push({
79
- id: `audience-${scope}`,
112
+ id: audienceNavShortId(scope),
80
113
  label: `config.settings.${scope}`,
81
114
  order: SCOPE_ORDER[scope],
82
- access: unionEditAccess(scopeKeys.map((k) => k.def)),
115
+ access: rolesToAccess(visible.flatMap((v) => v.roles)),
83
116
  });
84
117
 
85
- for (const feature of featuresPresent(scopeKeys)) {
86
- const group = scopeKeys.filter((k) => k.feature === feature);
87
- const ordered = sortByMaskOrder(group);
118
+ for (const feature of featuresPresent(visible.map((v) => v.key))) {
119
+ const group = visible.filter((v) => v.key.feature === feature);
120
+ const ordered = sortByMaskOrder(group.map((v) => v.key));
121
+ const access = rolesToAccess(group.flatMap((v) => v.roles));
88
122
  const shortId = `${feature}-${scope}`;
89
123
 
90
- screens.push(buildScreen(shortId, scope, feature, ordered));
124
+ screens.push(buildScreen(shortId, scope, feature, ordered, access));
91
125
  navs.push({
92
126
  id: shortId,
93
127
  label: `${feature}.settings`,
94
- parent: `audience-${scope}`,
128
+ parent: audienceNavShortId(scope),
95
129
  screen: shortId,
96
- order: minMaskOrder(group),
97
- access: unionEditAccess(group.map((k) => k.def)),
130
+ order: minMaskOrder(ordered),
131
+ access,
98
132
  });
99
133
  }
100
134
  }
101
135
 
136
+ // Alle masked Keys maschinen-only → kein menschlicher Hub, kein (leerer)
137
+ // Settings-Switcher.
138
+ if (navs.length === 0) return { screens, navs };
102
139
  return { screens, navs, workspace: buildSettingsWorkspace(navs, masked) };
103
140
  }
104
141
 
142
+ // Keys, die an `scope` sichtbar sind, gepaart mit ihren effektiven Schreib-
143
+ // Rollen AN diesem Scope (Home = volles write; breiter = elevated ∩ write).
144
+ function scopedKeysAt(masked: readonly MaskedKey[], scope: ConfigScope): ScopedKey[] {
145
+ const out: ScopedKey[] = [];
146
+ for (const key of masked) {
147
+ const roles = effectiveWriteRoles(key.def, scope);
148
+ if (roles.some((r) => r !== MACHINE_WRITE_ROLE)) out.push({ key, roles });
149
+ }
150
+ return out;
151
+ }
152
+
153
+ function effectiveWriteRoles(def: ConfigKeyDefinition, scope: ConfigScope): string[] {
154
+ if (SCOPE_ORDER[scope] > SCOPE_ORDER[def.scope]) return [];
155
+ if (scope === def.scope) return [...def.access.write];
156
+ const elevated = ELEVATED_ROLES[scope];
157
+ return def.access.write.filter((r) => elevated.includes(r));
158
+ }
159
+
105
160
  // navMembers tragen die QUALIFIZIERTEN Nav-QNs (siehe build-app-schema.test:
106
161
  // admin.navMembers === ["orders:nav:list", ...]). Die generierten Navs leben
107
162
  // unter SETTINGS_HUB_FEATURE, also `config:nav:<shortId>`. Sortiert = stabile
@@ -130,6 +185,7 @@ function buildScreen(
130
185
  scope: ConfigScope,
131
186
  feature: string,
132
187
  keys: readonly MaskedKey[],
188
+ access: AccessRule,
133
189
  ): ConfigEditScreenDefinition {
134
190
  const configKeys: Record<string, string> = {};
135
191
  const fields: Record<string, FieldDefinition> = {};
@@ -152,7 +208,7 @@ function buildScreen(
152
208
  fields,
153
209
  fieldLabels,
154
210
  layout: { sections: [section] },
155
- access: unionEditAccess(keys.map((k) => k.def)),
211
+ access,
156
212
  };
157
213
  }
158
214
 
@@ -189,11 +245,6 @@ function collectMaskedKeys(registry: Registry): MaskedKey[] {
189
245
  return out;
190
246
  }
191
247
 
192
- function scopesPresent(keys: readonly MaskedKey[]): ConfigScope[] {
193
- const set = new Set<ConfigScope>(keys.map((k) => k.def.scope));
194
- return [...set].sort((a, b) => (SCOPE_ORDER[a] ?? 0) - (SCOPE_ORDER[b] ?? 0));
195
- }
196
-
197
248
  function featuresPresent(keys: readonly MaskedKey[]): string[] {
198
249
  return [...new Set(keys.map((k) => k.feature))].sort();
199
250
  }
@@ -219,10 +270,12 @@ function minMaskOrder(keys: readonly MaskedKey[]): number {
219
270
  // AccessRule nur als openToAll ausdrücken; der Write bleibt server-seitig
220
271
  // per Key gegated.
221
272
  function unionEditAccess(defs: readonly ConfigKeyDefinition[]): AccessRule {
222
- const roles = new Set<string>();
223
- for (const def of defs) {
224
- for (const role of def.access.write) roles.add(role);
225
- }
226
- if (roles.has("all")) return { openToAll: true };
227
- return { roles: [...roles] };
273
+ return rolesToAccess(defs.flatMap((d) => [...d.access.write]));
274
+ }
275
+
276
+ // `all` lässt sich in AccessRule nur als openToAll ausdrücken; der Write bleibt
277
+ // server-seitig per Key gegated.
278
+ function rolesToAccess(roles: readonly string[]): AccessRule {
279
+ if (roles.includes("all")) return { openToAll: true };
280
+ return { roles: [...new Set(roles)] };
228
281
  }
@@ -3,6 +3,31 @@ import { assertUnreachable } from "../utils";
3
3
  import type { EmbeddedSubFieldDef, EntityDefinition, FieldDefinition } from "./types";
4
4
  import { DEFAULT_CURRENCIES } from "./types";
5
5
 
6
+ // Lexikografischer ISO-Vergleich — exakt für `yyyy-mm-dd` (date) und korrekt
7
+ // für ISO-Datetime in konsistenter Repräsentation (gleiche Offset-/Präzisions-
8
+ // Form). Bewusst ohne Date-API (no-date-api-Guard); die Tag-genaue Grenze
9
+ // reicht für min/max-Use-Cases (z.B. Geburtsdatum nicht in der Zukunft).
10
+ function withDateBounds(
11
+ schema: z.ZodTypeAny,
12
+ min: string | undefined,
13
+ max: string | undefined,
14
+ ): z.ZodTypeAny {
15
+ if (min === undefined && max === undefined) return schema;
16
+ const message =
17
+ min !== undefined && max !== undefined
18
+ ? `must be between ${min} and ${max}`
19
+ : min !== undefined
20
+ ? `must be on or after ${min}`
21
+ : `must be on or before ${max}`;
22
+ return schema.refine(
23
+ (value: unknown) =>
24
+ typeof value === "string" &&
25
+ (min === undefined || value >= min) &&
26
+ (max === undefined || value <= max),
27
+ { message },
28
+ );
29
+ }
30
+
6
31
  function embeddedSubFieldToZod(subField: EmbeddedSubFieldDef): z.ZodTypeAny {
7
32
  switch (subField.type) {
8
33
  case "text":
@@ -104,14 +129,17 @@ export function fieldToZod(field: FieldDefinition, currencies: readonly string[]
104
129
  return z.record(z.string(), z.unknown());
105
130
  }
106
131
  case "date": {
107
- return z.string().date();
132
+ const schema = z.string().date();
133
+ return withDateBounds(schema, field.min, field.max);
108
134
  }
109
135
  case "timestamp": {
110
136
  // Wenn locatedBy gesetzt: Wall-Clock OHNE Offset (ISO-Datetime ohne `Z`).
111
137
  // Sonst: ISO-UTC-Datetime (mit `Z`). Beide werden über z.iso.datetime
112
138
  // gegen das ISO-8601-Schema validiert; die Präzision (mit/ohne Offset)
113
139
  // hängt von locatedBy ab.
114
- return field.locatedBy !== undefined ? z.iso.datetime({ local: true }) : z.iso.datetime();
140
+ const schema =
141
+ field.locatedBy !== undefined ? z.iso.datetime({ local: true }) : z.iso.datetime();
142
+ return withDateBounds(schema, field.min, field.max);
115
143
  }
116
144
  case "tz": {
117
145
  // IANA-Zonenname. Validierung gegen Intl.supportedValuesOf("timeZone")
@@ -354,6 +354,14 @@ export type DateFieldDef = {
354
354
  readonly filterable?: boolean;
355
355
  readonly sensitive?: boolean;
356
356
  readonly access?: FieldAccess;
357
+ /** Erlaubte Datumsgrenzen als ISO `yyyy-mm-dd` (z.B. Geburtsdatum nicht
358
+ * in der Zukunft: `max` = heute). Begrenzt den Picker und wird vom
359
+ * Zod-Schema beim Write durchgesetzt. */
360
+ readonly min?: string;
361
+ readonly max?: string;
362
+ /** Format/Locale-Override für Anzeige und Eingabe-Parsing (z.B.
363
+ * "de-DE"). Default = App-Locale. */
364
+ readonly locale?: string;
357
365
  } & PiiAnnotations;
358
366
 
359
367
  // UTC-Instant (Temporal.Instant). Für Ereignisse die zu einem bestimmten
@@ -382,6 +390,14 @@ export type TimestampFieldDef = {
382
390
  * { pickupAt: { type: "timestamp", locatedBy: "pickupTz" }, pickupTz: { type: "tz" } }
383
391
  */
384
392
  readonly locatedBy?: string;
393
+ /** Erlaubte Grenzen als ISO-Datetime. Begrenzt den Picker auf
394
+ * Tages-Granularität; die exakte Uhrzeit-Grenze setzt das Zod-Schema
395
+ * beim Write durch. */
396
+ readonly min?: string;
397
+ readonly max?: string;
398
+ /** Format/Locale-Override für Anzeige und Eingabe-Parsing. Default =
399
+ * App-Locale. */
400
+ readonly locale?: string;
385
401
  } & PiiAnnotations;
386
402
 
387
403
  // IANA-Zonenname (z.B. "Europe/Berlin", "America/Los_Angeles").
@@ -420,6 +436,14 @@ export type LocatedTimestampFieldDef = {
420
436
  readonly filterable?: boolean;
421
437
  readonly sensitive?: boolean;
422
438
  readonly access?: FieldAccess;
439
+ /** Erlaubte Grenzen als ISO-Datetime (Wall-Clock). Begrenzt den Picker
440
+ * auf Tages-Granularität; die exakte Uhrzeit-Grenze setzt das Zod-Schema
441
+ * beim Write durch. */
442
+ readonly min?: string;
443
+ readonly max?: string;
444
+ /** Format/Locale-Override für Anzeige und Eingabe-Parsing. Default =
445
+ * App-Locale. */
446
+ readonly locale?: string;
423
447
  } & PiiAnnotations;
424
448
 
425
449
  export type FileFieldDef = {
@@ -11,8 +11,10 @@
11
11
  // - never-rebuilt projection has sensible default state
12
12
 
13
13
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
14
+ import type { DbConnection, DbTx } from "../../db/connection";
14
15
  import { integer, table as pgTable, uuid } from "../../db/dialect";
15
16
  import { createEventStoreExecutor } from "../../db/event-store-executor";
17
+ import { fenceLiveTable, swapShadowIntoLive } from "../../db/queries/shadow-swap";
16
18
  import { asRawClient, insertOne, selectMany } from "../../db/query";
17
19
  import { buildEntityTable } from "../../db/table-builder";
18
20
  import { createTenantDb, type TenantDb } from "../../db/tenant-db";
@@ -175,7 +177,8 @@ describe("rebuildProjection — happy path", () => {
175
177
  });
176
178
 
177
179
  expect(result.eventsProcessed).toBe(2);
178
- // Not 999+2, not 999 — TRUNCATE + replay.
180
+ // Not 999+2, not 999 — the shadow is built empty and swapped over the
181
+ // stale live table.
179
182
  expect(await getCount(group)).toBe(2);
180
183
  });
181
184
 
@@ -245,7 +248,7 @@ describe("rebuildProjection — state table lifecycle", () => {
245
248
  });
246
249
 
247
250
  describe("rebuildProjection — error path", () => {
248
- test("apply throw rolls TRUNCATE + partial replay back, marks status=failed", async () => {
251
+ test("apply throw rolls the shadow + partial replay back, marks status=failed", async () => {
249
252
  const group = "00000000-0000-4000-8000-000000000030";
250
253
  await appendCreatedEvent(group, "keeper-1");
251
254
  await appendCreatedEvent(group, "keeper-2");
@@ -275,9 +278,9 @@ describe("rebuildProjection — error path", () => {
275
278
  rebuildProjection(brokenName, { db: testDb.db, registry: brokenRegistry }),
276
279
  ).rejects.toThrow("boom");
277
280
 
278
- // Old counter rows are gone (TRUNCATE is inside the TX but this is a
279
- // DIFFERENT projection). Verify our original projection's rows WERE
280
- // preserved because the broken rebuild targets a different name.
281
+ // The broken rebuild targets a DIFFERENT projection name; its shadow
282
+ // rolled back and never swapped. Our original projection's rows are
283
+ // untouched either way.
281
284
  expect(await getCount(group)).toBe(2);
282
285
 
283
286
  // State of the broken projection is "failed" with the error message.
@@ -314,9 +317,10 @@ describe("rebuildProjection — error path", () => {
314
317
  rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry: brokenRegistry }),
315
318
  ).rejects.toThrow("poisoned");
316
319
 
317
- // CRITICAL: the old counter rows survive. TRUNCATE happened INSIDE the
318
- // transaction, so the rollback restored them. Without this the rebuild
319
- // would be worse than not rebuilding at all.
320
+ // CRITICAL: the old counter rows survive. The shadow rebuild replays into
321
+ // a separate table and only swaps as the last step a throw mid-replay
322
+ // rolls the shadow back and the live table was never touched. Without this
323
+ // the rebuild would be worse than not rebuilding at all.
320
324
  expect(await getCount(group)).toBe(3);
321
325
 
322
326
  // State reflects the failure.
@@ -502,7 +506,7 @@ describe("rebuildProjection — meter emission", () => {
502
506
  });
503
507
 
504
508
  describe("rebuildProjection — cancellation", () => {
505
- test("pre-aborted signal: rebuild throws, TRUNCATE rolls back, projection state preserved", async () => {
509
+ test("pre-aborted signal: rebuild throws, shadow rolls back, projection state preserved", async () => {
506
510
  // Setup: events on the log + a clean rebuild → projection has known
507
511
  // counter state. Then call rebuildProjection with a pre-aborted
508
512
  // controller. The first throwIfAborted() inside the apply loop
@@ -546,3 +550,237 @@ describe("rebuildProjection — cancellation", () => {
546
550
  expect(after).toBe(before);
547
551
  });
548
552
  });
553
+
554
+ describe("rebuildProjection — online shadow-swap mechanics", () => {
555
+ // Self-contained fixtures: a separate projection so the count-sensitive
556
+ // list/progress tests above keep seeing exactly one registered projection.
557
+ const swapEntity = createEntity({
558
+ table: "read_swap_indexed",
559
+ fields: { label: createTextField({ required: true }) },
560
+ });
561
+ const swapTable = buildEntityTable("swap-indexed", swapEntity);
562
+ // Empty apply: a 0-event rebuild still builds the shadow + swaps it in, so
563
+ // this exercises the table-recreation + swap path without wiring events.
564
+ const swapProjection: ProjectionDefinition = {
565
+ name: "swap-indexed-proj",
566
+ source: "swap-indexed",
567
+ table: swapTable,
568
+ apply: {},
569
+ };
570
+ const swapFeature = defineFeature("swaptest", (r) => {
571
+ r.entity("swap-indexed", swapEntity);
572
+ r.projection(swapProjection);
573
+ });
574
+ const swapRegistry = createRegistry([swapFeature]);
575
+ const swapProjName = "swaptest:projection:swap-indexed-proj";
576
+
577
+ test("swap moves the shadow into public with canonical index names", async () => {
578
+ await unsafeCreateEntityTable(testDb.db, swapEntity, "swap-indexed");
579
+ await rebuildProjection(swapProjName, { db: testDb.db, registry: swapRegistry });
580
+
581
+ // The tenant_id index carries its canonical name after SET SCHEMA. Future
582
+ // migrations DROP/CREATE INDEX by exactly this name, so a rename here would
583
+ // silently break them — this is the index-rename trap the shadow-schema
584
+ // approach dissolves.
585
+ const idx = await asRawClient(testDb.db).unsafe<{ indexname: string }>(
586
+ `SELECT indexname FROM pg_indexes WHERE schemaname = 'public' AND tablename = 'read_swap_indexed' ORDER BY indexname`,
587
+ );
588
+ expect(idx.map((r) => r.indexname)).toContain("read_swap_indexed_tenant_id_idx");
589
+
590
+ // Nothing left behind in the shadow schema — the table moved out via SET SCHEMA.
591
+ const leftover = await asRawClient(testDb.db).unsafe<{ tablename: string }>(
592
+ `SELECT tablename FROM pg_tables WHERE schemaname = 'kumiko_rebuild' AND tablename = 'read_swap_indexed'`,
593
+ );
594
+ expect(leftover).toHaveLength(0);
595
+ });
596
+
597
+ test("cleans a stale shadow table left by a crashed rebuild", async () => {
598
+ await unsafeCreateEntityTable(testDb.db, swapEntity, "swap-indexed");
599
+ // A crashed prior rebuild leaves a shadow with the wrong shape behind.
600
+ await asRawClient(testDb.db).unsafe(`CREATE SCHEMA IF NOT EXISTS kumiko_rebuild`);
601
+ await asRawClient(testDb.db).unsafe(`DROP TABLE IF EXISTS kumiko_rebuild.read_swap_indexed`);
602
+ await asRawClient(testDb.db).unsafe(
603
+ `CREATE TABLE kumiko_rebuild.read_swap_indexed (stale int)`,
604
+ );
605
+
606
+ // Rebuild drops the stale shadow before building the real one — succeeds.
607
+ await rebuildProjection(swapProjName, { db: testDb.db, registry: swapRegistry });
608
+
609
+ const cols = await asRawClient(testDb.db).unsafe<{ column_name: string }>(
610
+ `SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'read_swap_indexed'`,
611
+ );
612
+ const colNames = cols.map((c) => c.column_name);
613
+ expect(colNames).not.toContain("stale");
614
+ expect(colNames).toContain("tenant_id");
615
+ });
616
+
617
+ test("live table stays readable + intact during replay (no lock until swap)", async () => {
618
+ const group = "00000000-0000-4000-8000-0000000000d1";
619
+ await appendCreatedEvent(group, "a");
620
+ await appendCreatedEvent(group, "b");
621
+ await rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry });
622
+ expect(await getCount(group)).toBe(2);
623
+
624
+ // A probe apply reads the LIVE table from a SEPARATE pooled connection
625
+ // (default search_path = public) on its first invocation, while the rebuild
626
+ // tx is mid-replay against its shadow. An in-place TRUNCATE would hold an
627
+ // ACCESS EXCLUSIVE lock and deadlock this read; the shadow swap leaves the
628
+ // live table unlocked, so it returns the pre-swap rows promptly.
629
+ let liveDuringReplay: number | undefined = -1;
630
+ const probeFeature = defineFeature("probetest", (r) => {
631
+ r.entity("rebuild-item", itemEntity);
632
+ r.projection({
633
+ ...itemsPerGroupProjection,
634
+ apply: {
635
+ "rebuild-item.created": defineApply<ItemCreated>(async (event, tx) => {
636
+ if (liveDuringReplay === -1) liveDuringReplay = await getCount(group);
637
+ await bump(tx, event.payload.groupId, event.tenantId, 1);
638
+ }),
639
+ },
640
+ });
641
+ });
642
+ const probeRegistry = createRegistry([probeFeature]);
643
+
644
+ await rebuildProjection("probetest:projection:items-per-group", {
645
+ db: testDb.db,
646
+ registry: probeRegistry,
647
+ });
648
+
649
+ expect(liveDuringReplay).toBe(2);
650
+ expect(await getCount(group)).toBe(2);
651
+ });
652
+
653
+ test("warm-pool writes succeed after the swap changes the table OID", async () => {
654
+ // The swap replaces the physical table (new OID) — TRUNCATE never did.
655
+ // A live write through the same long-lived pool AFTER the swap (the
656
+ // steady-state apply path, typed query API) must still resolve the
657
+ // swapped relation, including across a second rebuild + swap.
658
+ const group = "00000000-0000-4000-8000-0000000000e1";
659
+ await appendCreatedEvent(group, "a");
660
+ await rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry });
661
+ expect(await getCount(group)).toBe(1);
662
+
663
+ const other = "00000000-0000-4000-8000-0000000000e2";
664
+ await insertOne(testDb.db, itemsPerGroupTable, {
665
+ groupId: other,
666
+ tenantId: admin.tenantId,
667
+ itemCount: 5,
668
+ });
669
+ expect(await getCount(other)).toBe(5);
670
+
671
+ await appendCreatedEvent(group, "b");
672
+ await rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry });
673
+ expect(await getCount(group)).toBe(2);
674
+ });
675
+ });
676
+
677
+ describe("rebuildProjection — live-tail catch-up (#363 Phase 2)", () => {
678
+ test("a write committed during the replay window survives the swap (Phase 1 lost it)", async () => {
679
+ const group = "00000000-0000-4000-8000-0000000000f1";
680
+ await appendCreatedEvent(group, "a"); // event id 1
681
+ await appendCreatedEvent(group, "b"); // event id 2
682
+
683
+ let injected = 0;
684
+ await rebuildProjection(qualifiedProjectionName, {
685
+ db: testDb.db,
686
+ registry,
687
+ // Fires after the unlocked bulk drain (events 1+2 applied) and before the
688
+ // fence. A 3rd event committed here is exactly the write Phase 1's single
689
+ // up-front SELECT missed — the fenced final drain must pick it up.
690
+ onBeforeFence: async () => {
691
+ injected++;
692
+ await appendCreatedEvent(group, "c"); // event id 3, separate connection
693
+ },
694
+ });
695
+
696
+ expect(injected).toBe(1); // seam ran exactly once
697
+ // 2 bulk + 1 caught-up tail. Under Phase 1's up-front SELECT this is 2.
698
+ expect(await getCount(group)).toBe(3);
699
+ });
700
+
701
+ test("the rebuild tx sees concurrently-committed rows (READ COMMITTED, not a frozen snapshot)", async () => {
702
+ // The catch-up loop only works if each fresh SELECT in the rebuild tx sees
703
+ // rows other connections committed since the previous batch. Under
704
+ // REPEATABLE READ the loop would be silently inert — pin the isolation.
705
+ const group = "00000000-0000-4000-8000-0000000000f2";
706
+ const db = testDb.db as DbConnection; // @cast-boundary test-harness (TestDb.db is intentionally unknown)
707
+ await db.begin(async (tx: DbTx) => {
708
+ const before = await asRawClient(tx).unsafe<{ n: number }>(
709
+ `SELECT count(*)::int AS n FROM "read_rebuild_items_per_group"`,
710
+ );
711
+ // Separate pooled connection commits a row mid-transaction.
712
+ await insertOne(testDb.db, itemsPerGroupTable, {
713
+ groupId: group,
714
+ tenantId: admin.tenantId,
715
+ itemCount: 1,
716
+ });
717
+ const after = await asRawClient(tx).unsafe<{ n: number }>(
718
+ `SELECT count(*)::int AS n FROM "read_rebuild_items_per_group"`,
719
+ );
720
+ expect(after[0]?.n ?? 0).toBe((before[0]?.n ?? 0) + 1);
721
+ });
722
+ });
723
+
724
+ test("a write blocked through the fence+swap is atomic with its partner write (cutover semantics)", async () => {
725
+ // Drive the cutover primitive directly (fenceLiveTable + swapShadowIntoLive):
726
+ // a concurrent tx does a partner write + a projection apply that BLOCKS on
727
+ // the fence and is carried through DROP + SET SCHEMA (the live table's OID
728
+ // changes). However Postgres resolves the dropped-OID write, the partner
729
+ // write and the apply share one tx → both commit or both roll back. The
730
+ // empirical outcome (errors vs. retargets) is logged for the changeset note.
731
+ const group = "00000000-0000-4000-8000-0000000000f3";
732
+ await asRawClient(testDb.db).unsafe(
733
+ `CREATE TABLE IF NOT EXISTS "cutover_probe" (id int primary key)`,
734
+ );
735
+ await asRawClient(testDb.db).unsafe(`DELETE FROM "cutover_probe"`);
736
+
737
+ // Pre-build a shadow under the canonical name so SET SCHEMA can move it into
738
+ // public — mirrors what buildShadowTable produces for a real rebuild.
739
+ await asRawClient(testDb.db).unsafe(`CREATE SCHEMA IF NOT EXISTS kumiko_rebuild`);
740
+ await asRawClient(testDb.db).unsafe(
741
+ `DROP TABLE IF EXISTS kumiko_rebuild."read_rebuild_items_per_group"`,
742
+ );
743
+ await asRawClient(testDb.db).unsafe(
744
+ `CREATE TABLE kumiko_rebuild."read_rebuild_items_per_group" (LIKE public."read_rebuild_items_per_group" INCLUDING ALL)`,
745
+ );
746
+
747
+ let bOutcome = "pending";
748
+ let bDone: Promise<void> | null = null;
749
+ const db = testDb.db as DbConnection; // @cast-boundary test-harness (TestDb.db is intentionally unknown)
750
+ await db.begin(async (atx: DbTx) => {
751
+ await fenceLiveTable(atx, "read_rebuild_items_per_group", 10_000);
752
+
753
+ // Connection B: partner write + a projection apply that blocks on the fence.
754
+ bDone = db
755
+ .begin(async (btx: DbTx) => {
756
+ await asRawClient(btx).unsafe(`INSERT INTO "cutover_probe" (id) VALUES (1)`);
757
+ await bump(btx, group, admin.tenantId, 1); // ROW EXCLUSIVE → blocks on the fence
758
+ })
759
+ .then(() => {
760
+ bOutcome = "committed";
761
+ })
762
+ .catch((e: unknown) => {
763
+ bOutcome = `errored: ${e instanceof Error ? e.message : String(e)}`;
764
+ });
765
+
766
+ // Let B reach its lock-wait before we swap the table out from under it.
767
+ // NOT awaiting bDone here: B can only finish once A commits (releases the
768
+ // fence) — awaiting it inside the tx would deadlock.
769
+ await new Promise((r) => setTimeout(r, 300));
770
+ await swapShadowIntoLive(atx, "read_rebuild_items_per_group");
771
+ });
772
+ if (bDone) await bDone; // A committed → B unblocks and resolves
773
+
774
+ console.log(`[#363 cutover] blocked writer outcome: ${bOutcome}`);
775
+
776
+ // Load-bearing invariant: partner write present ⟺ projection row present.
777
+ const probe = await asRawClient(testDb.db).unsafe<{ n: number }>(
778
+ `SELECT count(*)::int AS n FROM "cutover_probe" WHERE id = 1`,
779
+ );
780
+ const probePresent = (probe[0]?.n ?? 0) > 0;
781
+ const rowPresent = (await getCount(group)) !== undefined;
782
+ expect(probePresent).toBe(rowPresent);
783
+
784
+ await asRawClient(testDb.db).unsafe(`DROP TABLE IF EXISTS "cutover_probe"`);
785
+ });
786
+ });
@@ -1,4 +1,3 @@
1
- import { extractTableName } from "../db";
2
1
  import type { DbConnection, DbRunner, DbTx } from "../db/connection";
3
2
  import {
4
3
  markConsumerRebuildFailed,
@@ -6,7 +5,12 @@ import {
6
5
  selectConsumerForUpdate,
7
6
  updateConsumerRebuildCursor,
8
7
  } from "../db/queries/event-consumer";
9
- import { truncateTable } from "../db/queries/table-ops";
8
+ import {
9
+ buildShadowTable,
10
+ ensureRebuildSchema,
11
+ rebuildMetaOrThrow,
12
+ swapShadowIntoLive,
13
+ } from "../db/queries/shadow-swap";
10
14
  import { selectMany } from "../db/query";
11
15
  import type { Registry, TenantId } from "../engine/types";
12
16
  import { InternalError } from "../errors";
@@ -20,11 +24,12 @@ import type { MultiStreamApplyContext } from "./multi-stream-apply-context";
20
24
  import type { RebuildResult } from "./projection-rebuild";
21
25
 
22
26
  // Rebuild a multi-stream projection (MSP) from the event log. Symmetric to
23
- // `rebuildProjection` for single-stream projections — same single-TX
24
- // TRUNCATE+replay semantics but wired against the dispatcher's consumer
25
- // state row (cursor, not projection-state). MSPs are async-live and
26
- // cursor-driven; rebuild resets the cursor to 0 and rematerializes the
27
- // projection table in chronological event order.
27
+ // `rebuildProjection` for single-stream projections — same single-TX online
28
+ // shadow-swap (build in a private schema, replay, swap into public; the live
29
+ // table is never locked during replay, see db/queries/shadow-swap) but wired
30
+ // against the dispatcher's consumer state row (cursor, not projection-state).
31
+ // MSPs are async-live and cursor-driven; rebuild resets the cursor to 0 and
32
+ // rematerializes the projection table in chronological event order.
28
33
  //
29
34
  // Why separate from rebuildProjection:
30
35
  // - MSP apply signature includes a 3rd ctx arg (MultiStreamApplyContext
@@ -44,9 +49,10 @@ import type { RebuildResult } from "./projection-rebuild";
44
49
  // During the rebuild TX:
45
50
  // - FOR UPDATE lock on the consumer row blocks concurrent live passes
46
51
  // (SKIP LOCKED from the dispatcher backs off silently).
47
- // - TRUNCATE the projection table.
48
- // - Stream events matching apply-keys, invoke apply(event, tx, ctx).
52
+ // - Build the shadow table; replay events matching apply-keys into it via
53
+ // apply(event, tx, ctx) with search_path pointed at the shadow schema.
49
54
  // - Advance cursor to last processed event id, status=idle.
55
+ // - Swap the shadow into public (brief ACCESS EXCLUSIVE, not the replay).
50
56
  //
51
57
  // Failure: outer catch writes status="dead" + lastError so ops sees the
52
58
  // failure after the TX rolled back. Use restartConsumer to clear dead.
@@ -104,18 +110,19 @@ export async function rebuildMultiStreamProjection(
104
110
  });
105
111
  }
106
112
 
113
+ const meta = rebuildMetaOrThrow(msp.table, mspName);
114
+
107
115
  const startedAt = Date.now();
108
116
  let eventsProcessed = 0;
109
117
  let lastProcessedEventId = 0n;
110
118
 
111
119
  try {
120
+ await ensureRebuildSchema(db);
112
121
  await db.begin(async (tx: DbTx) => {
113
122
  await resetConsumerForMspRebuild(tx, mspName, SHARED_INSTANCE_SENTINEL);
114
123
  await selectConsumerForUpdate(tx, mspName, SHARED_INSTANCE_SENTINEL);
115
124
 
116
- const mspTable = msp.table as NonNullable<typeof msp.table>;
117
- const tableName = extractTableName(mspTable, "msp-rebuild");
118
- await truncateTable(tx, tableName);
125
+ await buildShadowTable(tx, meta);
119
126
 
120
127
  const subscribedTypes = Object.keys(msp.apply);
121
128
  if (subscribedTypes.length > 0) {
@@ -173,6 +180,7 @@ export async function rebuildMultiStreamProjection(
173
180
  SHARED_INSTANCE_SENTINEL,
174
181
  lastProcessedEventId,
175
182
  );
183
+ await swapShadowIntoLive(tx, meta.tableName);
176
184
  });
177
185
  } catch (e) {
178
186
  const message = e instanceof Error ? e.message : String(e);