@cosmicdrift/kumiko-framework 0.122.2 → 0.122.4

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.2",
3
+ "version": "0.122.4",
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.2",
192
+ "@cosmicdrift/kumiko-dispatcher-live": "0.122.4",
193
193
  "bun-types": "^1.3.13",
194
194
  "pino-pretty": "^13.1.3"
195
195
  },
@@ -0,0 +1,48 @@
1
+ import { afterEach, describe, expect, spyOn, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { runSchemaCli, type SchemaCliOut } from "../schema-cli";
6
+ import * as timeModule from "../time";
7
+
8
+ function captureOut(): { out: SchemaCliOut; log: string[]; err: string[] } {
9
+ const log: string[] = [];
10
+ const err: string[] = [];
11
+ return { out: { log: (l) => log.push(l), err: (l) => err.push(l) }, log, err };
12
+ }
13
+
14
+ describe("runSchemaCli — Temporal polyfill", () => {
15
+ let appCwd: string;
16
+
17
+ afterEach(() => {
18
+ if (appCwd) rmSync(appCwd, { recursive: true, force: true });
19
+ });
20
+
21
+ test("calls ensureTemporalPolyfill on every invocation", async () => {
22
+ // runProdApp/runDevApp call ensureTemporalPolyfill() at boot; the
23
+ // standalone CLI (migrate-db initContainer, `bun kumiko.js schema apply`)
24
+ // never goes through that boot path. Without this, a projection rebuild's
25
+ // tz/timestamp coercion throws "Temporal is not defined" on any runtime
26
+ // that lacks native Temporal — deterministically, since the crashed
27
+ // process still records the migration as applied and the rebuild is
28
+ // never retried on the next run.
29
+ //
30
+ // Asserting on globalThis.Temporal directly doesn't work here: the test
31
+ // harness's own preload (test-setup/base.preload.ts) already calls
32
+ // ensureTemporalPolyfill() once per process, and its idempotency cache
33
+ // (a module-level flag, not re-derived from globalThis) short-circuits
34
+ // any later call regardless of what a test does to globalThis.Temporal in
35
+ // between. Spying on the imported binding sidesteps that cache entirely.
36
+ appCwd = mkdtempSync(join(tmpdir(), "kumiko-schema-cli-temporal-"));
37
+ mkdirSync(join(appCwd, "kumiko"), { recursive: true });
38
+ const spy = spyOn(timeModule, "ensureTemporalPolyfill");
39
+ try {
40
+ const cap = captureOut();
41
+ const code = await runSchemaCli([], appCwd, cap.out);
42
+ expect(code).toBe(0);
43
+ expect(spy).toHaveBeenCalled();
44
+ } finally {
45
+ spy.mockRestore();
46
+ }
47
+ });
48
+ });
@@ -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
@@ -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
+ });
@@ -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
 
package/src/schema-cli.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  createProjectionStateTable,
37
37
  rebuildProjection,
38
38
  } from "./pipeline";
39
+ import { ensureTemporalPolyfill } from "./time";
39
40
 
40
41
  export type SchemaCliOut = {
41
42
  readonly log: (line: string) => void;
@@ -126,6 +127,13 @@ export async function runSchemaCli(
126
127
  out: SchemaCliOut,
127
128
  options: RunSchemaCliOptions = {},
128
129
  ): Promise<number> {
130
+ // runProdApp/runDevApp install this at boot; the standalone CLI (the
131
+ // migrate-db initContainer, `bun kumiko.js schema apply`) never goes through
132
+ // that boot path, so a projection rebuild's tz/timestamp coercion throws
133
+ // "Temporal is not defined" — deterministically, on every rebuild-marker
134
+ // migration, since the crashed process still records the migration as
135
+ // applied and the rebuild is never retried.
136
+ await ensureTemporalPolyfill();
129
137
  const sub = argv[0];
130
138
  const schemaFile = resolvePath(appCwd, "kumiko/schema.ts");
131
139
  const migrationsDir = resolvePath(appCwd, "kumiko/migrations");