@cosmicdrift/kumiko-framework 0.122.1 → 0.122.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.122.1",
3
+ "version": "0.122.3",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -189,7 +189,7 @@
189
189
  "zod": "^4.4.3"
190
190
  },
191
191
  "devDependencies": {
192
- "@cosmicdrift/kumiko-dispatcher-live": "0.122.1",
192
+ "@cosmicdrift/kumiko-dispatcher-live": "0.122.3",
193
193
  "bun-types": "^1.3.13",
194
194
  "pino-pretty": "^13.1.3"
195
195
  },
@@ -18,9 +18,11 @@
18
18
  // expressed in meta (hand-added in a migration) is not reconstructed, and a
19
19
  // partial index whose WHERE the renderer can't express is rejected up-front.
20
20
 
21
+ import type { DbConnection, DbTx } from "../connection";
21
22
  import type { EntityTableMeta } from "../entity-table-meta";
22
23
  import { type AnyDb, asEntityTableMeta, asRawClient } from "../query";
23
24
  import { renderTableDdl } from "../render-ddl";
25
+ import { columnNamesOf, tableExists } from "../schema-inspection";
24
26
  import { quoteTableIdent } from "./table-ops";
25
27
 
26
28
  export const PROJECTION_REBUILD_SCHEMA = "kumiko_rebuild";
@@ -68,6 +70,41 @@ export function rebuildMetaOrThrow(table: unknown, projectionName: string): Enti
68
70
  return meta;
69
71
  }
70
72
 
73
+ // Fence against a rebuild running with a registry that does not match the
74
+ // migrated live schema (#835): during a rolling deploy, a pod still running
75
+ // the previous build can pick up an async rebuild job; its shadow — built from
76
+ // the stale EntityTableMeta — would swap away a freshly-migrated column
77
+ // (recurrence class of #494). Compares COLUMN NAMES only; a type-/nullability-
78
+ // only drift passes (schema regression, not data loss — the boot gate of the
79
+ // next deploy catches it). A missing live table is fine: nothing to wipe.
80
+ // Must run BEFORE buildShadowTable; columnNamesOf pins table_schema='public',
81
+ // so the shadow search_path could not redirect it anyway.
82
+ export async function assertLiveColumnsMatchMeta(
83
+ db: DbConnection | DbTx,
84
+ meta: EntityTableMeta,
85
+ projectionName: string,
86
+ ): Promise<void> {
87
+ // skip: no live table yet — nothing a stale-meta shadow could wipe
88
+ if (!(await tableExists(db, `public.${meta.tableName}`))) return;
89
+ const live = await columnNamesOf(db, meta.tableName);
90
+ const metaNames = new Set(meta.columns.map((c) => c.name));
91
+ const onlyLive = [...live].filter((c) => !metaNames.has(c));
92
+ const onlyMeta = [...metaNames].filter((c) => !live.has(c));
93
+ // skip: column sets match — this process's registry is in sync with the migrated table
94
+ if (onlyLive.length === 0 && onlyMeta.length === 0) return;
95
+ const detail = [
96
+ onlyLive.length > 0 ? `live-only: ${onlyLive.join(", ")}` : "",
97
+ onlyMeta.length > 0 ? `meta-only: ${onlyMeta.join(", ")}` : "",
98
+ ]
99
+ .filter(Boolean)
100
+ .join("; ");
101
+ throw new Error(
102
+ `projection-rebuild "${projectionName}": columns of live table "${meta.tableName}" do not match this process's EntityTableMeta (${detail}). ` +
103
+ "Rebuilding would swap away the difference. Likely cause: this pod runs a build whose registry is behind (or ahead of) the applied migrations — rolling deploy in progress? — or DDL was applied by hand. " +
104
+ "Rebuild aborted; retry from a pod whose code matches the migrated schema.",
105
+ );
106
+ }
107
+
71
108
  // Runs INSIDE the rebuild tx, AFTER the state/consumer row lock is taken.
72
109
  // Points search_path at the shadow schema (SET LOCAL → auto-reset on commit or
73
110
  // rollback), drops any leftover shadow from a crashed run, then builds the
@@ -159,10 +159,20 @@ describe("event-dispatcher — happy path", () => {
159
159
  await stack.eventDispatcher?.runOnce();
160
160
  expect(captureA).toHaveLength(1);
161
161
 
162
+ const stateBefore = await getConsumerState(stack.db, qnA);
163
+
162
164
  const second = await stack.eventDispatcher?.runOnce();
163
165
  expect(second?.byConsumer[qnA]).toEqual({ processed: 0, failed: 0 });
164
166
  // Still only one — consumer correctly saw "nothing new past my cursor".
165
167
  expect(captureA).toHaveLength(1);
168
+
169
+ // No pending events means no write at all — an idle pass must not touch
170
+ // updated_at/status, otherwise every empty poll tick burns a WAL record.
171
+ const stateAfter = await getConsumerState(stack.db, qnA);
172
+ expect(stateAfter?.updatedAt.epochMilliseconds).toEqual(
173
+ stateBefore?.updatedAt.epochMilliseconds,
174
+ );
175
+ expect(stateAfter?.status).toBe(stateBefore?.status);
166
176
  });
167
177
 
168
178
  test("cursor advances only past successfully-consumed events", async () => {
@@ -890,3 +890,51 @@ describe("rebuildProjection — live-tail catch-up (#363 Phase 2)", () => {
890
890
  await asRawClient(testDb.db).unsafe(`DROP TABLE IF EXISTS "cutover_probe"`);
891
891
  });
892
892
  });
893
+
894
+ describe("rebuildProjection — stale-registry column fence (#835)", () => {
895
+ // Simulates the rolling-deploy race: a migration added a column to the live
896
+ // table, but the pod running the rebuild still carries the old registry
897
+ // (EntityTableMeta without that column). The rebuild must abort instead of
898
+ // swapping in a shadow that silently drops the migrated column (#494 class).
899
+ test("aborts when the live table has a column the registry meta lacks — data survives", async () => {
900
+ const group = "00000000-0000-4000-8000-000000000041";
901
+ await appendCreatedEvent(group, "item1");
902
+ await rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry });
903
+ expect(await getCount(group)).toBe(1);
904
+
905
+ const raw = asRawClient(testDb.db);
906
+ await raw.unsafe(`ALTER TABLE "read_rebuild_items_per_group" ADD COLUMN migrated_col text`);
907
+ try {
908
+ await raw.unsafe(
909
+ `UPDATE "read_rebuild_items_per_group" SET migrated_col = 'must-survive' WHERE group_id = $1::uuid`,
910
+ [group],
911
+ );
912
+
913
+ await expect(
914
+ rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry }),
915
+ ).rejects.toThrow(/live-only: migrated_col/);
916
+
917
+ // Live table untouched: column AND its data still there.
918
+ const [row] = await raw.unsafe<{ migrated_col: string | null }>(
919
+ `SELECT migrated_col FROM "read_rebuild_items_per_group" WHERE group_id = $1::uuid`,
920
+ [group],
921
+ );
922
+ expect(row?.migrated_col).toBe("must-survive");
923
+ expect(await getCount(group)).toBe(1);
924
+
925
+ // Failure is visible to ops.
926
+ const state = await getProjectionState(testDb.db, qualifiedProjectionName);
927
+ expect(state?.status).toBe("failed");
928
+ expect(state?.lastError).toContain("migrated_col");
929
+ } finally {
930
+ await raw.unsafe(
931
+ `ALTER TABLE "read_rebuild_items_per_group" DROP COLUMN IF EXISTS migrated_col`,
932
+ );
933
+ }
934
+
935
+ // Column sets aligned again → rebuild works.
936
+ const result = await rebuildProjection(qualifiedProjectionName, { db: testDb.db, registry });
937
+ expect(result.eventsProcessed).toBe(1);
938
+ expect(await getCount(group)).toBe(1);
939
+ });
940
+ });
@@ -524,9 +524,16 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
524
524
  span.setAttribute("consumer.skip_reason", acquired.skip);
525
525
  return;
526
526
  }
527
- await markProcessing(tx, consumer.name, instanceId);
528
527
 
529
528
  const events = await fetchPendingEvents(tx, acquired.state.lastProcessedEventId, batchSize);
529
+ // skip: nothing to deliver — no markProcessing/persistConsumerOutcome write,
530
+ // so an idle consumer doesn't burn a WAL record on every poll tick.
531
+ if (events.length === 0) {
532
+ span.setAttribute("consumer.skip_reason", "no_pending_events");
533
+ return;
534
+ }
535
+ await markProcessing(tx, consumer.name, instanceId);
536
+
530
537
  const outcome = await deliverEvents(consumer, events, context, maxAttempts, acquired.state);
531
538
  processed = outcome.processed;
532
539
  failed = outcome.failed;
@@ -6,6 +6,7 @@ import {
6
6
  updateConsumerRebuildCursor,
7
7
  } from "../db/queries/event-consumer";
8
8
  import {
9
+ assertLiveColumnsMatchMeta,
9
10
  buildShadowTable,
10
11
  ensureRebuildSchema,
11
12
  rebuildMetaOrThrow,
@@ -147,6 +148,7 @@ export async function rebuildMultiStreamProjection(
147
148
  await resetConsumerForMspRebuild(tx, mspName, SHARED_INSTANCE_SENTINEL);
148
149
  await selectConsumerForUpdate(tx, mspName, SHARED_INSTANCE_SENTINEL);
149
150
 
151
+ await assertLiveColumnsMatchMeta(tx, meta, mspName);
150
152
  await buildShadowTable(tx, meta);
151
153
 
152
154
  const subscribedTypes = Object.keys(msp.apply);
@@ -7,6 +7,7 @@ import {
7
7
  selectEventsForProjectionRebuildBatch,
8
8
  } from "../db/queries/projection-rebuild";
9
9
  import {
10
+ assertLiveColumnsMatchMeta,
10
11
  buildShadowTable,
11
12
  ensureRebuildSchema,
12
13
  fenceLiveTable,
@@ -260,6 +261,7 @@ export async function rebuildProjection(
260
261
  if (skipApplyErrors) await createRebuildDeadLetterTable(db);
261
262
  await db.begin(async (tx: DbTx) => {
262
263
  await markProjectionRebuilding(tx, projectionName);
264
+ await assertLiveColumnsMatchMeta(tx, meta, projectionName);
263
265
  await buildShadowTable(tx, meta);
264
266
  deps.__test_onBuildShadowTable?.();
265
267