Skip to content

Commit ca8ff44

Browse files
committed
fix(server): refuse mismatched fork ledger adoption and verify fork slots
Adoption inserted with OR IGNORE and deleted the legacy row unconditionally, so a fork ledger already holding that slot under another name would drop the only record that the migration ran. It now fails with a BadState MigrationError inside the transaction, leaving every legacy row in place. migrate-dev-db's slot-collision check now covers the fork ledger as well as upstream's, with the ledger named in the error. Findings from an advisory GPT-5.6 Sol review of the PR. Model: Claude Fable 5 via Claude Code.
1 parent 8bfcc9f commit ca8ff44

4 files changed

Lines changed: 106 additions & 13 deletions

File tree

apps/server/scripts/migrate-dev-db.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,36 @@ it.layer(NodeServices.layer)("migrate-dev-db", (it) => {
132132
}),
133133
);
134134

135+
it.effect("fails loudly on a fork migration slot collision", () =>
136+
Effect.gen(function* () {
137+
const fs = yield* FileSystem.FileSystem;
138+
const sourceDir = yield* fs.makeTempDirectoryScoped({ prefix: "migrate-dev-db-fork-slot-" });
139+
const destDir = yield* fs.makeTempDirectoryScoped({
140+
prefix: "migrate-dev-db-fork-slot-dest-",
141+
});
142+
const source = yield* createFixtureSource(sourceDir);
143+
yield* withDatabase(
144+
source,
145+
Effect.gen(function* () {
146+
const sql = yield* SqlClient.SqlClient;
147+
yield* sql`UPDATE effect_sql_migrations_fork
148+
SET name = 'SomebodyElsesForkMigration' WHERE migration_id = 1`;
149+
}),
150+
);
151+
152+
const error = yield* runMigrateDevDb(
153+
{ baseDir: destDir, source, projects: 5, threadsPerProject: 10 },
154+
{ sharedHome: sourceDir },
155+
).pipe(Effect.flip);
156+
assert.equal(error._tag, "MigrateDevDbSlotCollisionError");
157+
if (error._tag === "MigrateDevDbSlotCollisionError") {
158+
assert.equal(error.ledger, "effect_sql_migrations_fork");
159+
assert.equal(error.slot, 1);
160+
assert.equal(error.appliedName, "SomebodyElsesForkMigration");
161+
}
162+
}),
163+
);
164+
135165
it.effect("refuses while a dev server holds the destination", () =>
136166
Effect.gen(function* () {
137167
const fs = yield* FileSystem.FileSystem;

apps/server/scripts/migrate-dev-db.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,11 @@ import * as Schema from "effect/Schema";
3838
import * as SqlClient from "effect/unstable/sql/SqlClient";
3939
import { Command, Flag } from "effect/unstable/cli";
4040

41-
import { runAllMigrations } from "../src/persistence/ForkMigrations.ts";
41+
import {
42+
forkMigrationManifest,
43+
forkMigrationsTable,
44+
runAllMigrations,
45+
} from "../src/persistence/ForkMigrations.ts";
4246
import { migrationManifest } from "../src/persistence/Migrations.ts";
4347
import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts";
4448

@@ -119,13 +123,14 @@ export class MigrateDevDbDestinationBusyError extends Schema.TaggedErrorClass<Mi
119123
export class MigrateDevDbSlotCollisionError extends Schema.TaggedErrorClass<MigrateDevDbSlotCollisionError>()(
120124
"MigrateDevDbSlotCollisionError",
121125
{
126+
ledger: Schema.String,
122127
slot: Schema.Number,
123128
codeName: Schema.String,
124129
appliedName: Schema.String,
125130
},
126131
) {
127132
override get message(): string {
128-
return `Migration slot collision at ${this.slot}: this checkout registers '${this.codeName}' but the database already applied '${this.appliedName}' in that slot. Renumber the new migration to a free slot.`;
133+
return `Migration slot collision at ${this.ledger} ${this.slot}: this checkout registers '${this.codeName}' but the database already applied '${this.appliedName}' in that slot. Renumber the new migration to a free slot.`;
129134
}
130135
}
131136

@@ -337,15 +342,22 @@ const pruneSnapshot = Effect.fn("pruneDevDbSnapshot")(function* (input: RunMigra
337342
/** Compare this checkout's migration registry against what the cloned
338343
* database recorded: same slot under a different name means the migration
339344
* was skipped, not applied. */
345+
const migrationLedgers = [
346+
["effect_sql_migrations", migrationManifest],
347+
[forkMigrationsTable, forkMigrationManifest],
348+
] as const;
349+
340350
const verifyMigrationSlots = Effect.fn("verifyMigrationSlots")(function* () {
341351
const sql = yield* SqlClient.SqlClient;
342-
const applied = yield* sql<{ migration_id: number; name: string }>`
343-
SELECT migration_id, name FROM effect_sql_migrations`;
344-
const appliedById = new Map(applied.map((row) => [Number(row.migration_id), row.name]));
345-
for (const [slot, codeName] of migrationManifest) {
346-
const appliedName = appliedById.get(slot);
347-
if (appliedName !== undefined && appliedName !== codeName) {
348-
return yield* new MigrateDevDbSlotCollisionError({ slot, codeName, appliedName });
352+
for (const [ledger, manifest] of migrationLedgers) {
353+
const applied = yield* sql<{ migration_id: number; name: string }>`
354+
SELECT migration_id, name FROM ${sql(ledger)}`;
355+
const appliedById = new Map(applied.map((row) => [Number(row.migration_id), row.name]));
356+
for (const [slot, codeName] of manifest) {
357+
const appliedName = appliedById.get(slot);
358+
if (appliedName !== undefined && appliedName !== codeName) {
359+
return yield* new MigrateDevDbSlotCollisionError({ ledger, slot, codeName, appliedName });
360+
}
349361
}
350362
}
351363
});

apps/server/src/persistence/ForkMigrations.test.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ freshLayer()(
119119
yield* applyLegacyForkMigrations;
120120
const takenId = legacyForkRow(forkMigrationEntries.length);
121121
yield* sql`
122-
UPDATE effect_sql_migrations SET name = 'OrchestrationV2' WHERE migration_id = ${takenId}
122+
UPDATE effect_sql_migrations SET name = 'ApplicationEventSource' WHERE migration_id = ${takenId}
123123
`;
124124

125125
const result = yield* runAllMigrations();
@@ -142,3 +142,36 @@ freshLayer()(
142142
);
143143
},
144144
);
145+
146+
freshLayer()("ForkMigrations when the fork ledger already holds a different migration", (it) => {
147+
it.effect("refuses to adopt rather than dropping the legacy row", () =>
148+
Effect.gen(function* () {
149+
const sql = yield* SqlClient.SqlClient;
150+
yield* applyLegacyForkMigrations;
151+
yield* sql`
152+
CREATE TABLE ${sql(forkMigrationsTable)} (
153+
migration_id integer PRIMARY KEY NOT NULL,
154+
created_at datetime NOT NULL DEFAULT current_timestamp,
155+
name VARCHAR(255) NOT NULL
156+
)
157+
`;
158+
yield* sql`
159+
INSERT INTO ${sql(forkMigrationsTable)} (migration_id, name)
160+
VALUES (${forkMigrationEntries.length}, 'DifferentMigration')
161+
`;
162+
163+
const error = yield* runAllMigrations().pipe(Effect.flip);
164+
165+
assert.equal(error._tag, "MigrationError");
166+
if (error._tag === "MigrationError") {
167+
assert.equal(error.kind, "BadState");
168+
}
169+
// The transaction rolled back: every legacy row is still in place.
170+
assert.deepEqual(yield* readIds("effect_sql_migrations"), [
171+
...migrationManifest.map(([id]) => id),
172+
...forkMigrationManifest.map(([id]) => legacyForkRow(id)),
173+
]);
174+
assert.deepEqual(yield* readIds(forkMigrationsTable), [forkMigrationEntries.length]);
175+
}),
176+
);
177+
});

apps/server/src/persistence/ForkMigrations.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,30 @@ const adoptLegacyForkLedgerRows = Effect.fn("adoptLegacyForkLedgerRows")(functio
8282
([id, name]) => {
8383
const legacyId = legacyUpstreamIdOffset + id;
8484
return Effect.gen(function* () {
85-
yield* sql`
86-
INSERT OR IGNORE INTO ${sql(forkMigrationsTable)} (migration_id, created_at, name)
87-
SELECT ${id}, created_at, name
85+
const legacy = yield* sql<{ readonly createdAt: string }>`
86+
SELECT created_at AS "createdAt"
8887
FROM ${sql(upstreamMigrationsTable)}
8988
WHERE migration_id = ${legacyId} AND name = ${name}
9089
`;
90+
if (legacy.length === 0) {
91+
return;
92+
}
93+
const existing = yield* sql<{ readonly name: string }>`
94+
SELECT name FROM ${sql(forkMigrationsTable)} WHERE migration_id = ${id}
95+
`;
96+
if (existing.length === 0) {
97+
yield* sql`
98+
INSERT INTO ${sql(forkMigrationsTable)} (migration_id, created_at, name)
99+
VALUES (${id}, ${legacy[0]!.createdAt}, ${name})
100+
`;
101+
} else if (existing[0]!.name !== name) {
102+
// Deleting the legacy row here would lose the only record that the
103+
// fork migration ran; refuse instead of silently skipping it.
104+
return yield* new Migrator.MigrationError({
105+
kind: "BadState",
106+
message: `Fork migration ledger slot ${id} holds "${existing[0]!.name}" but upstream ledger row ${legacyId} records "${name}"`,
107+
});
108+
}
91109
yield* sql`
92110
DELETE FROM ${sql(upstreamMigrationsTable)}
93111
WHERE migration_id = ${legacyId} AND name = ${name}

0 commit comments

Comments
 (0)