From 142ff512a7b52004ff40305db6843290006a188f Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:26:41 +0530 Subject: [PATCH 1/4] fix(cli): sort local versions before the db pull reconcile walk --- .../legacy/shared/legacy-migration-history.ts | 32 ++++++++++++++++--- .../legacy-migration-history.unit.test.ts | 11 +++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-migration-history.ts b/apps/cli/src/legacy/shared/legacy-migration-history.ts index 49a6f3e883..730229a445 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-history.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-history.ts @@ -152,11 +152,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 = []; const extraLocal: Array = []; 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]!); @@ -167,8 +172,8 @@ 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; @@ -176,7 +181,7 @@ export function legacyReconcileMigrations( localTs = parsed; } if (localTs < remoteTs) { - extraLocal.push(local[j]!); + extraLocal.push(sortedLocal[j]!); j++; } else if (remoteTs < localTs) { extraRemote.push(remote[i]!); @@ -299,6 +304,10 @@ export const legacyLoadLocalVersions = ( /** Basename of a path, handling both `/` and `\` separators (keeps the helper pure). */ const baseName = (filePath: string): string => filePath.split(/[\\/]/u).pop() ?? filePath; +/** Lexical version order, shared by both sorters so the walks cannot drift apart. */ +const legacyCompareMigrationVersions = (a: string, b: string): number => + a < b ? -1 : a > b ? 1 : 0; + /** * Orders local migration paths by version so they line up with * `schema_migrations` (`ORDER BY version`) before a two-pointer walk compares @@ -314,10 +323,23 @@ 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); }); } +/** + * 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 {@link legacySortMigrationPathsByVersion} reproduces + * for the path-shaped walks. + */ +export function legacySortMigrationVersions( + versions: ReadonlyArray, +): ReadonlyArray { + return [...versions].sort(legacyCompareMigrationVersions); +} + /** Outcome of `legacyFindPendingMigrations` — `(slice, error)` as a tagged union. */ export type LegacyPendingMigrations = | { readonly kind: "pending"; readonly paths: ReadonlyArray } diff --git a/apps/cli/src/legacy/shared/legacy-migration-history.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-history.unit.test.ts index ac972b435d..c0d7781aed 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-history.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-history.unit.test.ts @@ -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. From 37f95f27260f81feb86f811a075fc89972623f8f Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:26:42 +0530 Subject: [PATCH 2/4] fix(cli): sort local versions before the migration list merge --- .../commands/migration/list/list.format.ts | 19 ++++++++++++++----- .../migration/list/list.format.unit.test.ts | 13 +++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/legacy/commands/migration/list/list.format.ts b/apps/cli/src/legacy/commands/migration/list/list.format.ts index d9ebbe27f1..5889353de9 100644 --- a/apps/cli/src/legacy/commands/migration/list/list.format.ts +++ b/apps/cli/src/legacy/commands/migration/list/list.format.ts @@ -1,3 +1,4 @@ +import { legacySortMigrationVersions } from "../../../shared/legacy-migration-history.ts"; import { LEGACY_MIGRATION_VERSION_MAX, legacyFormatTimestampVersion, @@ -20,10 +21,14 @@ export function legacyMakeMigrationListRows( remote: ReadonlyArray, local: ReadonlyArray, ): ReadonlyArray { + // `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 = []; 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]!); @@ -34,8 +39,8 @@ 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; @@ -43,14 +48,18 @@ export function legacyMakeMigrationListRows( 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]!), }); diff --git a/apps/cli/src/legacy/commands/migration/list/list.format.unit.test.ts b/apps/cli/src/legacy/commands/migration/list/list.format.unit.test.ts index 29815d647a..b8dca8dbb4 100644 --- a/apps/cli/src/legacy/commands/migration/list/list.format.unit.test.ts +++ b/apps/cli/src/legacy/commands/migration/list/list.format.unit.test.ts @@ -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([]); }); From 8e8076383f556c762a1e81dc4c0b822861faf8fa Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:31:19 +0530 Subject: [PATCH 3/4] fix(cli): apply migrations in version order on the local replay paths --- .../legacy-migrate-and-seed.unit.test.ts | 25 +++++++++++++++++++ .../legacy/shared/legacy-migration-apply.ts | 8 +++++- .../legacy/shared/legacy-migration-history.ts | 15 +++++++---- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-migrate-and-seed.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migrate-and-seed.unit.test.ts index d3cd29baf3..ff0a00b0e1 100644 --- a/apps/cli/src/legacy/shared/legacy-migrate-and-seed.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migrate-and-seed.unit.test.ts @@ -432,3 +432,28 @@ describe("legacyMigrateAndSeed experimental declarative-schema branch", () => { }, ); }); + +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 }); + }), + ), + ); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index 65ca346b0c..d552f2d16c 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -14,6 +14,7 @@ import { INSERT_MIGRATION_VERSION, MIGRATE_FILE_PATTERN, legacyCreateMigrationTable, + legacySortMigrationPathsByVersion, } from "./legacy-migration-history.ts"; import { legacySqlFilesGlob } from "./legacy-sql-files-glob.ts"; import { legacySplitAndTrim, legacySplitSqlTokens } from "./legacy-sql-split.ts"; @@ -674,7 +675,12 @@ export const legacyApplyMigrations = ( 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); diff --git a/apps/cli/src/legacy/shared/legacy-migration-history.ts b/apps/cli/src/legacy/shared/legacy-migration-history.ts index 730229a445..9f4d2e0cc5 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-history.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-history.ts @@ -404,11 +404,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; + }), + ), ), ); From 6798385e0625c5c1b0641db5ed4ac9250d7bd555 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Mon, 17 Aug 2026 23:20:57 +0530 Subject: [PATCH 4/4] nit --- .../commands/migration/list/list.format.ts | 2 +- .../legacy/shared/legacy-migration-history.ts | 19 ++----------------- .../legacy-migration-timestamp.format.ts | 17 +++++++++++++++++ 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/apps/cli/src/legacy/commands/migration/list/list.format.ts b/apps/cli/src/legacy/commands/migration/list/list.format.ts index 5889353de9..5d1d0d8a5e 100644 --- a/apps/cli/src/legacy/commands/migration/list/list.format.ts +++ b/apps/cli/src/legacy/commands/migration/list/list.format.ts @@ -1,8 +1,8 @@ -import { legacySortMigrationVersions } from "../../../shared/legacy-migration-history.ts"; 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. */ diff --git a/apps/cli/src/legacy/shared/legacy-migration-history.ts b/apps/cli/src/legacy/shared/legacy-migration-history.ts index 3a4e5a7f11..3242eef54a 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-history.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-history.ts @@ -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"; @@ -304,10 +306,6 @@ export const legacyLoadLocalVersions = ( /** Basename of a path, handling both `/` and `\` separators (keeps the helper pure). */ const baseName = (filePath: string): string => filePath.split(/[\\/]/u).pop() ?? filePath; -/** Lexical version order, shared by both sorters so the walks cannot drift apart. */ -const legacyCompareMigrationVersions = (a: string, b: string): number => - a < b ? -1 : a > b ? 1 : 0; - /** * Orders local migration paths by version so they line up with * `schema_migrations` (`ORDER BY version`) before a two-pointer walk compares @@ -327,19 +325,6 @@ export function legacySortMigrationPathsByVersion( }); } -/** - * 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 {@link legacySortMigrationPathsByVersion} reproduces - * for the path-shaped walks. - */ -export function legacySortMigrationVersions( - versions: ReadonlyArray, -): ReadonlyArray { - return [...versions].sort(legacyCompareMigrationVersions); -} - /** Outcome of `legacyFindPendingMigrations` — `(slice, error)` as a tagged union. */ export type LegacyPendingMigrations = | { readonly kind: "pending"; readonly paths: ReadonlyArray } diff --git a/apps/cli/src/legacy/shared/legacy-migration-timestamp.format.ts b/apps/cli/src/legacy/shared/legacy-migration-timestamp.format.ts index ee1203597d..91b78322c2 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-timestamp.format.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-timestamp.format.ts @@ -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, +): ReadonlyArray { + return [...versions].sort(legacyCompareMigrationVersions); +}