@cosmicdrift/kumiko-framework 0.53.0 → 0.55.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +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
|
@@ -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
|
|
75
|
-
const
|
|
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:
|
|
112
|
+
id: audienceNavShortId(scope),
|
|
80
113
|
label: `config.settings.${scope}`,
|
|
81
114
|
order: SCOPE_ORDER[scope],
|
|
82
|
-
access:
|
|
115
|
+
access: rolesToAccess(visible.flatMap((v) => v.roles)),
|
|
83
116
|
});
|
|
84
117
|
|
|
85
|
-
for (const feature of featuresPresent(
|
|
86
|
-
const group =
|
|
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:
|
|
128
|
+
parent: audienceNavShortId(scope),
|
|
95
129
|
screen: shortId,
|
|
96
|
-
order: minMaskOrder(
|
|
97
|
-
access
|
|
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
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 —
|
|
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
|
|
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
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
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.
|
|
318
|
-
//
|
|
319
|
-
//
|
|
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,
|
|
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 {
|
|
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
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
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
|
-
// -
|
|
48
|
-
//
|
|
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
|
-
|
|
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);
|