Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions apps/cli/src/legacy/commands/migration/list/list.format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
LEGACY_MIGRATION_VERSION_MAX,
legacyFormatTimestampVersion,
legacyParseMigrationVersion,
legacySortMigrationVersions,
} from "../../../shared/legacy-migration-timestamp.format.ts";

/** A merged local/remote migration row. `local`/`remote` are empty when absent. */
Expand All @@ -20,10 +21,14 @@ export function legacyMakeMigrationListRows(
remote: ReadonlyArray<string>,
local: ReadonlyArray<string>,
): ReadonlyArray<LegacyMigrationListRow> {
// `legacyLoadLocalVersions` yields versions in file-name order, which reverses
// `ORDER BY version` whenever one version is a prefix of another
// (supabase/cli#6036), desynchronising the walk into duplicate half-empty rows.
const sortedLocal = legacySortMigrationVersions(local);
const rows: Array<LegacyMigrationListRow> = [];
let i = 0;
let j = 0;
while (i < remote.length || j < local.length) {
while (i < remote.length || j < sortedLocal.length) {
let remoteTs = LEGACY_MIGRATION_VERSION_MAX;
if (i < remote.length) {
const parsed = legacyParseMigrationVersion(remote[i]!);
Expand All @@ -34,23 +39,27 @@ export function legacyMakeMigrationListRows(
remoteTs = parsed;
}
let localTs = LEGACY_MIGRATION_VERSION_MAX;
if (j < local.length) {
const parsed = legacyParseMigrationVersion(local[j]!);
if (j < sortedLocal.length) {
const parsed = legacyParseMigrationVersion(sortedLocal[j]!);
if (parsed === undefined) {
j++;
continue;
}
localTs = parsed;
}
if (localTs < remoteTs) {
rows.push({ local: local[j]!, remote: "", time: legacyFormatTimestampVersion(local[j]!) });
rows.push({
local: sortedLocal[j]!,
remote: "",
time: legacyFormatTimestampVersion(sortedLocal[j]!),
});
j++;
} else if (remoteTs < localTs) {
rows.push({ local: "", remote: remote[i]!, time: legacyFormatTimestampVersion(remote[i]!) });
i++;
} else {
rows.push({
local: local[j]!,
local: sortedLocal[j]!,
remote: remote[i]!,
time: legacyFormatTimestampVersion(remote[i]!),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ describe("legacyMakeMigrationListRows", () => {
]);
});

it("pairs an 8-digit and a 14-digit version that share a prefix (#6036)", () => {
// Local versions arrive in file-name order, where `20260420010000_b.sql`
// precedes `20260420_a.sql` ('0' < '_') — the reverse of the `ORDER BY
// version` order `schema_migrations` is read back in. Unsorted, the walk
// desynchronises and reports `20260420` as both remote-only and local-only.
expect(
legacyMakeMigrationListRows(["20260420", "20260420010000"], ["20260420010000", "20260420"]),
).toEqual([
{ local: "20260420", remote: "20260420", time: "20260420" },
{ local: "20260420010000", remote: "20260420010000", time: "2026-04-20 01:00:00" },
]);
});

it("skips non-numeric versions on both sides", () => {
expect(legacyMakeMigrationListRows(["a", "c"], ["a", "b"])).toEqual([]);
});
Expand Down
25 changes: 25 additions & 0 deletions apps/cli/src/legacy/shared/legacy-migrate-and-seed.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -528,3 +528,28 @@ describe("legacyMigrateAndSeed local pg_net remediation", () => {
);
});
});

describe("legacyMigrateAndSeed apply order", () => {
it.effect("applies mixed-width versions in version order, like db push (#6036)", () => {
const workdir = makeWorkdir();
// `20260420010000_b.sql` precedes `20260420_a.sql` in file-name order
// ('0' < '_'), the reverse of the version order `db push` applies in since
// #6038. Unsorted, `db reset`/`db start` replay `b` before `a` locally while
// `db push` sends `a` before `b` remotely.
writeFile(workdir, "supabase/migrations/20260420_a.sql", "create table t (id int);");
writeFile(workdir, "supabase/migrations/20260420010000_b.sql", "alter table t add c int;");
const { session, execs } = fakeSession();
const out = mockOutput();
return run(workdir, "", baseConfig, session, out).pipe(
Effect.tap(() =>
Effect.sync(() => {
expect(execs.filter((sql) => sql.includes("table t"))).toEqual([
"create table t (id int)",
"alter table t add c int",
]);
rmSync(workdir, { recursive: true, force: true });
}),
),
);
});
});
8 changes: 7 additions & 1 deletion apps/cli/src/legacy/shared/legacy-migration-apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
INSERT_MIGRATION_VERSION,
MIGRATE_FILE_PATTERN,
legacyCreateMigrationTable,
legacySortMigrationPathsByVersion,
} from "./legacy-migration-history.ts";
import { legacyParseMigrationContent } from "./legacy-migration-file.ts";
import { legacySqlFilesGlob } from "./legacy-sql-files-glob.ts";
Expand Down Expand Up @@ -763,7 +764,12 @@ export const legacyApplyMigrations = <E>(
yield* legacyCreateMigrationTable(session).pipe(
Effect.mapError((e) => mapError(legacyErrorMessage(e))),
);
for (const migrationPath of pending) {
// Sorted by version, not by file name: `db push` has applied in version
// order since supabase/cli#6038, so callers that hand over a name-ordered
// listing (`db reset`, the shadow-database replay) would otherwise apply the
// same files in the opposite order (#6036). Idempotent for callers that
// already sorted.
for (const migrationPath of legacySortMigrationPathsByVersion(pending)) {
yield* output.raw(`Applying migration ${path.basename(migrationPath)}...\n`, "stderr");
// Reset connection state per migration before running the batch.
yield* resetConnectionState(session, mapError);
Expand Down
32 changes: 22 additions & 10 deletions apps/cli/src/legacy/shared/legacy-migration-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import type { LegacyDbExecError } from "./legacy-db-connection.errors.ts";
import type { LegacyDbSession } from "./legacy-db-connection.service.ts";
import {
LEGACY_MIGRATION_VERSION_MAX,
legacyCompareMigrationVersions,
legacyParseMigrationVersion,
legacySortMigrationVersions,
} from "./legacy-migration-timestamp.format.ts";
import { LegacyMigrationsReadError } from "./legacy-migration.errors.ts";
import { legacyParseMigrationContent } from "./legacy-migration-file.ts";
Expand Down Expand Up @@ -152,11 +154,16 @@ export function legacyReconcileMigrations(
// exhausted side; `legacyParseMigrationVersion` mirrors Go's `strconv.Atoi`
// (digits only, within int64, BigInt for exact ordering) and is shared with
// `migration list` so both surfaces skip the same edge-case versions.
// `legacyLoadLocalVersions` yields versions in file-name order, which reverses
// `ORDER BY version` whenever one version is a prefix of another
// (supabase/cli#6036) — the same desynchronisation
// `legacyFindPendingMigrations` sorts away below.
const sortedLocal = legacySortMigrationVersions(local);
const extraRemote: Array<string> = [];
const extraLocal: Array<string> = [];
let i = 0;
let j = 0;
while (i < remote.length || j < local.length) {
while (i < remote.length || j < sortedLocal.length) {
let remoteTs = LEGACY_MIGRATION_VERSION_MAX;
if (i < remote.length) {
const parsed = legacyParseMigrationVersion(remote[i]!);
Expand All @@ -167,16 +174,16 @@ export function legacyReconcileMigrations(
remoteTs = parsed;
}
let localTs = LEGACY_MIGRATION_VERSION_MAX;
if (j < local.length) {
const parsed = legacyParseMigrationVersion(local[j]!);
if (j < sortedLocal.length) {
const parsed = legacyParseMigrationVersion(sortedLocal[j]!);
if (parsed === undefined) {
j++;
continue;
}
localTs = parsed;
}
if (localTs < remoteTs) {
extraLocal.push(local[j]!);
extraLocal.push(sortedLocal[j]!);
j++;
} else if (remoteTs < localTs) {
extraRemote.push(remote[i]!);
Expand Down Expand Up @@ -314,7 +321,7 @@ export function legacySortMigrationPathsByVersion(
return [...localPaths].sort((a, b) => {
const versionA = MIGRATE_FILE_PATTERN.exec(baseName(a))?.[1] ?? "";
const versionB = MIGRATE_FILE_PATTERN.exec(baseName(b))?.[1] ?? "";
return versionA < versionB ? -1 : versionA > versionB ? 1 : 0;
return legacyCompareMigrationVersions(versionA, versionB);
});
}

Expand Down Expand Up @@ -382,11 +389,16 @@ export const legacyLoadPartialMigrations = (
) =>
legacyListLocalMigrations(fs, path, migrationsDir).pipe(
Effect.map((paths) =>
paths.filter((p) => {
if (version.length === 0) return true;
const v = MIGRATE_FILE_PATTERN.exec(path.basename(p))?.[1];
return v !== undefined && v <= version;
}),
// Sorted by version, not by file name: `db push` has applied in version
// order since supabase/cli#6038, so replaying in name order here would
// apply the same files in the opposite order locally (#6036).
legacySortMigrationPathsByVersion(
paths.filter((p) => {
if (version.length === 0) return true;
const v = MIGRATE_FILE_PATTERN.exec(path.basename(p))?.[1];
return v !== undefined && v <= version;
}),
),
),
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ describe("legacyReconcileMigrations", () => {
expect(result.kind).toBe("conflict");
});

it("is in sync when an 8-digit and a 14-digit version share a prefix (#6036)", () => {
// Local versions arrive in file-name order, where `20260420010000_b.sql`
// precedes `20260420_a.sql` ('0' < '_') — the reverse of the `ORDER BY
// version` order `schema_migrations` is read back in. Unsorted, the walk
// desynchronises into a conflict whose repair suggestion asks for the same
// version to be marked both reverted and applied.
expect(
legacyReconcileMigrations(["20260420", "20260420010000"], ["20260420010000", "20260420"]),
).toEqual({ kind: "in-sync" });
});

it("skips versions that do not parse as integers", () => {
// A non-numeric remote version is skipped (Go's Atoi-error continue), leaving
// the numeric ones in sync.
Expand Down
17 changes: 17 additions & 0 deletions apps/cli/src/legacy/shared/legacy-migration-timestamp.format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,20 @@ export const legacyParseMigrationVersion = (value: string): bigint | undefined =
? undefined
: parsed;
};

/** Lexical version order, shared by every version sorter so the walks cannot drift apart. */
export const legacyCompareMigrationVersions = (a: string, b: string): number =>
a < b ? -1 : a > b ? 1 : 0;

/**
* Orders bare version strings the way `ORDER BY version` returns them, for the
* walks that compare version lists rather than paths. `version` is a `text`
* column, so Postgres orders it lexically and a prefix always precedes its
* extension — exactly what `legacySortMigrationPathsByVersion`
* (`legacy-migration-history.ts`) reproduces for the path-shaped walks.
*/
export function legacySortMigrationVersions(
versions: ReadonlyArray<string>,
): ReadonlyArray<string> {
return [...versions].sort(legacyCompareMigrationVersions);
}
Loading