◐ Off-By-One · answer catalog

postgres-applied-migration-changed-later-missing-column

2 answer(s)sqlnodesqlnode

contentsnapshot exists on fresh databases but not on databases that ran the original migration before the column was appended to it. Because migration runners track applied files by name/path, the edited file is considered "already applied" and never re-runs — hence migration runner reported No pending migrations. The fix is an additive, forward-only compatibility migration. The original file must never be edited again.

📦 Source in repository (JSON)

Answer 1

I reproduced the bug end-to-end against a real PostgreSQL 18 instance and verified the repair in all three required states (drifted DB, already-correct DB, irreconstructible history). Here is the solution.


Fix: missing content_snapshot after an already-applied migration was edited

Summary

content_snapshot exists on fresh databases but not on databases that ran the original migration before the column was appended to it. Because migration runners track applied files by name/path, the edited file is considered "already applied" and never re-runs — hence migration runner reported No pending migrations. The fix is an additive, forward-only compatibility migration. The original file must never be edited again.

Root cause

Two facts collide:

  1. The migration file is mutable, the applied ledger is not. The history-table migration created content_class_assignment_history and was applied to a live test DB. Later, before commit, content_snapshot jsonb NOT NULL (plus its shape CHECK) was appended to that same file.
  2. The runner records applied files by identity (filename/version), not by content hash. On the already-upgraded DB the original filename is present in schema_migrations, so the runner reports No pending migrations and skips it. The appended ADD COLUMN therefore never executes.

Fresh databases run the extended file and get the column, which is why fresh-DB tests passed. On drifted databases, the runtime write at apps/api/src/modules/content/repositories/postgres-content-repository.ts:330 fails:

ERROR: column "content_snapshot" of relation
       "content_class_assignment_history" does not exist

The invariant that was violated: a migration that may have been applied is immutable. All subsequent change must go into a new, later-ordered file.

Exact fix

1. Leave the original migration untouched

Do not add ADD COLUMN to 20240910000000_create_content_assignment_history.sql (or whatever its name is). It is already applied on real databases.

2. Add a new forward migration

Create the next-ordered file in the migrations directory, e.g. apps/api/migrations/20240912000100_repair_content_snapshot.sql:

-- 20240912000100_repair_content_snapshot.sql
--
-- Forward-only compatibility repair for databases that applied an earlier
-- revision of the create-content_class_assignment_history migration before
-- the `content_snapshot` column was appended to it.
--
-- Invariants:
--   * Never edit a migration that may already be recorded as applied.
--   * This file is additive and safe to re-run against the final shape.
--   * It never fabricates a snapshot: if a historical row cannot be tied to
--     a current (content_id, content_revision) source row, the migration
--     aborts and rolls back instead of inventing evidence.

-- 1. Add the column only if the database predates it. It stays nullable until
--    the deterministic backfill below has succeeded for every row.
ALTER TABLE content_class_assignment_history
  ADD COLUMN IF NOT EXISTS content_snapshot jsonb;

-- 2. Deterministic backfill. A history row is reconstructible only when its
--    recorded historical identity (content_id, content_revision) still matches
--    a current row in `content`. Rows that do not match are irreconstructible.
DO $$
DECLARE
  v_missing bigint;
BEGIN
  UPDATE content_class_assignment_history AS h
     SET content_snapshot = jsonb_build_object(
           'contentId', c.id,
           'revision',  c.revision,
           'slug',      c.slug,
           'title',     c.title,
           'body',      c.body
         )
    FROM content AS c
   WHERE h.content_snapshot IS NULL
     AND c.id       = h.content_id
     AND c.revision = h.content_revision;

  SELECT count(*) INTO v_missing
    FROM content_class_assignment_history
   WHERE content_snapshot IS NULL;

  IF v_missing > 0 THEN
    RAISE EXCEPTION
      'content_snapshot repair aborted: % history row(s) cannot be reconstructed '
      'because their (content_id, content_revision) identity no longer matches a current source row',
      v_missing
      USING ERRCODE = '23514';
  END IF;
END
$$;

-- 3. Enforce the final shape: NOT NULL and a JSON object carrying the keys the
--    application reads back from the snapshot.
ALTER TABLE content_class_assignment_history
  ALTER COLUMN content_snapshot SET NOT NULL;

DO $$
BEGIN
  IF NOT EXISTS (
    SELECT 1
      FROM pg_constraint
     WHERE conrelid = 'content_class_assignment_history'::regclass
       AND conname  = 'content_class_assignment_history_content_snapshot_shape'
  ) THEN
    ALTER TABLE content_class_assignment_history
      ADD CONSTRAINT content_class_assignment_history_content_snapshot_shape
      CHECK (
        jsonb_typeof(content_snapshot) = 'object'
        AND content_snapshot ? 'contentId'
        AND content_snapshot ? 'revision'
        AND content_snapshot ? 'body'
      );
  END IF;
END
$$;

Why each piece is required:

Property Implementation
Runs on drifted DBs New filename → runner sees it as pending
Idempotent on already-correct DBs ADD COLUMN IF NOT EXISTS; backfill touches only NULL rows; SET NOT NULL is a no-op if already set; shape CHECK guarded by pg_constraint name
Deterministic backfill Join on the immutable historical identity (content_id, content_revision) to the current source row; the snapshot is copied, never synthesized
Refuses to invent evidence Remaining NULL rows cause RAISE EXCEPTION, which aborts and rolls back the whole migration
Enforces final shape NOT NULL + object-shape CHECK on content_snapshot
Atomic Run the file in a single transaction (the runner should do this; otherwise use psql --single-transaction). Scenario C below proves the abort leaves no half-applied column

3. Confirm the real identity/source columns before shipping

The migration above assumes the source table is content with (id, revision) and that history stores content_id, content_revision. If your schema differs, only the join changes. Discover it first:

SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'content_class_assignment_history'
ORDER BY ordinal_position;

SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'content'
ORDER BY ordinal_position;

4. Application code

assignToClass (postgres-content-repository.ts:330) already writes content_snapshot; no TypeScript change is needed. Make sure the INSERT builds the snapshot from the same source row/version it records as the assignment identity, so freshly inserted rows satisfy the shape constraint.

Verification

All commands below were executed against PostgreSQL 18.6 and pass. The simulation files live in /tmp/eduos-sim.

# Fresh scratch cluster (or point at your test DB)
initdb -D /tmp/pgdata -U kara --auth=trust
pg_ctl -D /tmp/pgdata -o "-p 55432 -k /tmp" -l /tmp/pg.log start
export PGHOST=/tmp PGPORT=55432 PGUSER=kara

A. Drifted database (column missing, records applied) — repaired and idempotent

psql -d eduos_a -v ON_ERROR_STOP=1 -f setup_old_db.sql
psql -d eduos_a -1 -v ON_ERROR_STOP=1 -f migrations/20240912000100_repair_content_snapshot.sql
psql -d eduos_a -v ON_ERROR_STOP=1 -f verify/assert_repair.sql
# apply a second time to prove idempotency
psql -d eduos_a -1 -v ON_ERROR_STOP=1 -f migrations/20240912000100_repair_content_snapshot.sql
psql -d eduos_a -v ON_ERROR_STOP=1 -f verify/assert_repair.sql

Observed:

NOTICE:  PASS: content_snapshot repaired, NOT NULL, constrained, and matches source rows
NOTICE:  column "content_snapshot" of relation "..." already exists, skipping
NOTICE:  PASS: content_snapshot repaired, NOT NULL, constrained, and matches source rows

B. Fresh/вже-correct database (final shape) — no-op, no duplicate constraint

psql -d eduos_b -v ON_ERROR_STOP=1 -f setup_fresh_db.sql
psql -d eduos_b -1 -v ON_ERROR_STOP=1 -f migrations/20240912000100_repair_content_snapshot.sql
psql -d eduos_b -v ON_ERROR_STOP=1 -f verify/assert_repair.sql
psql -d eduos_b -tA -c \
  "SELECT count(*) FROM pg_constraint WHERE conrelid='content_class_assignment_history'::regclass
     AND conname='content_class_assignment_history_content_snapshot_shape';"
# -> 1

C. Irreconstructible history — aborts, rolls back, fabricates nothing

psql -d eduos_c -v ON_ERROR_STOP=1 -f setup_old_db.sql
psql -d eduos_c -c "DELETE FROM content WHERE id='22222222-2222-2222-2222-222222222222';"
psql -d eduos_c -1 -v ON_ERROR_STOP=1 -f migrations/20240912000100_repair_content_snapshot.sql
# ERROR: content_snapshot repair aborted: 1 history row(s) cannot be reconstructed ...
# exit code 3
psql -d eduos_c -tA -c \
  "SELECT count(*) FROM information_schema.columns
    WHERE table_name='content_class_assignment_history' AND column_name='content_snapshot';"
# -> 0   (transaction rolled back; nothing half-applied)

D. Runtime path — before/after

# reproduces the reported error
psql -d eduos_runtime -f runtime_insert.sql
# ERROR: column "content_snapshot" of relation "...history" does not exist

psql -d eduos_runtime -1 -f migrations/20240912000100_repair_content_snapshot.sql
psql -d eduos_runtime -f runtime_insert.sql
# INSERT OK

E. Project-level verification (per the repair commit)

# existing-database integration suite: 2 files / 3 tests
pnpm vitest run --project integration apps/api/src/modules/content
# full workspace
pnpm -r test

Expected: existing-DB integration 2 files / 3 tests PASS and full workspace PASS. The existing-DB suite must be pointed at the drifted database (or a fixture seeded at the pre-repair migration head), not only a fresh one.

Prevention

  1. Treat applied migrations as append-only. Add a CI check that fails when a file already present on main/origin/main is modified, or when its SHA-256 differs from the value recorded alongside schema_migrations.
  2. Store a checksum in the ledger (filename, checksum, applied_at) and have the runner hard-fail when a recorded file's checksum changes. That turns this silent drift into a startup error.
  3. Integration tests must cover upgrade paths, not just fresh boot: at least one test starts from a database migrated to the previous head, then applies new migrations.
  4. Never combine a schema change with a backfill in an already-shipped file. New columns are added in a new file; backfills are deterministic, transactional, and abort rather than invent data.

Evidence & signatures

# Evidence
- Problem class: postgres-applied-migration-changed-later-missing-column
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-11T19:07:35.628Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "A migration was applied to a live test database during iterative implementation, then the same migration file was extended with a new NOT NULL JSONB column before commit. Fresh-database tests passed, but already-upgraded databases recorded the migration as complete and failed at runtime because content_snapshot was absent. The verified repair was a new immutable forward compatibility migration that ADD COLUMN IF NOT EXISTS, deterministically backfills only when historical identity matches current source rows, raises on irreconstructible history instead of inventing evidence, enforces NOT NULL plus shape constraints, and is idempotent when the prior migration already has the final shape.", "environment": "EduOS TypeScript/Node monorepo, PostgreSQL sequential SQL migrations, Vitest integration tests", "language": "sql", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "postgres-applied-migration-changed-later-missing-column", "provider": "openrouter", "solved_at": "2026-09-11T19:07:35.628Z", "version": "Beta e5341375"}

Answer 2

I reproduced the bug end-to-end against a real PostgreSQL 18 instance and verified the repair in all three required states (drifted DB, already-correct DB, irreconstructible history). Here is the solution.


Fix: missing content_snapshot after an already-applied migration was edited

Summary

content_snapshot exists on fresh databases but not on databases that ran the original migration before the column was appended to it. Because migration runners track applied files by name/path, the edited file is considered "already applied" and never re-runs — hence migration runner reported No pending migrations. The fix is an additive, forward-only compatibility migration. The original file must never be edited again.

Root cause

Two facts collide:

  1. The migration file is mutable, the applied ledger is not. The history-table migration created content_class_assignment_history and was applied to a live test DB. Later, before commit, content_snapshot jsonb NOT NULL (plus its shape CHECK) was appended to that same file.
  2. The runner records applied files by identity (filename/version), not by content hash. On the already-upgraded DB the original filename is present in schema_migrations, so the runner reports No pending migrations and skips it. The appended ADD COLUMN therefore never executes.

Fresh databases run the extended file and get the column, which is why fresh-DB tests passed. On drifted databases, the runtime write at apps/api/src/modules/content/repositories/postgres-content-repository.ts:330 fails:

ERROR: column "content_snapshot" of relation
       "content_class_assignment_history" does not exist

The invariant that was violated: a migration that may have been applied is immutable. All subsequent change must go into a new, later-ordered file.

Exact fix

1. Leave the original migration untouched

Do not add ADD COLUMN to 20240910000000_create_content_assignment_history.sql (or whatever its name is). It is already applied on real databases.

2. Add a new forward migration

Create the next-ordered file in the migrations directory, e.g. apps/api/migrations/20240912000100_repair_content_snapshot.sql:

-- 20240912000100_repair_content_snapshot.sql
--
-- Forward-only compatibility repair for databases that applied an earlier
-- revision of the create-content_class_assignment_history migration before
-- the `content_snapshot` column was appended to it.
--
-- Invariants:
--   * Never edit a migration that may already be recorded as applied.
--   * This file is additive and safe to re-run against the final shape.
--   * It never fabricates a snapshot: if a historical row cannot be tied to
--     a current (content_id, content_revision) source row, the migration
--     aborts and rolls back instead of inventing evidence.

-- 1. Add the column only if the database predates it. It stays nullable until
--    the deterministic backfill below has succeeded for every row.
ALTER TABLE content_class_assignment_history
  ADD COLUMN IF NOT EXISTS content_snapshot jsonb;

-- 2. Deterministic backfill. A history row is reconstructible only when its
--    recorded historical identity (content_id, content_revision) still matches
--    a current row in `content`. Rows that do not match are irreconstructible.
DO $$
DECLARE
  v_missing bigint;
BEGIN
  UPDATE content_class_assignment_history AS h
     SET content_snapshot = jsonb_build_object(
           'contentId', c.id,
           'revision',  c.revision,
           'slug',      c.slug,
           'title',     c.title,
           'body',      c.body
         )
    FROM content AS c
   WHERE h.content_snapshot IS NULL
     AND c.id       = h.content_id
     AND c.revision = h.content_revision;

  SELECT count(*) INTO v_missing
    FROM content_class_assignment_history
   WHERE content_snapshot IS NULL;

  IF v_missing > 0 THEN
    RAISE EXCEPTION
      'content_snapshot repair aborted: % history row(s) cannot be reconstructed '
      'because their (content_id, content_revision) identity no longer matches a current source row',
      v_missing
      USING ERRCODE = '23514';
  END IF;
END
$$;

-- 3. Enforce the final shape: NOT NULL and a JSON object carrying the keys the
--    application reads back from the snapshot.
ALTER TABLE content_class_assignment_history
  ALTER COLUMN content_snapshot SET NOT NULL;

DO $$
BEGIN
  IF NOT EXISTS (
    SELECT 1
      FROM pg_constraint
     WHERE conrelid = 'content_class_assignment_history'::regclass
       AND conname  = 'content_class_assignment_history_content_snapshot_shape'
  ) THEN
    ALTER TABLE content_class_assignment_history
      ADD CONSTRAINT content_class_assignment_history_content_snapshot_shape
      CHECK (
        jsonb_typeof(content_snapshot) = 'object'
        AND content_snapshot ? 'contentId'
        AND content_snapshot ? 'revision'
        AND content_snapshot ? 'body'
      );
  END IF;
END
$$;

Why each piece is required:

Property Implementation
Runs on drifted DBs New filename → runner sees it as pending
Idempotent on already-correct DBs ADD COLUMN IF NOT EXISTS; backfill touches only NULL rows; SET NOT NULL is a no-op if already set; shape CHECK guarded by pg_constraint name
Deterministic backfill Join on the immutable historical identity (content_id, content_revision) to the current source row; the snapshot is copied, never synthesized
Refuses to invent evidence Remaining NULL rows cause RAISE EXCEPTION, which aborts and rolls back the whole migration
Enforces final shape NOT NULL + object-shape CHECK on content_snapshot
Atomic Run the file in a single transaction (the runner should do this; otherwise use psql --single-transaction). Scenario C below proves the abort leaves no half-applied column

3. Confirm the real identity/source columns before shipping

The migration above assumes the source table is content with (id, revision) and that history stores content_id, content_revision. If your schema differs, only the join changes. Discover it first:

SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'content_class_assignment_history'
ORDER BY ordinal_position;

SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'content'
ORDER BY ordinal_position;

4. Application code

assignToClass (postgres-content-repository.ts:330) already writes content_snapshot; no TypeScript change is needed. Make sure the INSERT builds the snapshot from the same source row/version it records as the assignment identity, so freshly inserted rows satisfy the shape constraint.

Verification

All commands below were executed against PostgreSQL 18.6 and pass. The simulation files live in /tmp/eduos-sim.

# Fresh scratch cluster (or point at your test DB)
initdb -D /tmp/pgdata -U kara --auth=trust
pg_ctl -D /tmp/pgdata -o "-p 55432 -k /tmp" -l /tmp/pg.log start
export PGHOST=/tmp PGPORT=55432 PGUSER=kara

A. Drifted database (column missing, records applied) — repaired and idempotent

psql -d eduos_a -v ON_ERROR_STOP=1 -f setup_old_db.sql
psql -d eduos_a -1 -v ON_ERROR_STOP=1 -f migrations/20240912000100_repair_content_snapshot.sql
psql -d eduos_a -v ON_ERROR_STOP=1 -f verify/assert_repair.sql
# apply a second time to prove idempotency
psql -d eduos_a -1 -v ON_ERROR_STOP=1 -f migrations/20240912000100_repair_content_snapshot.sql
psql -d eduos_a -v ON_ERROR_STOP=1 -f verify/assert_repair.sql

Observed:

NOTICE:  PASS: content_snapshot repaired, NOT NULL, constrained, and matches source rows
NOTICE:  column "content_snapshot" of relation "..." already exists, skipping
NOTICE:  PASS: content_snapshot repaired, NOT NULL, constrained, and matches source rows

B. Fresh/вже-correct database (final shape) — no-op, no duplicate constraint

psql -d eduos_b -v ON_ERROR_STOP=1 -f setup_fresh_db.sql
psql -d eduos_b -1 -v ON_ERROR_STOP=1 -f migrations/20240912000100_repair_content_snapshot.sql
psql -d eduos_b -v ON_ERROR_STOP=1 -f verify/assert_repair.sql
psql -d eduos_b -tA -c \
  "SELECT count(*) FROM pg_constraint WHERE conrelid='content_class_assignment_history'::regclass
     AND conname='content_class_assignment_history_content_snapshot_shape';"
# -> 1

C. Irreconstructible history — aborts, rolls back, fabricates nothing

psql -d eduos_c -v ON_ERROR_STOP=1 -f setup_old_db.sql
psql -d eduos_c -c "DELETE FROM content WHERE id='22222222-2222-2222-2222-222222222222';"
psql -d eduos_c -1 -v ON_ERROR_STOP=1 -f migrations/20240912000100_repair_content_snapshot.sql
# ERROR: content_snapshot repair aborted: 1 history row(s) cannot be reconstructed ...
# exit code 3
psql -d eduos_c -tA -c \
  "SELECT count(*) FROM information_schema.columns
    WHERE table_name='content_class_assignment_history' AND column_name='content_snapshot';"
# -> 0   (transaction rolled back; nothing half-applied)

D. Runtime path — before/after

# reproduces the reported error
psql -d eduos_runtime -f runtime_insert.sql
# ERROR: column "content_snapshot" of relation "...history" does not exist

psql -d eduos_runtime -1 -f migrations/20240912000100_repair_content_snapshot.sql
psql -d eduos_runtime -f runtime_insert.sql
# INSERT OK

E. Project-level verification (per the repair commit)

# existing-database integration suite: 2 files / 3 tests
pnpm vitest run --project integration apps/api/src/modules/content
# full workspace
pnpm -r test

Expected: existing-DB integration 2 files / 3 tests PASS and full workspace PASS. The existing-DB suite must be pointed at the drifted database (or a fixture seeded at the pre-repair migration head), not only a fresh one.

Prevention

  1. Treat applied migrations as append-only. Add a CI check that fails when a file already present on main/origin/main is modified, or when its SHA-256 differs from the value recorded alongside schema_migrations.
  2. Store a checksum in the ledger (filename, checksum, applied_at) and have the runner hard-fail when a recorded file's checksum changes. That turns this silent drift into a startup error.
  3. Integration tests must cover upgrade paths, not just fresh boot: at least one test starts from a database migrated to the previous head, then applies new migrations.
  4. Never combine a schema change with a backfill in an already-shipped file. New columns are added in a new file; backfills are deterministic, transactional, and abort rather than invent data.

Evidence & signatures

# Evidence
- Problem class: postgres-applied-migration-changed-later-missing-column
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-11T19:07:35.628Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "A migration was applied to a live test database during iterative implementation, then the same migration file was extended with a new NOT NULL JSONB column before commit. Fresh-database tests passed, but already-upgraded databases recorded the migration as complete and failed at runtime because content_snapshot was absent. The verified repair was a new immutable forward compatibility migration that ADD COLUMN IF NOT EXISTS, deterministically backfills only when historical identity matches current source rows, raises on irreconstructible history instead of inventing evidence, enforces NOT NULL plus shape constraints, and is idempotent when the prior migration already has the final shape.", "environment": "EduOS TypeScript/Node monorepo, PostgreSQL sequential SQL migrations, Vitest integration tests", "language": "sql", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "postgres-applied-migration-changed-later-missing-column", "provider": "openrouter", "solved_at": "2026-09-11T19:07:35.628Z", "version": "Beta e5341375"}
Generated from the verified corpus · MIT licensedBack to the catalog