Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
8a59da4
feat(cli): port migration commands to native TypeScript (CLI-1312)
Coly010 Jun 23, 2026
4e7e911
fix(cli): correct seed glob parity and dedup migration-version parsin…
Coly010 Jun 24, 2026
042cf2e
fix(cli): guard migration new path traversal and lock migration parit…
Coly010 Jun 24, 2026
2450834
Merge branch 'develop' into cli/port-migrations-commands
Coly010 Jun 24, 2026
f495458
test(cli): strip ANSI in migration new e2e assertion (ci: e2e shard 1/3)
Coly010 Jun 24, 2026
d4accbf
test(cli): canonicalize migration --local connect-error stderr in par…
Coly010 Jun 24, 2026
29d3fb0
fix(cli): run pipeline-incompatible migration statements outside the …
Coly010 Jun 24, 2026
8187562
fix(cli): reject out-of-int64-range migration repair versions to matc…
Coly010 Jun 25, 2026
b6ee413
fix(cli): resolve absolute seed sql_paths at the OS root to match Go …
Coly010 Jun 25, 2026
395283e
fix(cli): force remote db.seed.enabled=false and expand env() in sql_…
Coly010 Jun 25, 2026
2fcc4df
fix(cli): propagate migration-dir read errors in fetch to match Go (r…
Coly010 Jun 25, 2026
4f74cf4
fix(cli): honor SUPABASE_DB_{SEED,MIGRATIONS}_ENABLED env overrides t…
Coly010 Jun 25, 2026
717f123
fix(cli): gate migration confirmation prompts on stdin TTY, not outpu…
Coly010 Jun 25, 2026
a6549d0
fix(cli): skip --password/-p values when scanning DB target flags to …
Coly010 Jun 25, 2026
0399295
fix(cli): resolve DB config before repair-all prompt to match Go pre-…
Coly010 Jun 25, 2026
6210277
Merge remote-tracking branch 'origin/develop' into cli/port-migration…
Coly010 Jun 25, 2026
fc343df
fix(cli): scope migration-setup lock_timeout to its transaction for G…
Coly010 Jun 25, 2026
dc0323f
fix(cli): warn on skipped local migration files to match Go CLI (CLI-…
Coly010 Jun 25, 2026
045899c
feat(cli): decrypt dotenvx-encrypted [db.vault] secrets to match Go C…
Coly010 Jun 25, 2026
ed36c96
fix(cli): resolve DB config before migration fetch side effects for G…
Coly010 Jun 25, 2026
f0a5dd1
fix(cli): treat backslash as a seed glob metacharacter to match Go (C…
Coly010 Jun 25, 2026
6e73d5e
fix(cli): align seed sql_paths resolution and remote-seed precedence …
Coly010 Jun 25, 2026
8fad743
style(cli): reformat migration up/down SIDE_EFFECTS env-var tables (C…
Coly010 Jun 25, 2026
b319f02
fix(cli): correct repair version-parse order and cancel-path caching …
Coly010 Jun 25, 2026
9136274
fix(cli): emit Go connection banner in migration list/up/down/fetch (…
Coly010 Jun 25, 2026
0afe294
fix(cli): honor piped answers to migration prompts to match Go (CLI-1…
Coly010 Jun 25, 2026
a66f18a
fix(cli): decode string-valued db.seed.sql_paths to match Go (CLI-1312)
Coly010 Jun 25, 2026
1b88dde
fix(cli): prompt independently of output format in migration commands…
Coly010 Jun 25, 2026
ed35ddb
fix(cli): resolve DB target before rejecting --last 0 in migration do…
Coly010 Jun 25, 2026
df9eb94
fix(cli): align db-config decode with Go's viper/mapstructure hooks (…
Coly010 Jun 25, 2026
514a695
fix(cli): honor SUPABASE_YES for migration prompts to match Go (CLI-1…
Coly010 Jun 25, 2026
80ac2ca
fix(cli): let explicit remote-block config keys beat env overrides (C…
Coly010 Jun 25, 2026
f20ea59
fix(cli): accept signed migration versions like strconv.Atoi (CLI-1312)
Coly010 Jun 25, 2026
7d5dc51
Merge remote-tracking branch 'origin/develop' into cli/port-migration…
Coly010 Jun 26, 2026
2c59a11
test(cli): add live migration list + fetch suites
Coly010 Jun 26, 2026
1565da9
test(cli): gate migration live suites on data-plane health
Coly010 Jun 26, 2026
18456d9
fix(cli): expand env() in SUPABASE_* bool overrides before parsing
Coly010 Jun 26, 2026
cf476ee
fix(cli): match remote blocks on the raw project_id literal, not env(…
Coly010 Jun 26, 2026
b9e64a6
fix(cli): parse seed files one at a time to match Go's per-file memory
Coly010 Jun 26, 2026
0a0753f
fix(cli): stream piped stdin into new migration instead of buffering
Coly010 Jun 26, 2026
5dbee7f
fix(cli): propagate piped stdin read errors in migration new
Coly010 Jun 26, 2026
5c5f0b1
fix(cli): make every matched-remote key beat its SUPABASE_* env override
Coly010 Jun 26, 2026
3a2ea8b
fix(cli): honor SUPABASE_YES from the project .env in migration down/…
Coly010 Jun 26, 2026
a0ac48e
fix(cli): expand env() in pg-delta string env overrides
Coly010 Jun 26, 2026
58754ec
fix(cli): validate target flag conflicts before loading project env
Coly010 Jun 26, 2026
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
42 changes: 36 additions & 6 deletions apps/cli-e2e/src/tests/migrations.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,33 @@ import { testBehaviour, testParity } from "./test-context.ts";

const MIGRATION_NAME = "my_change";

// The `migration … --local` parity cases deliberately exercise the
// connection-refused path (no local stack), and on that path the TS port's stderr
// does not yet byte-match the Go CLI: Go prints `Connecting to local database...`,
// the pgconn dial error, and `SetConnectSuggestion`'s Network-Restrictions hint,
// whereas the TS layer surfaces the `@effect/sql-pg` SqlError and the generic
// `--debug` suggestion. Porting that connect-error shaping is tracked separately
// (see the PR's local-connect parity note). Until then we keep exit code, stdout,
// request log, and filesystem under strict parity and canonicalize the known stderr
// divergence down to the shared `failed to connect to postgres:` prefix. The
// `exits non-zero on connection refused` behaviour tests below still assert the
// meaningful stderr substring and non-zero exit, so the contract stays covered.
const CONNECT_REFUSED_STDERR_STRIP: readonly RegExp[] = [
// Go-only "Connecting to local database..." preamble (the TS port omits it).
/^Connecting to local database\.\.\.\n/m,
// Driver-specific detail after the shared "failed to connect to postgres:" prefix
// (Go: pgconn dial error; TS: effect/sql SqlError).
/(?<=failed to connect to postgres:).*/g,
// Go's SetConnectSuggestion: Network-Restrictions hint + dashboard URL line.
/\nMake sure your local IP is allowed in Network Restrictions and Network Bans\.\n[^\n]*/g,
// TS's generic --debug suggestion.
/\nTry rerunning the command with --debug to troubleshoot the error\./g,
];

const connectRefusedParity = {
normalize: { stderr: { stripPatterns: CONNECT_REFUSED_STDERR_STRIP } },
};

describe("migrations", () => {
describe("migration:new", () => {
testBehaviour("creates timestamped sql file", async ({ run, workspace }) => {
Expand All @@ -29,7 +56,7 @@ describe("migrations", () => {
expect(result.stderr).toContain("failed to connect");
});

testParity(["migration", "list", "--local"]);
testParity(["migration", "list", "--local"], connectRefusedParity);
});

describe("migration:up", () => {
Expand All @@ -39,7 +66,7 @@ describe("migrations", () => {
expect(result.stderr).toContain("failed to connect");
});

testParity(["migration", "up", "--local"]);
testParity(["migration", "up", "--local"], connectRefusedParity);
});

describe("migration:down", () => {
Expand All @@ -55,8 +82,8 @@ describe("migrations", () => {
expect(result.stderr).toContain("failed to connect");
});

testParity(["migration", "down", "--local"]);
testParity(["migration", "down", "--last", "2", "--local"]);
testParity(["migration", "down", "--local"], connectRefusedParity);
testParity(["migration", "down", "--last", "2", "--local"], connectRefusedParity);
});

describe("migration:repair", () => {
Expand All @@ -79,7 +106,10 @@ describe("migrations", () => {
expect(result.stderr).toContain("failed to connect");
});

testParity(["migration", "repair", "--status", "applied", "--local", "20230101000000"]);
testParity(
["migration", "repair", "--status", "applied", "--local", "20230101000000"],
connectRefusedParity,
);
});

describe("migration:squash", () => {
Expand All @@ -99,6 +129,6 @@ describe("migrations", () => {
expect(result.stderr).toContain("failed to connect");
});

testParity(["migration", "fetch", "--local"]);
testParity(["migration", "fetch", "--local"], connectRefusedParity);
});
});
28 changes: 14 additions & 14 deletions apps/cli/docs/go-cli-porting-status.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"fix:all": "nx run-many -t lint:fix fmt:fix knip:fix --projects=$npm_package_name"
},
"dependencies": {
"eciesjs": "^0.5.0",
"jose": "^6.2.3"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/legacy/commands/db/diff/diff.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
import {
legacyFormatMigrationTimestamp,
legacyGetMigrationPath,
} from "../shared/legacy-migration-file.ts";
} from "../../../shared/legacy-migration-file.ts";
import { legacyDiffMigra } from "../shared/legacy-migra.ts";
import { type LegacyPgDeltaContext, legacyDiffPgDelta } from "../shared/legacy-pgdelta.ts";
import { LegacyDeclarativeSeam } from "../shared/legacy-pgdelta.seam.service.ts";
Expand Down
6 changes: 3 additions & 3 deletions apps/cli/src/legacy/commands/db/pull/pull.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import { legacyDiffMigra } from "../shared/legacy-migra.ts";
import {
legacyFormatMigrationTimestamp,
legacyGetMigrationPath,
} from "../shared/legacy-migration-file.ts";
} from "../../../shared/legacy-migration-file.ts";
import { legacyFormatDebugId } from "../shared/legacy-debug-bundle.ts";
import {
type LegacyPgDeltaContext,
Expand All @@ -62,8 +62,8 @@ import {
legacyListRemoteMigrations,
legacyLoadLocalVersions,
legacyReconcileMigrations,
legacyUpdateMigrationHistory,
} from "./pull.sync.ts";
} from "../../../shared/legacy-migration-history.ts";
import { legacyUpdateMigrationHistory } from "./pull.sync.ts";

// pflag's `MarkDeprecated` emits `"Flag --%s has been deprecated, %s\n"` with the
// registration message verbatim (`apps/cli-go/cmd/db.go:466`), which ends with a `.`.
Expand Down
177 changes: 6 additions & 171 deletions apps/cli/src/legacy/commands/db/pull/pull.sync.ts
Original file line number Diff line number Diff line change
@@ -1,176 +1,15 @@
import { Effect, type FileSystem, type Path } from "effect";

import { Output } from "../../../../shared/output/output.service.ts";
import { legacyBold } from "../../../shared/legacy-colors.ts";
import type { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts";
import type { LegacyDbSession } from "../../../shared/legacy-db-connection.service.ts";
import {
MIGRATE_FILE_PATTERN,
UPSERT_MIGRATION_VERSION,
legacyCreateMigrationTable,
} from "../../../shared/legacy-migration-history.ts";
import { legacySplitAndTrim } from "../../../shared/legacy-sql-split.ts";
import { LegacyMigrationsReadError } from "../shared/legacy-pgdelta.errors.ts";
import { legacyListLocalMigrations } from "../shared/legacy-pgdelta.cache.ts";
import { LegacyDbPullWriteError } from "./pull.errors.ts";

/** `SELECT version FROM supabase_migrations.schema_migrations ORDER BY version`. */
const LIST_MIGRATION_VERSION =
"SELECT version FROM supabase_migrations.schema_migrations ORDER BY version";

// Migration-history DDL/DML, verbatim from Go's `pkg/migration/history.go`.
const SET_LOCK_TIMEOUT = "SET lock_timeout = '4s'";
const CREATE_VERSION_SCHEMA = "CREATE SCHEMA IF NOT EXISTS supabase_migrations";
const CREATE_VERSION_TABLE =
"CREATE TABLE IF NOT EXISTS supabase_migrations.schema_migrations (version text NOT NULL PRIMARY KEY)";
const ADD_STATEMENTS_COLUMN =
"ALTER TABLE supabase_migrations.schema_migrations ADD COLUMN IF NOT EXISTS statements text[]";
const ADD_NAME_COLUMN =
"ALTER TABLE supabase_migrations.schema_migrations ADD COLUMN IF NOT EXISTS name text";
const UPSERT_MIGRATION_VERSION =
"INSERT INTO supabase_migrations.schema_migrations(version, name, statements) VALUES($1, $2, $3) ON CONFLICT (version) DO UPDATE SET name = EXCLUDED.name, statements = EXCLUDED.statements";

// `pkg/migration/file.go` — `<digits>_<name>.sql`.
const MIGRATE_FILE_PATTERN = /^([0-9]+)_(.*)\.sql$/u;

/** The outcome of comparing remote vs local migration histories. */
export type LegacyMigrationSync =
| { readonly kind: "in-sync" }
| { readonly kind: "missing" }
| { readonly kind: "conflict"; readonly suggestion: string };

/**
* Reconciles the remote and local migration version lists. Pure port of Go's
* `assertRemoteInSync` two-pointer comparison (`internal/db/pull/pull.go:212-258`):
* versions that fail to parse as integers are skipped (Go's `Atoi` error →
* `continue`); any extra remote/local version is a conflict; an empty local set
* is `missing`; otherwise in-sync.
*/
export function legacyReconcileMigrations(
remote: ReadonlyArray<string>,
local: ReadonlyArray<string>,
): LegacyMigrationSync {
// Go's `math.MaxInt` on a 64-bit build == math.MaxInt64; the exhausted side pins
// here. Use BigInt so the full int64 range compares EXACTLY — `Number` loses
// precision above `Number.MAX_SAFE_INTEGER` (e.g. `Number("9999999999999999")`
// rounds to 1e16), which would mis-order versions Go accepts.
const MAX = 9223372036854775807n;
const extraRemote: Array<string> = [];
const extraLocal: Array<string> = [];
let i = 0;
let j = 0;
// Matches Go's `strconv.Atoi`: digits only, no empty/whitespace/sign/float. A
// non-parseable version is skipped (Go's `Atoi` error → `continue`). On 64-bit
// builds `Atoi` parses the full int64 range and returns a range error ONLY for
// values above int64 max; reject only those (so e.g. `9999999999999999`, which Go
// accepts and surfaces as a conflict, is NOT skipped) while still rejecting
// 19+-digit values above the sentinel so they can never exceed the exhausted-side
// pin and stall the two-pointer scan.
const parseVersion = (v: string): bigint | undefined => {
if (!/^\d+$/u.test(v)) return undefined;
const parsed = BigInt(v);
return parsed > MAX ? undefined : parsed;
};
while (i < remote.length || j < local.length) {
let remoteTs = MAX;
if (i < remote.length) {
const parsed = parseVersion(remote[i]!);
if (parsed === undefined) {
i++;
continue;
}
remoteTs = parsed;
}
let localTs = MAX;
if (j < local.length) {
const parsed = parseVersion(local[j]!);
if (parsed === undefined) {
j++;
continue;
}
localTs = parsed;
}
if (localTs < remoteTs) {
extraLocal.push(local[j]!);
j++;
} else if (remoteTs < localTs) {
extraRemote.push(remote[i]!);
i++;
} else {
i++;
j++;
}
}
if (extraRemote.length + extraLocal.length > 0) {
return { kind: "conflict", suggestion: legacySuggestMigrationRepair(extraRemote, extraLocal) };
}
if (local.length === 0) {
return { kind: "missing" };
}
return { kind: "in-sync" };
}

/** Go's `suggestMigrationRepair` (`internal/db/pull/pull.go:280-289`). */
export function legacySuggestMigrationRepair(
extraRemote: ReadonlyArray<string>,
extraLocal: ReadonlyArray<string>,
): string {
let result =
"\nMake sure your local git repo is up-to-date. If the error persists, try repairing the migration history table:\n";
for (const version of extraRemote) {
result += `${legacyBold(`supabase migration repair --status reverted ${version}`)}\n`;
}
for (const version of extraLocal) {
result += `${legacyBold(`supabase migration repair --status applied ${version}`)}\n`;
}
return result;
}

/**
* Lists the remote project's applied migration versions. Mirrors Go's
* `migration.ListRemoteMigrations` (`pkg/migration/list.go:18-31`): ONLY a missing
* history table (`pgerrcode.UndefinedTable` = `42P01`) means the remote has no
* migrations and returns `[]`; any other error (e.g. a malformed table missing the
* `version` column, `42703`) propagates rather than being silently treated as an
* initial pull. We match the SQLSTATE like Go; if the driver didn't surface a code,
* fall back to a message check that matches a missing relation but NOT a missing
* column.
*/
export const legacyListRemoteMigrations = (session: LegacyDbSession) =>
session.query(LIST_MIGRATION_VERSION).pipe(
Effect.map((rows) => rows.map((row) => String(row["version"]))),
Effect.catch((error) =>
legacyIsUndefinedTableError(error)
? Effect.succeed<ReadonlyArray<string>>([])
: Effect.fail(new LegacyMigrationsReadError({ message: error.message })),
),
);

/** Whether a query error is Postgres `undefined_table` (42P01), matching Go's `pgerrcode.UndefinedTable`. */
const legacyIsUndefinedTableError = (error: LegacyDbExecError): boolean => {
if (error.code !== undefined) return error.code === "42P01";
// No SQLSTATE surfaced: a relation-not-exist message counts, a column-not-exist
// one does not (Postgres phrases an undefined column as `column "x" does not exist`).
return (
/relation .* does not exist/iu.test(error.message) &&
!/column .* does not exist/iu.test(error.message)
);
};

/**
* Loads the local migration versions (the `<timestamp>` prefixes). Mirrors Go's
* `LoadLocalVersions` (`internal/migration/list/list.go:72`) → `ListLocalMigrations`
* with a version-collecting filter.
*/
export const legacyLoadLocalVersions = (
fs: FileSystem.FileSystem,
path: Path.Path,
migrationsDir: string,
) =>
legacyListLocalMigrations(fs, path, migrationsDir).pipe(
Effect.map((paths) =>
paths.flatMap((p) => {
const match = MIGRATE_FILE_PATTERN.exec(path.basename(p));
return match?.[1] !== undefined ? [match[1]] : [];
}),
),
);

/**
* Records the pulled migration as applied in `supabase_migrations.schema_migrations`
* WITHOUT re-executing it (the schema already exists on the remote). Mirrors Go's
Expand Down Expand Up @@ -213,11 +52,7 @@ export const legacyUpdateMigrationHistory = (
yield* Effect.gen(function* () {
const content = yield* fs.readFileString(migrationPath);
const statements = legacySplitAndTrim(content);
yield* session.exec(SET_LOCK_TIMEOUT);
yield* session.exec(CREATE_VERSION_SCHEMA);
yield* session.exec(CREATE_VERSION_TABLE);
yield* session.exec(ADD_STATEMENTS_COLUMN);
yield* session.exec(ADD_NAME_COLUMN);
yield* legacyCreateMigrationTable(session);
yield* session.query(UPSERT_MIGRATION_VERSION, [version, name, statements]);
}).pipe(
Effect.mapError(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { BunServices } from "@effect/platform-bun";
import { describe, expect, it } from "@effect/vitest";
import { Cause, Effect, Exit, Layer, Option, Redacted } from "effect";
import { Cause, Effect, Exit, Layer, Option, Redacted, Stream } from "effect";
import * as HttpClient from "effect/unstable/http/HttpClient";
import * as HttpClientError from "effect/unstable/http/HttpClientError";
import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
Expand Down Expand Up @@ -147,11 +147,21 @@ function mockStdin(opts: { isTTY?: boolean; piped?: string }) {
readPipedBytes: Effect.succeed(
opts.piped === undefined ? Option.none() : Option.some(new TextEncoder().encode(opts.piped)),
),
pipedBytesStream:
opts.piped === undefined
? Stream.empty
: Stream.fromIterable([new TextEncoder().encode(opts.piped)]),
readPipedText: Effect.succeed(
opts.piped === undefined || opts.piped.trim() === ""
? Option.none()
: Option.some(opts.piped.trim()),
),
readLine: () =>
Effect.succeed(
opts.piped === undefined || opts.piped.split(/\r?\n/u)[0]!.trim() === ""
? Option.none()
: Option.some(opts.piped.split(/\r?\n/u)[0]!.trim()),
),
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { BunServices } from "@effect/platform-bun";
import { describe, expect, it } from "@effect/vitest";
import { Effect, Exit, FileSystem, Path } from "effect";
import { Effect, Exit, FileSystem, Layer, Path } from "effect";

import { mockOutput } from "../../../../../tests/helpers/mocks.ts";
import { legacyCollectMigrationsList, legacySaveDebugBundle } from "./legacy-debug-bundle.ts";

const save = (workdir: string, tempDir: string, migrationsDir: string, id: string) =>
Expand All @@ -16,7 +17,7 @@ const save = (workdir: string, tempDir: string, migrationsDir: string, id: strin
error: "boom",
migrationSql: "create table t();",
});
}).pipe(Effect.provide(BunServices.layer));
}).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, mockOutput().layer)));

describe("legacySaveDebugBundle", () => {
it.effect("writes artifacts and returns the debug directory", () => {
Expand Down Expand Up @@ -58,7 +59,7 @@ const collect = (migrationsDir: string) =>
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
return yield* legacyCollectMigrationsList(fs, path, migrationsDir);
}).pipe(Effect.provide(BunServices.layer));
}).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, mockOutput().layer)));

describe("legacyCollectMigrationsList", () => {
it.effect("returns migration filenames when the dir is readable", () => {
Expand Down
Loading
Loading