@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.
@@ -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
- selectEventsForProjectionRebuild,
6
+ selectEventsForProjectionRebuildBatch,
8
7
  } from "../db/queries/projection-rebuild";
9
- import { truncateTable } from "../db/queries/table-ops";
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
- // Rebuild a projection from the event log.
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 FOR UPDATE. Concurrent rebuild
26
- // attempts of the same projection block here instead of racing.
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. TRUNCATE the projection's backing table.
29
- // 4. Stream events in chronological order, for every apply-key match
30
- // invoke apply(event, tx). Event-by-event, so two projections of the
31
- // same source stay semantically identical to the live pipeline.
32
- // 5. Store the last processed event-id + mark status = "idle".
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 throws partway through,
35
- // Postgres rolls back everything — the old projection is still there,
36
- // status goes back to "idle" via the outer catch, and lastError records
37
- // what went wrong. A partial/empty projection is never observable.
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
- // This is an ops-time operation. While a rebuild is in progress, live
40
- // writes that touch the same projection will also try to insert into the
41
- // TRUNCATE'd table, triggering either a serialization conflict or (for a
42
- // new row after TRUNCATE) a noisy conflict. Intended behaviour: rebuild
43
- // on a quiet entity, or during a deliberate write-pause.
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
- // Scale limit: single-TX TRUNCATE + replay works as long as your
46
- // maintenance window absorbs the replay. Effective ceiling depends on
47
- // payload size, apply() cost, and DB load measure before trusting it.
48
- // Beyond that window, plan for a shadow-swap variant. For v1 that's
49
- // documented as a known boundary in docs/projections.md.
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
- const tableName = extractTableName(projection.table, "projection-rebuild");
101
- await truncateTable(tx, tableName);
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
- // Stream events in chronological order for every source. The event
104
- // type filter prunes events the projection doesn't care about early.
105
- const subscribed = Object.keys(projection.apply);
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
- // Upcasters run at read time: older stored payloads get walked
132
- // through the registered r.eventMigration chain until their shape
133
- // matches the current event version.
134
- const upcasters = registry.getEventUpcasters();
135
- for (const row of events) {
136
- deps.signal?.throwIfAborted();
137
- const raw: StoredEvent = {
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: keine
300
- // generische Interaktion der Test-Autor liefert später einen
301
- // Hand-Override oder überspringt das Feld.
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
  }