@cosmicdrift/kumiko-framework 0.52.0 → 0.55.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/db/queries/__tests__/shadow-swap.test.ts +30 -0
- package/src/db/queries/projection-rebuild.ts +11 -3
- package/src/db/queries/shadow-swap.ts +118 -0
- package/src/engine/__tests__/build-app-schema-settings-hub.test.ts +86 -3
- package/src/engine/__tests__/build-config-feature-schema.test.ts +99 -12
- package/src/engine/__tests__/schema-builder-date-bounds.test.ts +47 -0
- package/src/engine/boot-validator/screens-nav.ts +11 -0
- package/src/engine/build-app-schema.ts +83 -6
- package/src/engine/build-config-feature-schema.ts +76 -23
- package/src/engine/schema-builder.ts +30 -2
- package/src/engine/types/fields.ts +24 -0
- package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +247 -9
- package/src/pipeline/msp-rebuild.ts +20 -12
- package/src/pipeline/projection-rebuild.ts +160 -84
- package/src/testing/__tests__/e2e-generator.test.ts +32 -0
- package/src/testing/e2e-generator.ts +5 -4
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
import { extractTableName } from "../db";
|
|
2
1
|
import type { DbConnection, DbTx } from "../db/connection";
|
|
3
2
|
import {
|
|
4
3
|
finalizeProjectionRebuild,
|
|
5
4
|
markProjectionRebuildFailed,
|
|
6
5
|
markProjectionRebuilding,
|
|
7
|
-
|
|
6
|
+
selectEventsForProjectionRebuildBatch,
|
|
8
7
|
} from "../db/queries/projection-rebuild";
|
|
9
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
buildShadowTable,
|
|
10
|
+
ensureRebuildSchema,
|
|
11
|
+
fenceLiveTable,
|
|
12
|
+
rebuildMetaOrThrow,
|
|
13
|
+
swapShadowIntoLive,
|
|
14
|
+
} from "../db/queries/shadow-swap";
|
|
10
15
|
import { coerceRow, extractTableInfo, selectMany } from "../db/query";
|
|
11
16
|
import type { Registry, TenantId } from "../engine/types";
|
|
12
17
|
import {
|
|
@@ -15,38 +20,96 @@ import {
|
|
|
15
20
|
type StoredEvent,
|
|
16
21
|
upcastStoredEvent,
|
|
17
22
|
} from "../event-store";
|
|
23
|
+
import type { EventMetadata } from "../event-store/event-store";
|
|
18
24
|
import { emitProjectionRebuild } from "../observability/standard-metrics";
|
|
19
25
|
import type { Meter } from "../observability/types/metric";
|
|
20
26
|
import { projectionStateTable } from "./projection-state";
|
|
21
27
|
|
|
22
|
-
//
|
|
28
|
+
// Events replayed per catch-up batch. Each batch is a fresh READ COMMITTED
|
|
29
|
+
// SELECT, so a batch shorter than this means the currently-committed tail is
|
|
30
|
+
// drained.
|
|
31
|
+
const REBUILD_BATCH_SIZE = 1000;
|
|
32
|
+
|
|
33
|
+
// Cap on UNLOCKED catch-up batches before forcing the cutover fence. Bounds the
|
|
34
|
+
// lock-free phase under sustained writes that never momentarily quiesce; the
|
|
35
|
+
// fenced final drain then always terminates (no new event can commit once the
|
|
36
|
+
// live table is held ACCESS EXCLUSIVE).
|
|
37
|
+
const MAX_UNLOCKED_BATCHES = 10_000;
|
|
38
|
+
|
|
39
|
+
const DEFAULT_FENCE_LOCK_TIMEOUT_MS = 5_000;
|
|
40
|
+
|
|
41
|
+
type StoredEventRow = {
|
|
42
|
+
id: bigint;
|
|
43
|
+
aggregateId: string;
|
|
44
|
+
aggregateType: string;
|
|
45
|
+
tenantId: string;
|
|
46
|
+
version: number;
|
|
47
|
+
type: string;
|
|
48
|
+
eventVersion: number;
|
|
49
|
+
payload: Record<string, unknown>;
|
|
50
|
+
metadata: EventMetadata;
|
|
51
|
+
createdAt: Temporal.Instant;
|
|
52
|
+
createdBy: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function rowToStoredEvent(row: StoredEventRow): StoredEvent {
|
|
56
|
+
return {
|
|
57
|
+
id: String(row.id),
|
|
58
|
+
aggregateId: row.aggregateId,
|
|
59
|
+
aggregateType: row.aggregateType,
|
|
60
|
+
tenantId: row.tenantId,
|
|
61
|
+
version: row.version,
|
|
62
|
+
type: row.type,
|
|
63
|
+
eventVersion: row.eventVersion,
|
|
64
|
+
payload: row.payload,
|
|
65
|
+
metadata: row.metadata,
|
|
66
|
+
createdAt: row.createdAt,
|
|
67
|
+
createdBy: row.createdBy,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Rebuild a projection from the event log — online, via a shadow swap with a
|
|
72
|
+
// live-tail catch-up.
|
|
23
73
|
//
|
|
24
74
|
// Mechanics:
|
|
25
|
-
// 1. Lock the projection's state row
|
|
26
|
-
//
|
|
75
|
+
// 1. Lock the projection's state row (INSERT … ON CONFLICT DO UPDATE takes
|
|
76
|
+
// a row lock held to commit). Concurrent rebuilds of the same projection
|
|
77
|
+
// — including across pods — block here instead of racing.
|
|
27
78
|
// 2. Mark status = "rebuilding".
|
|
28
|
-
// 3.
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
79
|
+
// 3. Build a shadow table in a private schema (see db/queries/shadow-swap),
|
|
80
|
+
// with search_path pointed there so apply-writes land in the shadow.
|
|
81
|
+
// 4. Unlocked catch-up: replay events in chronological batches into the
|
|
82
|
+
// shadow until a short batch signals the currently-committed tail. Single-
|
|
83
|
+
// stream projections apply SYNCHRONOUSLY in the appending tx, so live
|
|
84
|
+
// writers keep updating public.<table> meanwhile; READ COMMITTED makes
|
|
85
|
+
// each fresh batch see their newly-committed events.
|
|
86
|
+
// 5. Fence: take ACCESS EXCLUSIVE on the live table (bounded by lock_timeout),
|
|
87
|
+
// then drain the final delta. Once fenced, no new event can commit, so the
|
|
88
|
+
// shadow ends up reflecting every event the live table reflects — the
|
|
89
|
+
// writes that land DURING the replay are no longer lost.
|
|
90
|
+
// 6. Store the last processed event-id + mark status = "idle".
|
|
91
|
+
// 7. Swap: DROP the live table + ALTER the shadow into public.
|
|
33
92
|
//
|
|
34
|
-
// All of that runs in ONE transaction. If apply
|
|
35
|
-
// Postgres rolls back everything — the
|
|
36
|
-
//
|
|
37
|
-
//
|
|
93
|
+
// All of that runs in ONE transaction. If apply (or the fence's lock_timeout)
|
|
94
|
+
// throws partway through, Postgres rolls back everything — the shadow is
|
|
95
|
+
// discarded, the live table was never touched (the swap is the last step),
|
|
96
|
+
// status is recorded "failed" via the outer catch with lastError. A
|
|
97
|
+
// partial/empty projection is never observable.
|
|
38
98
|
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
99
|
+
// Cutover semantics: the fence blocks concurrent synchronous applies for the
|
|
100
|
+
// final-drain + swap window only (not the whole replay). A live write blocked
|
|
101
|
+
// THROUGH the swap is one atomic append+apply tx; whichever way Postgres
|
|
102
|
+
// resolves the dropped-OID reference, the event INSERT and the projection row
|
|
103
|
+
// commit or roll back together (no event ⟺ no row). See the cutover test for
|
|
104
|
+
// the empirically-pinned behavior.
|
|
44
105
|
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
106
|
+
// Boundaries:
|
|
107
|
+
// - Not multi-pod zero-downtime on its own: during a rolling deploy, old pods
|
|
108
|
+
// still running cannot read the new shape after the swap. End-to-end ZD
|
|
109
|
+
// also needs app-author expand/contract discipline (see the plan doc).
|
|
110
|
+
// - The shadow is rebuilt from EntityTableMeta, so an index hand-added in a
|
|
111
|
+
// migration but absent from meta is not reconstructed.
|
|
112
|
+
// - Requires CREATE privilege to provision the shared rebuild schema.
|
|
50
113
|
|
|
51
114
|
export type RebuildResult = {
|
|
52
115
|
readonly projection: string;
|
|
@@ -72,6 +135,14 @@ type RebuildDeps = {
|
|
|
72
135
|
// when a CLI/Job wraps the rebuild in its own AbortController for ops
|
|
73
136
|
// timeout enforcement.
|
|
74
137
|
readonly signal?: AbortSignal;
|
|
138
|
+
// Cutover fence lock_timeout (ms). The final catch-up takes ACCESS EXCLUSIVE
|
|
139
|
+
// on the live table; if a long-running writer holds it past this, the rebuild
|
|
140
|
+
// fails loud instead of hanging. Defaults to DEFAULT_FENCE_LOCK_TIMEOUT_MS.
|
|
141
|
+
readonly fenceLockTimeoutMs?: number;
|
|
142
|
+
// Test-only seam: fires once after the unlocked bulk drain and before the
|
|
143
|
+
// cutover fence. Lets a concurrency test inject a committed write into the
|
|
144
|
+
// replay window deterministically. Undefined in production.
|
|
145
|
+
readonly onBeforeFence?: () => void | Promise<void>;
|
|
75
146
|
};
|
|
76
147
|
|
|
77
148
|
export async function rebuildProjection(
|
|
@@ -88,80 +159,85 @@ export async function rebuildProjection(
|
|
|
88
159
|
);
|
|
89
160
|
}
|
|
90
161
|
|
|
162
|
+
const meta = rebuildMetaOrThrow(projection.table, projectionName);
|
|
163
|
+
|
|
91
164
|
const sources = Array.isArray(projection.source) ? projection.source : [projection.source];
|
|
165
|
+
const sourcesList = [...sources];
|
|
166
|
+
const subscribedList = Object.keys(projection.apply);
|
|
167
|
+
// Upcasters run at read time: older stored payloads get walked through the
|
|
168
|
+
// registered r.eventMigration chain until their shape matches the current
|
|
169
|
+
// event version.
|
|
170
|
+
const upcasters = registry.getEventUpcasters();
|
|
171
|
+
const eventsInfo = extractTableInfo(eventsTable);
|
|
172
|
+
const fenceLockTimeoutMs = deps.fenceLockTimeoutMs ?? DEFAULT_FENCE_LOCK_TIMEOUT_MS;
|
|
92
173
|
const startedAt = Date.now();
|
|
93
174
|
let eventsProcessed = 0;
|
|
94
175
|
let lastProcessedEventId = 0n;
|
|
95
176
|
|
|
177
|
+
// One chronological batch of events after lastProcessedEventId, applied into
|
|
178
|
+
// the shadow. Returns the batch size so the caller can detect the tail
|
|
179
|
+
// (a short batch = no more currently-committed events).
|
|
180
|
+
const drainBatch = async (tx: DbTx): Promise<number> => {
|
|
181
|
+
const rawEvents = await selectEventsForProjectionRebuildBatch(
|
|
182
|
+
tx,
|
|
183
|
+
sourcesList,
|
|
184
|
+
subscribedList,
|
|
185
|
+
lastProcessedEventId,
|
|
186
|
+
REBUILD_BATCH_SIZE,
|
|
187
|
+
);
|
|
188
|
+
for (const r of rawEvents) {
|
|
189
|
+
deps.signal?.throwIfAborted();
|
|
190
|
+
const row = coerceRow(r, eventsInfo) as StoredEventRow;
|
|
191
|
+
const storedEvent = await upcastStoredEvent(rowToStoredEvent(row), upcasters, {
|
|
192
|
+
db: tx,
|
|
193
|
+
tenantId: row.tenantId as TenantId, // @cast-boundary db-row
|
|
194
|
+
});
|
|
195
|
+
const applyFn = projection.apply[row.type];
|
|
196
|
+
// skip: apply-key validation ensures every subscribed type has a handler;
|
|
197
|
+
// defensive check against runtime-mutated registry
|
|
198
|
+
if (!applyFn) continue;
|
|
199
|
+
await applyFn(storedEvent, tx);
|
|
200
|
+
eventsProcessed++;
|
|
201
|
+
lastProcessedEventId = row.id;
|
|
202
|
+
}
|
|
203
|
+
return rawEvents.length;
|
|
204
|
+
};
|
|
205
|
+
|
|
96
206
|
try {
|
|
207
|
+
await ensureRebuildSchema(db);
|
|
97
208
|
await db.begin(async (tx: DbTx) => {
|
|
98
209
|
await markProjectionRebuilding(tx, projectionName);
|
|
210
|
+
await buildShadowTable(tx, meta);
|
|
99
211
|
|
|
100
|
-
|
|
101
|
-
|
|
212
|
+
// A projection that subscribes to nothing has no events to replay and no
|
|
213
|
+
// live writer touching its table — skip straight to swapping the empty
|
|
214
|
+
// shadow (no fence needed).
|
|
215
|
+
if (subscribedList.length > 0) {
|
|
216
|
+
// Unlocked catch-up: drain batches until a short batch signals the
|
|
217
|
+
// currently-committed tail. Live synchronous applies keep writing to
|
|
218
|
+
// public.<table> meanwhile; READ COMMITTED makes each fresh batch see
|
|
219
|
+
// their newly-committed events. Capped so sustained writes can't keep
|
|
220
|
+
// the lock-free phase running forever.
|
|
221
|
+
for (let batches = 0; batches < MAX_UNLOCKED_BATCHES; batches++) {
|
|
222
|
+
if ((await drainBatch(tx)) < REBUILD_BATCH_SIZE) break;
|
|
223
|
+
}
|
|
102
224
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if (subscribed.length === 0) {
|
|
107
|
-
// nothing to replay, just mark idle — projection exists but doesn't
|
|
108
|
-
// subscribe to any event types on its sources yet.
|
|
109
|
-
} else {
|
|
110
|
-
type EventRow = {
|
|
111
|
-
id: bigint;
|
|
112
|
-
aggregateId: string;
|
|
113
|
-
aggregateType: string;
|
|
114
|
-
tenantId: string;
|
|
115
|
-
version: number;
|
|
116
|
-
type: string;
|
|
117
|
-
eventVersion: number;
|
|
118
|
-
payload: Record<string, unknown>;
|
|
119
|
-
metadata: import("../event-store/event-store").EventMetadata;
|
|
120
|
-
createdAt: Temporal.Instant;
|
|
121
|
-
createdBy: string;
|
|
122
|
-
};
|
|
123
|
-
const sourcesList = [...sources];
|
|
124
|
-
const subscribedList = [...subscribed];
|
|
125
|
-
const rawEvents = await selectEventsForProjectionRebuild(tx, sourcesList, subscribedList);
|
|
126
|
-
const events = rawEvents.map((r) => {
|
|
127
|
-
const info = extractTableInfo(eventsTable);
|
|
128
|
-
return coerceRow(r, info) as EventRow;
|
|
129
|
-
});
|
|
225
|
+
// Test seam: inject a mid-replay committed write here to prove the
|
|
226
|
+
// fenced final drain catches it instead of losing it at swap.
|
|
227
|
+
await deps.onBeforeFence?.();
|
|
130
228
|
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
id: String(row.id),
|
|
139
|
-
aggregateId: row.aggregateId,
|
|
140
|
-
aggregateType: row.aggregateType,
|
|
141
|
-
tenantId: row.tenantId,
|
|
142
|
-
version: row.version,
|
|
143
|
-
type: row.type,
|
|
144
|
-
eventVersion: row.eventVersion,
|
|
145
|
-
payload: row.payload,
|
|
146
|
-
metadata: row.metadata,
|
|
147
|
-
createdAt: row.createdAt,
|
|
148
|
-
createdBy: row.createdBy,
|
|
149
|
-
};
|
|
150
|
-
const storedEvent = await upcastStoredEvent(raw, upcasters, {
|
|
151
|
-
db: tx,
|
|
152
|
-
tenantId: row.tenantId as TenantId, // @cast-boundary db-row
|
|
153
|
-
});
|
|
154
|
-
const applyFn = projection.apply[row.type];
|
|
155
|
-
// skip: apply-key validation ensures every subscribed type has a
|
|
156
|
-
// handler; defensive check against runtime-mutated registry
|
|
157
|
-
if (!applyFn) continue;
|
|
158
|
-
await applyFn(storedEvent, tx);
|
|
159
|
-
eventsProcessed++;
|
|
160
|
-
lastProcessedEventId = row.id;
|
|
229
|
+
// Fence the live table, then drain the final delta. Once ACCESS
|
|
230
|
+
// EXCLUSIVE is held no concurrent apply can commit a new event, so this
|
|
231
|
+
// loop terminates and the shadow ends up reflecting every committed
|
|
232
|
+
// event — closing Phase 1's write-loss window for single-pod rebuilds.
|
|
233
|
+
await fenceLiveTable(tx, meta.tableName, fenceLockTimeoutMs);
|
|
234
|
+
while ((await drainBatch(tx)) === REBUILD_BATCH_SIZE) {
|
|
235
|
+
// keep draining full batches; a short batch ends the loop
|
|
161
236
|
}
|
|
162
237
|
}
|
|
163
238
|
|
|
164
239
|
await finalizeProjectionRebuild(tx, projectionName, lastProcessedEventId);
|
|
240
|
+
await swapShadowIntoLive(tx, meta.tableName);
|
|
165
241
|
});
|
|
166
242
|
} catch (e) {
|
|
167
243
|
// Outer catch: TX has been rolled back by Postgres already. Record the
|
|
@@ -2,10 +2,12 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import {
|
|
4
4
|
createBooleanField,
|
|
5
|
+
createDateField,
|
|
5
6
|
createEntity,
|
|
6
7
|
createRegistry,
|
|
7
8
|
createSelectField,
|
|
8
9
|
createTextField,
|
|
10
|
+
createTimestampField,
|
|
9
11
|
defineEntityCreateHandler,
|
|
10
12
|
defineFeature,
|
|
11
13
|
} from "../../engine";
|
|
@@ -86,6 +88,36 @@ describe("generateE2ESpec", () => {
|
|
|
86
88
|
]);
|
|
87
89
|
});
|
|
88
90
|
|
|
91
|
+
test("date emittiert fill, timestamp wird übersprungen (zwei Inputs seit #369)", () => {
|
|
92
|
+
const entity = createEntity({
|
|
93
|
+
table: "events",
|
|
94
|
+
fields: {
|
|
95
|
+
title: createTextField({ required: true }),
|
|
96
|
+
day: createDateField(),
|
|
97
|
+
at: createTimestampField(),
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
const feature = defineFeature("events", (r) => {
|
|
101
|
+
r.systemScope();
|
|
102
|
+
r.entity("event", entity);
|
|
103
|
+
r.writeHandler(defineEntityCreateHandler("event", entity));
|
|
104
|
+
r.screen({
|
|
105
|
+
id: "event-edit",
|
|
106
|
+
type: "entityEdit",
|
|
107
|
+
entity: "event",
|
|
108
|
+
layout: { sections: [{ title: "events:section", fields: ["title", "day", "at"] }] },
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
const specs = generateE2ESpec(createRegistry([feature]));
|
|
112
|
+
const persists = specs.find((s) => s.kind === "edit-save-persists");
|
|
113
|
+
if (persists?.kind !== "edit-save-persists") throw new Error("unreachable");
|
|
114
|
+
const filledFields = persists.fills.map((f) => f.field);
|
|
115
|
+
// date: ein Text-Input, generisch füllbar. timestamp: Datum + Uhrzeit =
|
|
116
|
+
// zwei Inputs, die ein einzelnes .fill() in Playwright-strict-mode brechen.
|
|
117
|
+
expect(filledFields).toContain("day");
|
|
118
|
+
expect(filledFields).not.toContain("at");
|
|
119
|
+
});
|
|
120
|
+
|
|
89
121
|
test("accepts tenant-slug override", () => {
|
|
90
122
|
const registry = createRegistry([createTasksFeature()]);
|
|
91
123
|
const specs = generateE2ESpec(registry, { tenantPlaceholder: "acme" });
|
|
@@ -292,13 +292,14 @@ function buildEditFillOps(
|
|
|
292
292
|
case "longText":
|
|
293
293
|
case "number":
|
|
294
294
|
case "date":
|
|
295
|
-
case "timestamp":
|
|
296
295
|
case "tz":
|
|
296
|
+
// date: ein Text-Input, akzeptiert getippte ISO-Werte direkt.
|
|
297
297
|
ops.push({ kind: "fill", field, value: String(v) });
|
|
298
298
|
break;
|
|
299
|
-
// embedded/money/locatedTimestamp/file/image/files/images:
|
|
300
|
-
// generische Interaktion
|
|
301
|
-
//
|
|
299
|
+
// embedded/money/timestamp/locatedTimestamp/file/image/files/images:
|
|
300
|
+
// keine generische Interaktion. timestamp rendert seit #369 zwei
|
|
301
|
+
// Inputs (Datum + Uhrzeit), die ein einzelnes .fill() nicht bedienen
|
|
302
|
+
// kann — der Test-Autor liefert einen Hand-Override oder überspringt.
|
|
302
303
|
default:
|
|
303
304
|
break;
|
|
304
305
|
}
|