From b8facdecbe7fced4f7664a37e80d8c15e13dbbe2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 19:10:05 +0000 Subject: [PATCH 01/29] feat(cli): add pending-migration reconciliation for legacy db push Pure 1:1 port of Go's FindPendingMigrations + GetPendingMigrations suggestion strings (internal/migration/up, pkg/migration/apply), the foundation for the native legacy `db push` handler. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nv8pAJ695qfNs5Btbv5v7h --- .../db/shared/legacy-migration-pending.ts | 123 ++++++++++++++++++ .../legacy-migration-pending.unit.test.ts | 83 ++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.unit.test.ts diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.ts b/apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.ts new file mode 100644 index 0000000000..579a0b675a --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.ts @@ -0,0 +1,123 @@ +import { legacyBold } from "../../../shared/legacy-colors.ts"; + +/** + * `pkg/migration/file.go` — local migration filenames are `_.sql`. + * `ListLocalMigrations` guarantees every path in `localMigrations` matches, so the + * version capture group is always present. + */ +const MIGRATE_FILE_PATTERN = /^([0-9]+)_(.*)\.sql$/u; + +/** Last path segment, mirroring Go's `filepath.Base`. */ +const baseName = (path: string): string => { + const normalized = path.replace(/[/\\]+$/u, ""); + const slash = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\")); + return slash === -1 ? normalized : normalized.slice(slash + 1); +}; + +/** + * `pkg/migration/apply.go:14-16` — the exact error strings Go raises so the legacy + * handler can byte-match them on stderr. + */ +export const LEGACY_ERR_MISSING_REMOTE = + "Found local migration files to be inserted before the last migration on remote database."; +export const LEGACY_ERR_MISSING_LOCAL = + "Remote migration versions not found in local migrations directory."; + +/** + * The outcome of comparing local migration files against the remote + * `schema_migrations` history. Pure 1:1 port of Go's `FindPendingMigrations` + * (`pkg/migration/apply.go:21-54`). + * + * - `ok` — `pending` are the local migration paths to apply (those + * beyond the remote history, in order). + * - `missing-local` — remote has versions with no local file (`ErrMissingLocal`). + * `versions` are the offending remote versions. + * - `missing-remote`— local has files ordered before the remote head + * (`ErrMissingRemote`). `paths` are the out-of-order local + * migration paths. + */ +export type LegacyPendingMigrations = + | { readonly kind: "ok"; readonly pending: ReadonlyArray } + | { readonly kind: "missing-local"; readonly versions: ReadonlyArray } + | { readonly kind: "missing-remote"; readonly paths: ReadonlyArray }; + +/** + * Two-pointer reconciliation of local migration paths vs remote applied versions. + * Mirrors Go's `FindPendingMigrations` exactly, including its **string** + * comparison of versions (`remote == local` / `remote < local`) — version + * prefixes are fixed-width timestamps, so lexical order equals chronological + * order, matching Go. + */ +export function legacyFindPendingMigrations( + localMigrations: ReadonlyArray, + remoteMigrations: ReadonlyArray, +): LegacyPendingMigrations { + const unapplied: Array = []; + const missing: Array = []; + let i = 0; + let j = 0; + while (i < remoteMigrations.length && j < localMigrations.length) { + const remote = remoteMigrations[i]!; + const filename = baseName(localMigrations[j]!); + // ListLocalMigrations guarantees a match, so the capture group is present. + const local = MIGRATE_FILE_PATTERN.exec(filename)![1]!; + if (remote === local) { + i++; + j++; + } else if (remote < local) { + missing.push(remote); + i++; + } else { + // Include out-of-order local migrations. + unapplied.push(localMigrations[j]!); + j++; + } + } + // Ensure all remote versions exist on local. + if (j === localMigrations.length) { + missing.push(...remoteMigrations.slice(i)); + } + if (missing.length > 0) { + return { kind: "missing-local", versions: missing }; + } + // Enforce migrations are applied in chronological order by default. + if (unapplied.length > 0) { + return { kind: "missing-remote", paths: unapplied }; + } + return { kind: "ok", pending: localMigrations.slice(remoteMigrations.length) }; +} + +/** + * Computes the `--include-all` pending set when reconciliation reports + * `missing-remote`. Mirrors Go's `GetPendingMigrations` includeAll branch + * (`internal/migration/up/up.go:46-48`): the out-of-order paths first, then the + * local migrations beyond `len(remote)+len(diff)`. + */ +export function legacyIncludeAllPending( + localMigrations: ReadonlyArray, + remoteCount: number, + diff: ReadonlyArray, +): ReadonlyArray { + return [...diff, ...localMigrations.slice(remoteCount + diff.length)]; +} + +/** + * Go's `suggestRevertHistory` (`internal/migration/up/up.go:55-61`). `fmt.Sprintln` + * appends a trailing newline to each line, so the suggestion ends with `\n`. + */ +export function legacySuggestRevertHistory(versions: ReadonlyArray): string { + return ( + "\nMake sure your local git repo is up-to-date. If the error persists, try repairing the migration history table:\n" + + `${legacyBold(`supabase migration repair --status reverted ${versions.join(" ")}`)}\n` + + "\nAnd update local migrations to match remote database:\n" + + `${legacyBold("supabase db pull")}\n` + ); +} + +/** Go's `suggestIgnoreFlag` (`internal/migration/up/up.go:63-67`). */ +export function legacySuggestIgnoreFlag(paths: ReadonlyArray): string { + return ( + "\nRerun the command with --include-all flag to apply these migrations:\n" + + `${legacyBold(paths.join("\n"))}\n` + ); +} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.unit.test.ts new file mode 100644 index 0000000000..eaf5400d60 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-migration-pending.unit.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; + +import { + legacyFindPendingMigrations, + legacyIncludeAllPending, + legacySuggestIgnoreFlag, + legacySuggestRevertHistory, +} from "./legacy-migration-pending.ts"; + +const local = (...versions: ReadonlyArray) => + versions.map((v) => `supabase/migrations/${v}_name.sql`); + +describe("legacyFindPendingMigrations", () => { + it("returns the local migrations beyond the remote history when in sync", () => { + const result = legacyFindPendingMigrations(local("0001", "0002", "0003"), ["0001"]); + expect(result).toEqual({ + kind: "ok", + pending: ["supabase/migrations/0002_name.sql", "supabase/migrations/0003_name.sql"], + }); + }); + + it("is up to date when local and remote match exactly", () => { + const result = legacyFindPendingMigrations(local("0001", "0002"), ["0001", "0002"]); + expect(result).toEqual({ kind: "ok", pending: [] }); + }); + + it("reports missing-local when remote has a version with no local file", () => { + const result = legacyFindPendingMigrations(local("0001", "0003"), ["0001", "0002", "0003"]); + expect(result).toEqual({ kind: "missing-local", versions: ["0002"] }); + }); + + it("reports missing-local for trailing remote versions absent locally", () => { + const result = legacyFindPendingMigrations(local("0001"), ["0001", "0002"]); + expect(result).toEqual({ kind: "missing-local", versions: ["0002"] }); + }); + + it("reports missing-remote for an out-of-order local migration", () => { + const result = legacyFindPendingMigrations(local("0001", "0002"), ["0002"]); + expect(result).toEqual({ + kind: "missing-remote", + paths: ["supabase/migrations/0001_name.sql"], + }); + }); + + it("treats an empty remote history as all-local pending", () => { + const result = legacyFindPendingMigrations(local("0001", "0002"), []); + expect(result).toEqual({ + kind: "ok", + pending: ["supabase/migrations/0001_name.sql", "supabase/migrations/0002_name.sql"], + }); + }); +}); + +describe("legacyIncludeAllPending", () => { + it("prepends the out-of-order diff then the migrations beyond remote+diff", () => { + const locals = local("0001", "0002", "0003"); + const diff = ["supabase/migrations/0001_name.sql"]; + // remoteCount 1, diff length 1 → slice from index 2. + expect(legacyIncludeAllPending(locals, 1, diff)).toEqual([ + "supabase/migrations/0001_name.sql", + "supabase/migrations/0003_name.sql", + ]); + }); +}); + +describe("suggestion strings", () => { + it("builds the revert-history suggestion with a trailing newline per line", () => { + expect(legacySuggestRevertHistory(["0002", "0003"])).toContain( + "supabase migration repair --status reverted 0002 0003", + ); + expect(legacySuggestRevertHistory(["0002"])).toMatch(/\n$/u); + expect(legacySuggestRevertHistory(["0002"])).toContain("supabase db pull"); + }); + + it("builds the include-all suggestion listing each path on its own line", () => { + const suggestion = legacySuggestIgnoreFlag([ + "supabase/migrations/0001_a.sql", + "supabase/migrations/0002_b.sql", + ]); + expect(suggestion).toContain("--include-all"); + expect(suggestion).toContain("supabase/migrations/0001_a.sql\nsupabase/migrations/0002_b.sql"); + }); +}); From d7bb3561db9937c6d47d8af67d39a9e1a329aa5b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 19:15:10 +0000 Subject: [PATCH 02/29] feat(cli): add seed-file ops for legacy db push Port Go's GetPendingSeeds/SeedData with a faithful fs.Glob/path.Match matcher, sha256 dirty detection against supabase_migrations.seed_files, and transactional seed application (pkg/migration/seed.go, file.go). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nv8pAJ695qfNs5Btbv5v7h --- .../commands/db/shared/legacy-seed-ops.ts | 289 ++++++++++++++++++ .../db/shared/legacy-seed-ops.unit.test.ts | 39 +++ 2 files changed, 328 insertions(+) create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.unit.test.ts diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts b/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts new file mode 100644 index 0000000000..98cc8bbf6b --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts @@ -0,0 +1,289 @@ +import { createHash } from "node:crypto"; +import { Effect, type FileSystem, Option, type Path } from "effect"; + +import { Output } from "../../../../shared/output/output.service.ts"; +import type { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts"; +import type { LegacyDbSession } from "../../../shared/legacy-db-connection.service.ts"; +import { legacySplitAndTrim } from "../../../shared/legacy-sql-split.ts"; + +/** + * Seed-history DDL/DML, verbatim from Go's `pkg/migration/history.go`. + */ +const CREATE_SEED_TABLE = + "CREATE TABLE IF NOT EXISTS supabase_migrations.seed_files (path text NOT NULL PRIMARY KEY, hash text NOT NULL)"; +const UPSERT_SEED_FILE = + "INSERT INTO supabase_migrations.seed_files(path, hash) VALUES($1, $2) ON CONFLICT (path) DO UPDATE SET hash = EXCLUDED.hash"; +const SELECT_SEED_TABLE = "SELECT path, hash FROM supabase_migrations.seed_files"; + +/** A local seed file resolved from `[db.seed].sql_paths`, with its content hash. */ +export interface LegacySeedFile { + /** Workdir-relative, forward-slashed path (Go's `filepath.ToSlash`). */ + readonly path: string; + /** Lowercase hex SHA-256 of the file content (Go's `NewSeedFile`). */ + readonly hash: string; + /** True when the remote `seed_files` row has a different hash (re-hash only). */ + readonly dirty: boolean; +} + +const META_CHARS = /[*?[\\]/u; + +/** + * Go's `path.Match` for a single filename (no `/`). Supports `*` (any run of + * non-separator chars), `?` (one char), `[...]` classes with ranges and a + * leading `^`/`!` negation, and `\` escapes. Filenames never contain `/`, so the + * separator subtlety in Go's matcher does not apply here. + */ +export function legacyMatchPattern(pattern: string, name: string): boolean { + const matchClass = (cls: string, ch: string): boolean => { + let negated = false; + let body = cls; + if (body.startsWith("^") || body.startsWith("!")) { + negated = true; + body = body.slice(1); + } + let matched = false; + for (let k = 0; k < body.length; k++) { + if (body[k + 1] === "-" && k + 2 < body.length) { + if (ch >= body[k]! && ch <= body[k + 2]!) matched = true; + k += 2; + } else if (body[k] === ch) { + matched = true; + } + } + return matched !== negated; + }; + + const match = (p: number, n: number): boolean => { + while (p < pattern.length) { + const pc = pattern[p]!; + if (pc === "*") { + // Collapse consecutive stars, then try to match the rest at every offset. + while (pattern[p] === "*") p++; + if (p === pattern.length) return true; + for (let k = n; k <= name.length; k++) { + if (match(p, k)) return true; + } + return false; + } + if (n >= name.length) return false; + if (pc === "?") { + p++; + n++; + continue; + } + if (pc === "[") { + const end = pattern.indexOf("]", p + 1); + if (end === -1) return false; + if (!matchClass(pattern.slice(p + 1, end), name[n]!)) return false; + p = end + 1; + n++; + continue; + } + if (pc === "\\" && p + 1 < pattern.length) { + if (pattern[p + 1] !== name[n]) return false; + p += 2; + n++; + continue; + } + if (pc !== name[n]) return false; + p++; + n++; + } + return n === name.length; + }; + + return match(0, 0); +} + +/** Result of resolving `[db.seed].sql_paths` against the workspace. */ +export interface LegacyGlobResult { + /** Workdir-relative, forward-slashed matches, deduplicated in pattern order. */ + readonly files: ReadonlyArray; + /** Per-pattern warnings (`no files matched pattern: …`), joined by Go's `errors.Join`. */ + readonly warning: Option.Option; +} + +/** + * Resolves seed glob patterns to existing files, porting Go's `config.Glob.Files` + * over `fs.Glob` (`pkg/config/config.go:102-124`). Each pattern is first joined + * under the `supabase/` directory (Go resolves `sql_paths` at config load, + * `config.go:884`). Matches per pattern are sorted; the overall result preserves + * first-seen order across patterns. A pattern that matches nothing contributes a + * `no files matched pattern: ` warning but is not fatal. + */ +export const legacyGlobSeedFiles = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + patterns: ReadonlyArray, + workdir: string, +) { + const seen = new Set(); + const files: Array = []; + const errors: Array = []; + + for (const rawPattern of patterns) { + // Go joins each configured pattern under SupabaseDirPath before globbing. + const pattern = toSlash(path.join("supabase", rawPattern)); + const matches = yield* globOne(fs, path, workdir, pattern); + if (matches.length === 0) { + errors.push(`no files matched pattern: ${pattern}`); + continue; + } + for (const match of [...matches].sort()) { + const fp = toSlash(match); + if (!seen.has(fp)) { + seen.add(fp); + files.push(fp); + } + } + } + + return { + files, + warning: errors.length > 0 ? Option.some(errors.join("\n")) : Option.none(), + } satisfies LegacyGlobResult; +}); + +const toSlash = (p: string): string => p.replaceAll("\\", "/"); + +/** Splits a forward-slashed path into its directory prefix and final element. */ +const splitPath = (p: string): { readonly dir: string; readonly file: string } => { + const slash = p.lastIndexOf("/"); + return slash === -1 ? { dir: "", file: p } : { dir: p.slice(0, slash), file: p.slice(slash + 1) }; +}; + +/** Faithful port of Go's `fs.Glob` for one pattern, rooted at `workdir`. */ +const globOne = ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + pattern: string, +): Effect.Effect, never> => + Effect.gen(function* () { + // No metacharacters: a direct existence check (Go's `fs.Glob` fast path). + if (!META_CHARS.test(pattern)) { + const exists = yield* fs.exists(path.join(workdir, pattern)).pipe(Effect.orElseSucceed(() => false)); + return exists ? [pattern] : []; + } + const { dir, file } = splitPath(pattern); + // Resolve the directory level first (recursively if it, too, is a glob). + const dirs = dir === "" || !META_CHARS.test(dir) ? [dir] : yield* globOne(fs, path, workdir, dir); + const result: Array = []; + for (const d of dirs) { + const absDir = d === "" ? workdir : path.join(workdir, d); + const names = yield* fs + .readDirectory(absDir) + .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); + for (const name of names) { + if (legacyMatchPattern(file, name)) { + result.push(d === "" ? name : `${d}/${name}`); + } + } + } + return result; + }); + +/** `SELECT path, hash FROM supabase_migrations.seed_files`, `42P01` → empty map. */ +const readRemoteSeeds = (session: LegacyDbSession) => + session.query(SELECT_SEED_TABLE).pipe( + Effect.map((rows) => { + const applied = new Map(); + for (const row of rows) applied.set(String(row["path"]), String(row["hash"])); + return applied; + }), + Effect.catch((error: LegacyDbExecError) => + isUndefinedTable(error) + ? Effect.succeed(new Map()) + : Effect.fail(error), + ), + ); + +const isUndefinedTable = (error: LegacyDbExecError): boolean => + error.code !== undefined + ? error.code === "42P01" + : /relation .* does not exist/iu.test(error.message) && + !/column .* does not exist/iu.test(error.message); + +/** + * Resolves the pending seed files for `db push --include-seed`. Mirrors Go's + * `GetPendingSeeds` (`pkg/migration/seed.go:34-63`): glob the configured paths + * (warn, don't fail, on empty patterns), read the remote `seed_files` hashes, + * and emit each local file that is new (`dirty=false`) or hash-changed + * (`dirty=true`); files whose hash already matches are skipped. + */ +export const legacyGetPendingSeeds = Effect.fnUntraced(function* ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + path: Path.Path, + patterns: ReadonlyArray, + workdir: string, +) { + const output = yield* Output; + const { files, warning } = yield* legacyGlobSeedFiles(fs, path, patterns, workdir); + if (Option.isSome(warning)) { + yield* output.raw(`WARN: ${warning.value}\n`, "stderr"); + } + if (files.length === 0) return [] as ReadonlyArray; + + const applied = yield* readRemoteSeeds(session); + const pending: Array = []; + for (const file of files) { + const content = yield* fs.readFileString(path.join(workdir, file)); + const hash = createHash("sha256").update(content).digest("hex"); + const appliedHash = applied.get(file); + if (appliedHash !== undefined) { + if (appliedHash === hash) continue; // Already applied, unchanged. + pending.push({ path: file, hash, dirty: true }); + continue; + } + pending.push({ path: file, hash, dirty: false }); + } + return pending as ReadonlyArray; +}); + +/** + * Applies pending seed files. Mirrors Go's `SeedData` + `ExecBatchWithCache` + * (`pkg/migration/seed.go:65-83`, `file.go:198-217`): create the `seed_files` + * table, then per file emit the dirty/clean status line and, in one transaction, + * run the file's statements (skipped when dirty — only the hash is refreshed) + * followed by the `seed_files` hash upsert. + */ +export const legacySeedData = ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + workdir: string, + path: Path.Path, + seeds: ReadonlyArray, + mapError: (message: string) => E, +): Effect.Effect => + Effect.gen(function* () { + const output = yield* Output; + if (seeds.length === 0) return; + yield* session.exec(CREATE_SEED_TABLE); + for (const seed of seeds) { + yield* output.raw( + seed.dirty + ? `Updating seed hash to ${seed.path}...\n` + : `Seeding data from ${seed.path}...\n`, + "stderr", + ); + const statements = seed.dirty + ? [] + : legacySplitAndTrim(yield* fs.readFileString(path.join(workdir, seed.path))); + yield* session.exec("BEGIN"); + const body = Effect.gen(function* () { + for (const statement of statements) yield* session.exec(statement); + yield* session.query(UPSERT_SEED_FILE, [seed.path, seed.hash]); + yield* session.exec("COMMIT"); + }); + yield* body.pipe(Effect.tapError(() => session.exec("ROLLBACK").pipe(Effect.ignore))); + } + }).pipe( + Effect.mapError((error) => + mapError( + typeof error === "object" && error !== null && "message" in error + ? String((error as { message: unknown }).message) + : String(error), + ), + ), + ); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.unit.test.ts new file mode 100644 index 0000000000..8d2a1e860f --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.unit.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { legacyMatchPattern } from "./legacy-seed-ops.ts"; + +describe("legacyMatchPattern", () => { + it("matches a literal filename", () => { + expect(legacyMatchPattern("seed.sql", "seed.sql")).toBe(true); + expect(legacyMatchPattern("seed.sql", "other.sql")).toBe(false); + }); + + it("matches `*` against any run of characters", () => { + expect(legacyMatchPattern("*.sql", "seed.sql")).toBe(true); + expect(legacyMatchPattern("*.sql", "0001_init.sql")).toBe(true); + expect(legacyMatchPattern("*.sql", "seed.txt")).toBe(false); + expect(legacyMatchPattern("seed.*", "seed.sql")).toBe(true); + }); + + it("matches `?` against exactly one character", () => { + expect(legacyMatchPattern("seed?.sql", "seed1.sql")).toBe(true); + expect(legacyMatchPattern("seed?.sql", "seed12.sql")).toBe(false); + expect(legacyMatchPattern("seed?.sql", "seed.sql")).toBe(false); + }); + + it("matches character classes with ranges and negation", () => { + expect(legacyMatchPattern("seed[0-9].sql", "seed5.sql")).toBe(true); + expect(legacyMatchPattern("seed[0-9].sql", "seedx.sql")).toBe(false); + expect(legacyMatchPattern("seed[!0-9].sql", "seedx.sql")).toBe(true); + expect(legacyMatchPattern("seed[!0-9].sql", "seed5.sql")).toBe(false); + }); + + it("honors backslash escapes", () => { + expect(legacyMatchPattern("seed\\*.sql", "seed*.sql")).toBe(true); + expect(legacyMatchPattern("seed\\*.sql", "seedx.sql")).toBe(false); + }); + + it("collapses consecutive stars", () => { + expect(legacyMatchPattern("**.sql", "seed.sql")).toBe(true); + }); +}); From d207d56b48f54f668b558fee315c1c641a149eab Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 19:19:15 +0000 Subject: [PATCH 03/29] feat(cli): add vault upsert + migration/globals apply loops for db push Port vault.UpsertVaultSecrets (literal/env-skip parity) and the ApplyMigrations / SeedGlobals stderr-emitting loops, extracting a shared transactional batch core from legacyApplyMigrationFile. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nv8pAJ695qfNs5Btbv5v7h --- .../legacy/commands/db/shared/legacy-vault.ts | 77 ++++++++++++ .../legacy/shared/legacy-migration-apply.ts | 112 +++++++++++++----- 2 files changed, 161 insertions(+), 28 deletions(-) create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-vault.ts diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-vault.ts b/apps/cli/src/legacy/commands/db/shared/legacy-vault.ts new file mode 100644 index 0000000000..736d9619cc --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-vault.ts @@ -0,0 +1,77 @@ +import { Effect } from "effect"; + +import { Output } from "../../../../shared/output/output.service.ts"; +import type { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts"; +import type { LegacyDbSession } from "../../../shared/legacy-db-connection.service.ts"; + +/** + * Vault SQL, verbatim from Go's `pkg/vault/batch.go`. `create_secret(value, name)` + * and `update_secret(id, value)` argument orders match Go exactly. + */ +const READ_VAULT_KV = "SELECT id, name FROM vault.secrets WHERE name = ANY($1)"; +const CREATE_VAULT_KV = "SELECT vault.create_secret($1, $2)"; +const UPDATE_VAULT_KV = "SELECT vault.update_secret($1, $2)"; + +// Go's secret env-reference form (`pkg/config` envPattern); env() references are +// never synced to the vault — Go leaves them verbatim with an empty SHA256. +const ENV_REFERENCE_PATTERN = /^env\([A-Z_][A-Z0-9_]*\)$/u; +// dotenvx-encrypted secrets (Go decrypts before hashing). Decryption is not yet +// ported, so encrypted entries are skipped rather than sent as ciphertext. +const ENCRYPTED_PREFIX = "encrypted:"; + +/** + * Selects the `[db.vault]` entries Go would sync. Go's secret decode + * (`pkg/config/secret.go:86-108`) sets a non-empty `SHA256` — the gate + * `UpsertVaultSecrets` keys on — only for non-empty, non-`env()` values, so those + * are exactly the syncable ones. Encrypted values are excluded pending the + * decryption port (documented in SIDE_EFFECTS.md). + */ +export function legacySyncableVaultSecrets( + vault: Readonly> | undefined, +): ReadonlyArray<{ readonly key: string; readonly value: string }> { + if (vault === undefined) return []; + const result: Array<{ readonly key: string; readonly value: string }> = []; + for (const [key, value] of Object.entries(vault)) { + if (value.length === 0) continue; + if (ENV_REFERENCE_PATTERN.test(value)) continue; + if (value.startsWith(ENCRYPTED_PREFIX)) continue; + result.push({ key, value }); + } + return result; +} + +/** + * Upserts configured `[db.vault]` secrets into the target database. Mirrors Go's + * `vault.UpsertVaultSecrets` (`pkg/vault/batch.go:25-60`): no-op when nothing is + * syncable; otherwise read existing secrets by name, `update_secret` the matches + * (by id) and `create_secret` the rest. Emits `Updating vault secrets...` to + * stderr only when there is at least one secret to sync. + */ +export const legacyUpsertVaultSecrets = ( + session: LegacyDbSession, + vault: Readonly> | undefined, + mapError: (message: string) => E, +): Effect.Effect => + Effect.gen(function* () { + const output = yield* Output; + const secrets = legacySyncableVaultSecrets(vault); + if (secrets.length === 0) return; + const toInsert = new Map(secrets.map((s) => [s.key, s.value])); + + yield* output.raw("Updating vault secrets...\n", "stderr"); + + const existing = yield* session.query(READ_VAULT_KV, [secrets.map((s) => s.key)]); + for (const row of existing) { + const name = String(row["name"]); + const id = String(row["id"]); + const value = toInsert.get(name); + if (value === undefined) continue; + yield* session.query(UPDATE_VAULT_KV, [id, value]); + toInsert.delete(name); + } + for (const [key, value] of toInsert) { + yield* session.query(CREATE_VAULT_KV, [value, key]); + } + }).pipe( + Effect.mapError((error: LegacyDbExecError) => mapError(error.message)), + ); diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index 2b584eb1d3..684afd06f3 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -1,5 +1,6 @@ import { Effect, type FileSystem, type Path } from "effect"; +import { Output } from "../../shared/output/output.service.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; import { legacySplitAndTrim } from "./legacy-sql-split.ts"; @@ -21,7 +22,7 @@ const INSERT_MIGRATION_VERSION = const MIGRATE_FILE_PATTERN = /^([0-9]+)_(.*)\.sql$/; /** Creates the migration-history schema/table (idempotent). Go's `CreateMigrationTable`. */ -const createMigrationTable = (session: LegacyDbSession) => +export const legacyCreateMigrationTable = (session: LegacyDbSession) => Effect.gen(function* () { yield* session.exec(SET_LOCK_TIMEOUT); yield* session.exec(CREATE_VERSION_SCHEMA); @@ -30,43 +31,40 @@ const createMigrationTable = (session: LegacyDbSession) => yield* session.exec(ADD_NAME_COLUMN); }); +const errMessage = (e: unknown): string => + typeof e === "object" && e !== null && "message" in e && typeof e.message === "string" + ? e.message + : String(e); + /** - * Applies a single migration file to the connected database and records it in - * `supabase_migrations.schema_migrations`. Mirrors Go's `migration.ApplyMigrations` - * for one file (`pkg/migration/apply.go` + `(*MigrationFile).ExecBatch`): create - * the history table, `RESET ALL`, then run the file's statements + the history - * insert atomically. The whole file is one transaction (Go's `ExecBatch` is - * implicitly transactional); on failure the transaction is rolled back. - * - * `mapError` lets the caller tag the failure (e.g. `LegacyDeclarativeApplyError`). + * Runs a single migration/seed file's statements (plus the optional history + * insert) inside one transaction. Mirrors Go's `(*MigrationFile).ExecBatch` + * (`pkg/migration/file.go:75-115`) — the batch is implicitly transactional, so a + * failed statement rolls the file back. Does NOT create the history table; the + * caller decides whether to (Go's `ApplyMigrations` creates it once up front, + * `SeedGlobals` never does). When `forceNoVersion` is set the history insert is + * skipped regardless of filename (Go's `SeedGlobals` clears `Version`). */ -export const legacyApplyMigrationFile = ( +const execMigrationBatch = ( session: LegacyDbSession, fs: FileSystem.FileSystem, path: Path.Path, migrationPath: string, mapError: (message: string) => E, + forceNoVersion: boolean, ): Effect.Effect => Effect.gen(function* () { const content = yield* fs.readFileString(migrationPath); const statements = legacySplitAndTrim(content); const filename = path.basename(migrationPath); const matches = MIGRATE_FILE_PATTERN.exec(filename); - const version = matches?.[1] ?? ""; + const version = forceNoVersion ? "" : (matches?.[1] ?? ""); const name = matches?.[2] ?? ""; - yield* createMigrationTable(session); yield* session.exec("RESET ALL"); yield* session.exec("BEGIN"); // Mirror Go's `MigrationFile.ExecBatch` error context (`pkg/migration/file.go:88-113`): - // on a failed statement, append `At statement: ` and the statement text so the - // error (and the debug bundle) point at the exact failing SQL. (Go also adds a caret / - // pgErr.Detail / extension-type hint, which need the driver SQLSTATE the session does - // not currently surface — the statement number + text is the always-present context.) - const errMessage = (e: unknown): string => - typeof e === "object" && e !== null && "message" in e && typeof e.message === "string" - ? e.message - : String(e); + // on a failed statement, append `At statement: ` and the statement text. const atStatement = (e: unknown, index: number, stat: string) => new Error(`${errMessage(e)}\nAt statement: ${index}\n${stat}`); const body = Effect.gen(function* () { @@ -77,7 +75,6 @@ export const legacyApplyMigrationFile = ( .pipe(Effect.mapError((cause) => atStatement(cause, i, statement))); } if (version.length > 0) { - // Go defaults to the version-insert statement when all listed statements succeed. yield* session .query(INSERT_MIGRATION_VERSION, [version, name, statements]) .pipe( @@ -89,10 +86,69 @@ export const legacyApplyMigrationFile = ( yield* session.exec("COMMIT"); }); yield* body.pipe(Effect.tapError(() => session.exec("ROLLBACK").pipe(Effect.ignore))); - }).pipe( - Effect.mapError((error) => - mapError( - "message" in error && typeof error.message === "string" ? error.message : String(error), - ), - ), - ); + }).pipe(Effect.mapError((error) => mapError(errMessage(error)))); + +/** + * Applies a single migration file to the connected database and records it in + * `supabase_migrations.schema_migrations`. Mirrors Go's `migration.ApplyMigrations` + * for one file (`pkg/migration/apply.go` + `(*MigrationFile).ExecBatch`): create + * the history table, `RESET ALL`, then run the file's statements + the history + * insert atomically. + * + * `mapError` lets the caller tag the failure (e.g. `LegacyDeclarativeApplyError`). + */ +export const legacyApplyMigrationFile = ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + path: Path.Path, + migrationPath: string, + mapError: (message: string) => E, +): Effect.Effect => + Effect.gen(function* () { + yield* legacyCreateMigrationTable(session).pipe(Effect.mapError((e) => mapError(errMessage(e)))); + yield* execMigrationBatch(session, fs, path, migrationPath, mapError, false); + }); + +/** + * Applies a list of pending migration files, mirroring Go's + * `migration.ApplyMigrations` (`pkg/migration/apply.go:56-77`): create the + * history table once when there is anything to apply, then for each file emit + * `Applying migration ...` to stderr and run it transactionally. + */ +export const legacyApplyMigrations = ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + path: Path.Path, + pending: ReadonlyArray, + mapError: (message: string) => E, +): Effect.Effect => + Effect.gen(function* () { + const output = yield* Output; + if (pending.length === 0) return; + yield* legacyCreateMigrationTable(session).pipe(Effect.mapError((e) => mapError(errMessage(e)))); + for (const migrationPath of pending) { + yield* output.raw(`Applying migration ${path.basename(migrationPath)}...\n`, "stderr"); + yield* execMigrationBatch(session, fs, path, migrationPath, mapError, false); + } + }); + +/** + * Applies custom-role / globals files, mirroring Go's `migration.SeedGlobals` + * (`pkg/migration/seed.go:85-100`): for each file emit `Seeding globals from + * ...` to stderr and run it transactionally WITHOUT inserting a migration + * history row (Go clears `Version`) and WITHOUT creating the history table. + */ +export const legacySeedGlobals = ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + path: Path.Path, + globals: ReadonlyArray, + mapError: (message: string) => E, +): Effect.Effect => + Effect.gen(function* () { + const output = yield* Output; + for (const globalPath of globals) { + yield* output.raw(`Seeding globals from ${path.basename(globalPath)}...\n`, "stderr"); + yield* execMigrationBatch(session, fs, path, globalPath, mapError, true); + } + }); From 6853ef44bec0c48ce506870df069e2812b54a5b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 19:26:09 +0000 Subject: [PATCH 04/29] feat(cli): implement native db push handler Replace the Go-proxy shim with a native Effect handler porting internal/db/push/push.go: target mutex, config-driven skip messages, pending-migration/seed/roles collection, dry-run, confirm prompts, vault upsert + migration/seed/globals apply, and three output modes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nv8pAJ695qfNs5Btbv5v7h --- .../legacy/commands/db/push/push.command.ts | 25 +- .../legacy/commands/db/push/push.errors.ts | 61 +++ .../legacy/commands/db/push/push.handler.ts | 367 +++++++++++++++++- .../legacy/commands/db/push/push.layers.ts | 73 ++++ 4 files changed, 512 insertions(+), 14 deletions(-) create mode 100644 apps/cli/src/legacy/commands/db/push/push.errors.ts create mode 100644 apps/cli/src/legacy/commands/db/push/push.layers.ts diff --git a/apps/cli/src/legacy/commands/db/push/push.command.ts b/apps/cli/src/legacy/commands/db/push/push.command.ts index 2d6d8d17e9..8c936315ca 100644 --- a/apps/cli/src/legacy/commands/db/push/push.command.ts +++ b/apps/cli/src/legacy/commands/db/push/push.command.ts @@ -1,6 +1,10 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyDbPush } from "./push.handler.ts"; +import { legacyDbPushRuntimeLayer } from "./push.layers.ts"; const config = { includeAll: Flag.boolean("include-all").pipe( @@ -37,5 +41,24 @@ export type LegacyDbPushFlags = CliCommand.Command.Config.Infer; export const legacyDbPushCommand = Command.make("push", config).pipe( Command.withDescription("Push new migrations to the remote database."), Command.withShortDescription("Push new migrations to the remote database"), - Command.withHandler((flags) => legacyDbPush(flags)), + Command.withHandler((flags) => + legacyDbPush(flags).pipe( + withLegacyCommandInstrumentation({ + flags: { + "include-all": flags.includeAll, + "include-roles": flags.includeRoles, + "include-seed": flags.includeSeed, + "dry-run": flags.dryRun, + "db-url": flags.dbUrl, + linked: flags.linked, + local: flags.local, + // `password` is a credential — always reaches telemetry as ``. + password: flags.password, + }, + aliases: { p: "password" }, + }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyDbPushRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/db/push/push.errors.ts b/apps/cli/src/legacy/commands/db/push/push.errors.ts new file mode 100644 index 0000000000..ff808b969c --- /dev/null +++ b/apps/cli/src/legacy/commands/db/push/push.errors.ts @@ -0,0 +1,61 @@ +import { Data } from "effect"; + +/** + * Conflicting database-target flags. Reproduces cobra's + * `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` error byte-for-byte + * (`apps/cli-go/cmd/db.go:526`). + */ +export class LegacyDbPushTargetFlagsError extends Data.TaggedError("LegacyDbPushTargetFlagsError")<{ + readonly message: string; +}> {} + +/** + * Remote migration versions are missing from the local directory. Byte-matches + * Go's `migration.ErrMissingLocal` (`pkg/migration/apply.go:16`); the + * `migration repair` / `db pull` suggestion is attached (Go's `CmdSuggestion`). + */ +export class LegacyDbPushMissingLocalError extends Data.TaggedError( + "LegacyDbPushMissingLocalError", +)<{ + readonly message: string; + readonly suggestion: string; +}> {} + +/** + * Local migration files are ordered before the remote head and `--include-all` + * was not passed. Byte-matches Go's `migration.ErrMissingRemote` + * (`pkg/migration/apply.go:15`); the `--include-all` suggestion is attached. + */ +export class LegacyDbPushMissingRemoteError extends Data.TaggedError( + "LegacyDbPushMissingRemoteError", +)<{ + readonly message: string; + readonly suggestion: string; +}> {} + +/** + * The user declined a confirmation prompt. Go returns `errors.New(context.Canceled)` + * (`internal/db/push/push.go:80,91,110`), rendered as `context canceled`. + */ +export class LegacyDbPushCancelledError extends Data.TaggedError("LegacyDbPushCancelledError")<{ + readonly message: string; +}> {} + +/** `supabase/config.toml` failed to parse. */ +export class LegacyDbPushConfigLoadError extends Data.TaggedError("LegacyDbPushConfigLoadError")<{ + readonly message: string; +}> {} + +/** Locating `supabase/roles.sql` failed (Go's `failed to find custom roles: %w`). */ +export class LegacyDbPushRolesError extends Data.TaggedError("LegacyDbPushRolesError")<{ + readonly message: string; +}> {} + +/** + * A migration / seed / globals / vault statement failed while applying. Carries + * the underlying Postgres error (with Go's `At statement: ` context for + * migrations) so stderr matches Go's propagated error. + */ +export class LegacyDbPushApplyError extends Data.TaggedError("LegacyDbPushApplyError")<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/db/push/push.handler.ts b/apps/cli/src/legacy/commands/db/push/push.handler.ts index fddb270174..5d74d95114 100644 --- a/apps/cli/src/legacy/commands/db/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/db/push/push.handler.ts @@ -1,17 +1,358 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { + loadProjectConfig, + type LoadProjectConfigOptions, + ProjectConfigSchema, +} from "@supabase/config"; +import { Effect, FileSystem, Option, Path, Schema } from "effect"; + +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; +import { legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { legacyAqua, legacyBold } from "../../../shared/legacy-colors.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; +import { + legacyApplyMigrations, + legacySeedGlobals, +} from "../../../shared/legacy-migration-apply.ts"; +import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; +import { resolveLegacyDbTargetFlags } from "../../../shared/legacy-db-target-flags.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { legacyListLocalMigrations } from "../shared/legacy-pgdelta.cache.ts"; +import { + LEGACY_ERR_MISSING_LOCAL, + LEGACY_ERR_MISSING_REMOTE, + legacyFindPendingMigrations, + legacyIncludeAllPending, + legacySuggestIgnoreFlag, + legacySuggestRevertHistory, +} from "../shared/legacy-migration-pending.ts"; +import { + type LegacySeedFile, + legacyGetPendingSeeds, + legacySeedData, +} from "../shared/legacy-seed-ops.ts"; +import { legacyUpsertVaultSecrets } from "../shared/legacy-vault.ts"; +// Listing the remote `schema_migrations` history (with the 42P01 → empty rule) is +// already implemented for `db pull`; reused here. A future hoist of this single +// helper into `db/shared/` would let `db reset` share it too. +import { legacyListRemoteMigrations } from "../pull/pull.sync.ts"; import type { LegacyDbPushFlags } from "./push.command.ts"; +import { + LegacyDbPushApplyError, + LegacyDbPushCancelledError, + LegacyDbPushConfigLoadError, + LegacyDbPushMissingLocalError, + LegacyDbPushMissingRemoteError, + LegacyDbPushRolesError, + LegacyDbPushTargetFlagsError, +} from "./push.errors.ts"; + +const CUSTOM_ROLES_PATH = "supabase/roles.sql"; + +const decodeDefaultConfig = Schema.decodeUnknownSync(ProjectConfigSchema); + +const toSlash = (p: string): string => p.replaceAll("\\", "/"); + +const baseName = (p: string): string => { + const normalized = p.replace(/[/\\]+$/u, ""); + const slash = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\")); + return slash === -1 ? normalized : normalized.slice(slash + 1); +}; + +/** Reads the raw `[db.vault]` table from the loaded config document. */ +const readVaultDocument = ( + document: Record | undefined, +): Readonly> | undefined => { + const db = document?.["db"]; + const vault = typeof db === "object" && db !== null ? (db as Record)["vault"] : undefined; + if (typeof vault !== "object" || vault === null) return undefined; + const result: Record = {}; + for (const [key, value] of Object.entries(vault)) { + if (typeof value === "string") result[key] = value; + } + return result; +}; + +/** Go's `confirmPushAll` (`internal/db/push/push.go:123-129`) — bold filenames. */ +const confirmPushAll = (pending: ReadonlyArray): string => + pending.map((p) => ` • ${legacyBold(baseName(p))}\n`).join(""); +/** Go's `confirmSeedAll` (`internal/db/push/push.go:131-140`) — bold paths, hash notice. */ +const confirmSeedAll = (seeds: ReadonlyArray): string => + seeds + .map((seed) => ` • ${legacyBold(seed.dirty ? `${seed.path} (hash update)` : seed.path)}\n`) + .join(""); + +const applyError = (message: string) => new LegacyDbPushApplyError({ message }); + +/** + * `supabase db push` — apply pending local migrations (and optionally seed data + * and custom roles) to the local or linked/remote database. + * + * Strict 1:1 port of `apps/cli-go/internal/db/push/push.go`. + */ export const legacyDbPush = Effect.fn("legacy.db.push")(function* (flags: LegacyDbPushFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["db", "push"]; - if (flags.includeAll) args.push("--include-all"); - if (flags.includeRoles) args.push("--include-roles"); - if (flags.includeSeed) args.push("--include-seed"); - if (flags.dryRun) args.push("--dry-run"); - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - if (Option.isSome(flags.password)) args.push("--password", flags.password.value); - yield* proxy.exec(args); + const output = yield* Output; + const resolver = yield* LegacyDbConfigResolver; + const dbConn = yield* LegacyDbConnection; + const cliConfig = yield* LegacyCliConfig; + const telemetryState = yield* LegacyTelemetryState; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cliArgs = yield* CliArgs; + const dnsResolver = yield* LegacyDnsResolverFlag; + const yes = yield* legacyResolveYes; + + const workdir = cliConfig.workdir; + let linkedRefForCache: string | undefined; + + const body = Effect.gen(function* () { + const target = resolveLegacyDbTargetFlags(cliArgs.args); + // cobra MarkFlagsMutuallyExclusive("db-url", "linked", "local"), keyed off the + // explicitly-set flags (cobra's `Changed`), not the `--linked` default value. + if (target.setFlags.length > 1) { + return yield* Effect.fail( + new LegacyDbPushTargetFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, + }), + ); + } + // Go's push defaults `--linked` to true, so no target flag → linked. + const connType = target.connType ?? "linked"; + + // The linked path resolves the project ref before loading config so a matching + // `[remotes.]` block merges (Go's ParseDatabaseConfig → LoadConfig). For + // `--local` / `--db-url`, Go leaves `flags.ProjectRef` empty. + let projectRef = ""; + if (connType === "linked") { + const refResolver = yield* LegacyProjectRefResolver; + projectRef = yield* refResolver.loadProjectRef(Option.none()); + linkedRefForCache = projectRef; + } + + const loadOptions: LoadProjectConfigOptions | undefined = + projectRef !== "" ? { projectRef } : undefined; + const loaded = yield* loadProjectConfig(workdir, loadOptions).pipe( + Effect.catchTag( + "ProjectConfigParseError", + (cause) => + new LegacyDbPushConfigLoadError({ + message: `failed to parse supabase/config.toml: ${String(cause.cause)}`, + }), + ), + ); + const config = loaded === null ? decodeDefaultConfig({}) : loaded.config; + const document = loaded === null ? undefined : loaded.document; + if (loaded !== null && loaded.appliedRemote !== undefined) { + yield* output.raw(`Loading config override: [remotes.${loaded.appliedRemote}]\n`, "stderr"); + } + + if (flags.dryRun) { + yield* output.raw("DRY RUN: migrations will *not* be pushed to the database.\n", "stderr"); + } + + const cfg = yield* resolver.resolve({ + dbUrl: flags.dbUrl, + connType, + dnsResolver, + password: flags.password ?? Option.none(), + }); + const databaseName = cfg.isLocal ? "local database" : "remote database"; + const statusTarget = cfg.isLocal ? "Local database" : "Remote database"; + + yield* Effect.scoped( + Effect.gen(function* () { + yield* output.raw( + `Connecting to ${cfg.isLocal ? "local" : "remote"} database...\n`, + "stderr", + ); + const session = yield* dbConn.connect(cfg.conn, { isLocal: cfg.isLocal, dnsResolver }); + + // --- Collect pending migrations --- + let pending: ReadonlyArray = []; + if (!config.db.migrations.enabled) { + yield* output.raw( + `Skipping migrations because it is disabled in config.toml for project: ${projectRef}\n`, + "stderr", + ); + } else { + const migrationsDir = path.join(workdir, "supabase", "migrations"); + const remote = yield* legacyListRemoteMigrations(session); + const local = yield* legacyListLocalMigrations(fs, path, migrationsDir); + const result = legacyFindPendingMigrations(local, remote); + if (result.kind === "missing-local") { + return yield* Effect.fail( + new LegacyDbPushMissingLocalError({ + message: LEGACY_ERR_MISSING_LOCAL, + suggestion: legacySuggestRevertHistory(result.versions), + }), + ); + } + if (result.kind === "missing-remote") { + if (!flags.includeAll) { + // Go's suggestIgnoreFlag lists the workdir-relative paths. + const relPaths = result.paths.map((p) => toSlash(path.relative(workdir, p))); + return yield* Effect.fail( + new LegacyDbPushMissingRemoteError({ + message: LEGACY_ERR_MISSING_REMOTE, + suggestion: legacySuggestIgnoreFlag(relPaths), + }), + ); + } + pending = legacyIncludeAllPending(local, remote.length, result.paths); + } else { + pending = result.pending; + } + } + + // --- Collect pending seeds --- + let seeds: ReadonlyArray = []; + if (flags.includeSeed) { + if (!config.db.seed.enabled) { + yield* output.raw( + `Skipping seed because it is disabled in config.toml for project: ${projectRef}\n`, + "stderr", + ); + } else { + seeds = yield* legacyGetPendingSeeds( + session, + fs, + path, + config.db.seed.sql_paths, + workdir, + ); + } + } + + // --- Collect custom roles --- + const globals: Array = []; + if (flags.includeRoles) { + const exists = yield* fs.exists(path.join(workdir, CUSTOM_ROLES_PATH)).pipe( + Effect.mapError( + (cause) => + new LegacyDbPushRolesError({ + message: `failed to find custom roles: ${cause.message}`, + }), + ), + ); + if (exists) globals.push(CUSTOM_ROLES_PATH); + } + + // --- Nothing to push --- + if (pending.length === 0 && seeds.length === 0 && globals.length === 0) { + if (output.format === "text") { + yield* output.raw(`${statusTarget} is up to date.\n`); + } else { + yield* output.success(`${statusTarget} is up to date.`, { + upToDate: true, + dryRun: flags.dryRun, + migrations: [], + seeds: [], + roles: [], + }); + } + return; + } + + if (flags.dryRun) { + if (globals.length > 0) { + yield* output.raw(`Would create custom roles ${legacyBold(globals[0]!)}...\n`, "stderr"); + } + if (pending.length > 0) { + yield* output.raw("Would push these migrations:\n", "stderr"); + yield* output.raw(confirmPushAll(pending), "stderr"); + } + if (seeds.length > 0) { + yield* output.raw("Would seed these files:\n", "stderr"); + yield* output.raw(confirmSeedAll(seeds), "stderr"); + } + } else { + // --- Custom roles --- + if (globals.length > 0) { + const ok = yield* legacyPromptYesNo( + output, + yes, + "Do you want to create custom roles in the database cluster?", + true, + ); + if (!ok) { + return yield* Effect.fail(new LegacyDbPushCancelledError({ message: "context canceled" })); + } + yield* legacySeedGlobals( + session, + fs, + path, + globals.map((g) => path.join(workdir, g)), + applyError, + ); + } + + // --- Migrations --- + if (pending.length > 0) { + const ok = yield* legacyPromptYesNo( + output, + yes, + `Do you want to push these migrations to the ${databaseName}?\n${confirmPushAll(pending)}`, + true, + ); + if (!ok) { + return yield* Effect.fail(new LegacyDbPushCancelledError({ message: "context canceled" })); + } + yield* legacyUpsertVaultSecrets(session, readVaultDocument(document), applyError); + yield* legacyApplyMigrations(session, fs, path, pending, applyError); + // Go best-effort caches the migrations catalog for pg-delta; a failure + // only warns (`push.go:99-101`). The catalog cache is not yet ported, so + // there is nothing to warn about — parity is preserved (no extra output). + } else { + yield* output.raw("Schema migrations are up to date.\n", "stderr"); + } + + // --- Seeds --- + if (seeds.length > 0) { + const ok = yield* legacyPromptYesNo( + output, + yes, + `Do you want to seed the ${databaseName} with these files?\n${confirmSeedAll(seeds)}`, + true, + ); + if (!ok) { + return yield* Effect.fail(new LegacyDbPushCancelledError({ message: "context canceled" })); + } + yield* legacySeedData(session, fs, workdir, path, seeds, applyError); + } else if (flags.includeSeed) { + yield* output.raw("Seed files are up to date.\n", "stderr"); + } + } + + if (output.format === "text") { + yield* output.raw(`Finished ${legacyAqua("supabase db push")}.\n`); + } else { + yield* output.success("Finished supabase db push.", { + upToDate: false, + dryRun: flags.dryRun, + migrations: pending.map((p) => baseName(p)), + seeds: seeds.map((s) => s.path), + roles: globals, + }); + } + }), + ); + }); + + yield* body.pipe( + Effect.ensuring( + Effect.suspend(() => + linkedRefForCache !== undefined && linkedRefForCache !== "" + ? linkedProjectCache.cache(linkedRefForCache) + : Effect.void, + ), + ), + Effect.ensuring(telemetryState.flush), + ); }); diff --git a/apps/cli/src/legacy/commands/db/push/push.layers.ts b/apps/cli/src/legacy/commands/db/push/push.layers.ts new file mode 100644 index 0000000000..c3cf8d4cca --- /dev/null +++ b/apps/cli/src/legacy/commands/db/push/push.layers.ts @@ -0,0 +1,73 @@ +import { Layer } from "effect"; + +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { legacyCredentialsLayer } from "../../../auth/legacy-credentials.layer.ts"; +import { legacyHttpClientLayer } from "../../../auth/legacy-http-debug.layer.ts"; +import { legacyPlatformApiFactoryLayer } from "../../../auth/legacy-platform-api-factory.layer.ts"; +import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; +import { legacyProjectRefLayer } from "../../../config/legacy-project-ref.layer.ts"; +import { legacyDbConfigLayer } from "../../../shared/legacy-db-config.layer.ts"; +import { legacyDbConnectionLayer } from "../../../shared/legacy-db-connection.layer.ts"; +import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; +import { legacyIdentityStitchLayer } from "../../../shared/legacy-identity-stitch.ts"; +import { legacyLinkedProjectCacheLayer } from "../../../telemetry/legacy-linked-project-cache.layer.ts"; +import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; + +/** + * Runtime layer for `supabase db push`. Same shape as `db lint`: it spans local + * (`--local` / `--db-url`) and linked DB access, so it composes the Postgres + * connection, the db-config resolver, project-ref resolution, and the + * linked-project cache (Go's PersistentPostRun `ensureProjectGroupsCached`). + * + * Like `db lint`, it deliberately uses the **lazy** `legacyPlatformApiFactoryLayer` + * (not the eager management-API runtime) so the auth-free `--local` path never + * resolves an access token at layer-build time. `legacyCliConfigLayer` is provided + * to each consumer that needs it (legacy CLAUDE.md item 5); the single + * `legacyIdentityStitchLayer` reference is shared so the factory, the cache, and + * the db-config resolver share one `stitchAttempted` guard. + */ +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const credentials = legacyCredentialsLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), +); + +const platformApiFactory = legacyPlatformApiFactoryLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(legacyIdentityStitchLayer), +); + +const projectRef = legacyProjectRefLayer.pipe( + Layer.provide(platformApiFactory), + Layer.provide(cliConfig), +); + +const linkedProjectCache = legacyLinkedProjectCacheLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliConfig), + Layer.provide(httpClient), + Layer.provide(legacyIdentityStitchLayer), +); + +const dbConfig = legacyDbConfigLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDbConnectionLayer), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(legacyIdentityStitchLayer), +); + +export const legacyDbPushRuntimeLayer = Layer.mergeAll( + dbConfig, + legacyDbConnectionLayer, + cliConfig, + httpClient, + credentials, + projectRef, + linkedProjectCache, + legacyIdentityStitchLayer, + legacyTelemetryStateLayer, + commandRuntimeLayer(["db", "push"]), +); From 3eead5726b3e1deb341ffd3f08d6766f7fd8aea7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 19:30:08 +0000 Subject: [PATCH 05/29] test(cli): integration tests for native db push Cover up-to-date, apply+confirm, decline/cancel, dry-run, missing-local and missing-remote classification, --include-all, config-disabled skips, seed apply/up-to-date/disabled, and custom roles. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nv8pAJ695qfNs5Btbv5v7h --- .../commands/db/push/push.integration.test.ts | 403 ++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 apps/cli/src/legacy/commands/db/push/push.integration.test.ts diff --git a/apps/cli/src/legacy/commands/db/push/push.integration.test.ts b/apps/cli/src/legacy/commands/db/push/push.integration.test.ts new file mode 100644 index 0000000000..ccaa31fe04 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/push/push.integration.test.ts @@ -0,0 +1,403 @@ +import { createHash } from "node:crypto"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option } from "effect"; + +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { + LEGACY_VALID_REF, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { LegacyDnsResolverFlag, LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; +import type { OutputFormat } from "../../../../shared/output/types.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import type { + LegacyDbConfigFlags, + LegacyResolvedDbConfig, +} from "../../../shared/legacy-db-config.types.ts"; +import { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts"; +import { + LegacyDbConnection, + type LegacyPgConnInput, +} from "../../../shared/legacy-db-connection.service.ts"; +import { legacyDbPush } from "./push.handler.ts"; +import type { LegacyDbPushFlags } from "./push.command.ts"; + +const LIST_MIGRATIONS = "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version"; +const SELECT_SEEDS = "SELECT path, hash FROM supabase_migrations.seed_files"; +const READ_VAULT = "SELECT id, name FROM vault.secrets WHERE name = ANY($1)"; + +const LOCAL_CONN: LegacyPgConnInput = { + host: "127.0.0.1", + port: 54322, + user: "postgres", + password: "postgres", + database: "postgres", +}; + +const DEFAULT_FLAGS: LegacyDbPushFlags = { + includeAll: false, + includeRoles: false, + includeSeed: false, + dryRun: false, + dbUrl: Option.none(), + linked: false, + local: true, + password: Option.none(), +}; + +function mockResolver(opts: { isLocal?: boolean } = {}) { + return Layer.succeed(LegacyDbConfigResolver, { + resolve: (_flags: LegacyDbConfigFlags) => + Effect.succeed({ + conn: LOCAL_CONN, + isLocal: opts.isLocal ?? true, + } satisfies LegacyResolvedDbConfig), + resolvePoolerFallback: () => Effect.succeed(Option.none()), + }); +} + +function mockConnection(opts: { + remoteMigrations?: ReadonlyArray; + remoteSeeds?: Readonly>; + vaultRows?: ReadonlyArray<{ id: string; name: string }>; + noSeedTable?: boolean; +}) { + const execs: Array = []; + const queries: Array<{ sql: string; params?: ReadonlyArray }> = []; + const layer = Layer.succeed(LegacyDbConnection, { + connect: () => + Effect.succeed({ + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + exec: (sql: string) => + Effect.sync(() => { + execs.push(sql); + }), + query: (sql: string, params?: ReadonlyArray) => + Effect.suspend(() => { + queries.push({ sql, params }); + if (sql === LIST_MIGRATIONS) { + return Effect.succeed((opts.remoteMigrations ?? []).map((version) => ({ version }))); + } + if (sql === SELECT_SEEDS) { + if (opts.noSeedTable === true) { + return Effect.fail( + new LegacyDbExecError({ + message: 'relation "supabase_migrations.seed_files" does not exist', + code: "42P01", + }), + ); + } + return Effect.succeed( + Object.entries(opts.remoteSeeds ?? {}).map(([path, hash]) => ({ path, hash })), + ); + } + if (sql === READ_VAULT) { + return Effect.succeed(opts.vaultRows ?? []); + } + return Effect.succeed([]); + }), + }), + }); + return { + layer, + get execs() { + return execs; + }, + get queries() { + return queries; + }, + }; +} + +function setup( + workdir: string, + opts: { + toml?: string; + files?: Readonly>; + format?: OutputFormat; + confirm?: ReadonlyArray; + args?: ReadonlyArray; + yes?: boolean; + isLocal?: boolean; + projectRef?: string; + linkedFails?: boolean; + remoteMigrations?: ReadonlyArray; + remoteSeeds?: Readonly>; + vaultRows?: ReadonlyArray<{ id: string; name: string }>; + noSeedTable?: boolean; + }, +) { + if (opts.toml !== undefined) { + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), opts.toml); + } + for (const [rel, content] of Object.entries(opts.files ?? {})) { + const abs = join(workdir, rel); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, content); + } + + const out = mockOutput({ format: opts.format ?? "text", promptConfirmResponses: opts.confirm }); + const conn = mockConnection(opts); + const telemetry = mockLegacyTelemetryStateTracked(); + const linkedCache = mockLegacyLinkedProjectCacheTracked(); + const projectRefLayer = Layer.succeed(LegacyProjectRefResolver, { + resolve: () => Effect.succeed(opts.projectRef ?? LEGACY_VALID_REF), + resolveForLink: () => Effect.succeed(opts.projectRef ?? LEGACY_VALID_REF), + resolveOptional: () => Effect.succeed(Option.some(opts.projectRef ?? LEGACY_VALID_REF)), + loadProjectRef: () => + opts.linkedFails === true + ? Effect.fail( + new LegacyProjectNotLinkedError({ + message: "Cannot find project ref. Have you run supabase link?", + }), + ) + : Effect.succeed(opts.projectRef ?? LEGACY_VALID_REF), + promptProjectRef: () => Effect.succeed(opts.projectRef ?? LEGACY_VALID_REF), + }); + + const layer = Layer.mergeAll( + out.layer, + conn.layer, + mockResolver({ isLocal: opts.isLocal ?? true }), + mockLegacyCliConfig({ workdir }), + BunServices.layer, + Layer.succeed(CliArgs, { args: opts.args ?? ["db", "push", "--local"] }), + Layer.succeed(LegacyYesFlag, opts.yes ?? false), + Layer.succeed(LegacyDnsResolverFlag, "native"), + projectRefLayer, + telemetry.layer, + linkedCache.layer, + ); + return { layer, out, conn, telemetry, linkedCache }; +} + +const MIGRATION_DIR = "supabase/migrations"; +const migrationFile = (version: string, body = "create table t ();") => ({ + [`${MIGRATION_DIR}/${version}_test.sql`]: body, +}); + +describe("legacy db push", () => { + const tmp = useLegacyTempWorkdir("supabase-db-push-"); + + it.live("reports up to date when nothing is pending (text)", () => { + const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(out.stdoutText).toBe("Local database is up to date.\n"); + // No migration was applied. + expect(conn.execs).not.toContain("BEGIN"); + }); + }); + + it.live("emits a json result for an up-to-date run", () => { + const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', format: "json" }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["upToDate"]).toBe(true); + expect(success?.data?.["migrations"]).toEqual([]); + }); + }); + + it.live("rejects mutually exclusive target flags", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "push", "--local", "--linked"], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }); + }); + + it.live("applies a pending migration after confirmation", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + // "supabase db push" is wrapped in Aqua (cyan) on stdout, matching Go. + expect(out.stdoutText).toContain("Finished"); + expect(out.stdoutText).toContain("supabase db push"); + // The migration body + history insert ran inside a transaction. + expect(conn.execs).toContain("BEGIN"); + expect(conn.execs).toContain("COMMIT"); + expect(conn.queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations"))).toBe(true); + }); + }); + + it.live("returns context canceled when the migration prompt is declined", () => { + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + confirm: [false], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("context canceled"); + } + expect(conn.execs).not.toContain("BEGIN"); + }); + }); + + it.live("prints the plan without applying in dry-run mode", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, dryRun: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("DRY RUN: migrations will *not* be pushed to the database."); + expect(out.stderrText).toContain("Would push these migrations:"); + expect(out.stderrText).toContain("20240101000000_test.sql"); + expect(conn.execs).not.toContain("BEGIN"); + }); + }); + + it.live("fails with a repair suggestion when remote has versions missing locally", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + remoteMigrations: ["20240101000000"], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "Remote migration versions not found in local migrations directory.", + ); + expect(JSON.stringify(exit.cause)).toContain("migration repair --status reverted"); + } + expect(out).toBeDefined(); + }); + }); + + it.live("fails with an --include-all suggestion for out-of-order local migrations", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + // 0101 is local-only and ordered before the already-applied remote 0202. + files: { ...migrationFile("20240101000000"), ...migrationFile("20240202000000") }, + remoteMigrations: ["20240202000000"], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("--include-all"); + } + }); + }); + + it.live("pushes out-of-order migrations with --include-all", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { ...migrationFile("20240101000000"), ...migrationFile("20240202000000") }, + remoteMigrations: ["20240202000000"], + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeAll: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + }); + }); + + it.live("skips migrations when disabled in config and reports up to date", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.migrations]\nenabled = false\n', + files: migrationFile("20240101000000"), + }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain( + "Skipping migrations because it is disabled in config.toml for project:", + ); + expect(out.stdoutText).toBe("Local database is up to date.\n"); + }); + }); + + it.live("seeds a new file with --include-seed", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/seed.sql": "insert into t values (1);" }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Seeding data from supabase/seed.sql..."); + expect(conn.queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations.seed_files"))).toBe( + true, + ); + }); + }); + + it.live("reports seed files up to date when hash matches remote", () => { + // sha256 of the seed body must match the remote hash to be skipped. + const body = "insert into t values (1);"; + const hash = createHash("sha256").update(body).digest("hex"); + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/seed.sql": body }, + remoteSeeds: { "supabase/seed.sql": hash }, + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stdoutText).toBe("Local database is up to date.\n"); + }); + }); + + it.live("skips seeding when disabled in config", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.seed]\nenabled = false\n', + files: { "supabase/seed.sql": "insert into t values (1);" }, + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain( + "Skipping seed because it is disabled in config.toml for project:", + ); + }); + }); + + it.live("creates custom roles with --include-roles", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/roles.sql": "create role app;" }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeRoles: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Seeding globals from roles.sql..."); + }); + }); + + it.live("reports schema migrations up to date when only roles are pushed", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/roles.sql": "create role app;" }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeRoles: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Schema migrations are up to date."); + }); + }); +}); From 4a9eee4fbba69b115086e0bb8a3c1b921c4e9236 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 19:47:15 +0000 Subject: [PATCH 06/29] test(cli): expand db push coverage to 98.7% branch Add vault upsert (update+create), seed dirty/no-table/glob-warning, linked-path, no-target-flag default, apply-error, parse-error, and remotes-override cases. Relocate vault document parsing into the vault module with unit coverage. The lone uncovered branch is Go's unreachable afero.Exists error wrap. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nv8pAJ695qfNs5Btbv5v7h --- .../legacy/commands/db/push/push.handler.ts | 53 ++- .../commands/db/push/push.integration.test.ts | 307 ++++++++++++++++-- .../commands/db/shared/legacy-seed-ops.ts | 11 +- .../legacy/commands/db/shared/legacy-vault.ts | 23 +- .../db/shared/legacy-vault.unit.test.ts | 35 ++ .../legacy/shared/legacy-migration-apply.ts | 8 +- 6 files changed, 366 insertions(+), 71 deletions(-) create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-vault.unit.test.ts diff --git a/apps/cli/src/legacy/commands/db/push/push.handler.ts b/apps/cli/src/legacy/commands/db/push/push.handler.ts index 5d74d95114..19fbe83d6d 100644 --- a/apps/cli/src/legacy/commands/db/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/db/push/push.handler.ts @@ -36,7 +36,7 @@ import { legacyGetPendingSeeds, legacySeedData, } from "../shared/legacy-seed-ops.ts"; -import { legacyUpsertVaultSecrets } from "../shared/legacy-vault.ts"; +import { legacyReadVaultDocument, legacyUpsertVaultSecrets } from "../shared/legacy-vault.ts"; // Listing the remote `schema_migrations` history (with the 42P01 → empty rule) is // already implemented for `db pull`; reused here. A future hoist of this single // helper into `db/shared/` would let `db reset` share it too. @@ -58,29 +58,9 @@ const decodeDefaultConfig = Schema.decodeUnknownSync(ProjectConfigSchema); const toSlash = (p: string): string => p.replaceAll("\\", "/"); -const baseName = (p: string): string => { - const normalized = p.replace(/[/\\]+$/u, ""); - const slash = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\")); - return slash === -1 ? normalized : normalized.slice(slash + 1); -}; - -/** Reads the raw `[db.vault]` table from the loaded config document. */ -const readVaultDocument = ( - document: Record | undefined, -): Readonly> | undefined => { - const db = document?.["db"]; - const vault = typeof db === "object" && db !== null ? (db as Record)["vault"] : undefined; - if (typeof vault !== "object" || vault === null) return undefined; - const result: Record = {}; - for (const [key, value] of Object.entries(vault)) { - if (typeof value === "string") result[key] = value; - } - return result; -}; - /** Go's `confirmPushAll` (`internal/db/push/push.go:123-129`) — bold filenames. */ -const confirmPushAll = (pending: ReadonlyArray): string => - pending.map((p) => ` • ${legacyBold(baseName(p))}\n`).join(""); +const confirmPushAll = (filenames: ReadonlyArray): string => + filenames.map((name) => ` • ${legacyBold(name)}\n`).join(""); /** Go's `confirmSeedAll` (`internal/db/push/push.go:131-140`) — bold paths, hash notice. */ const confirmSeedAll = (seeds: ReadonlyArray): string => @@ -161,7 +141,7 @@ export const legacyDbPush = Effect.fn("legacy.db.push")(function* (flags: Legacy dbUrl: flags.dbUrl, connType, dnsResolver, - password: flags.password ?? Option.none(), + password: flags.password, }); const databaseName = cfg.isLocal ? "local database" : "remote database"; const statusTarget = cfg.isLocal ? "Local database" : "Remote database"; @@ -262,11 +242,14 @@ export const legacyDbPush = Effect.fn("legacy.db.push")(function* (flags: Legacy if (flags.dryRun) { if (globals.length > 0) { - yield* output.raw(`Would create custom roles ${legacyBold(globals[0]!)}...\n`, "stderr"); + yield* output.raw( + `Would create custom roles ${legacyBold(globals[0]!)}...\n`, + "stderr", + ); } if (pending.length > 0) { yield* output.raw("Would push these migrations:\n", "stderr"); - yield* output.raw(confirmPushAll(pending), "stderr"); + yield* output.raw(confirmPushAll(pending.map((p) => path.basename(p))), "stderr"); } if (seeds.length > 0) { yield* output.raw("Would seed these files:\n", "stderr"); @@ -282,7 +265,9 @@ export const legacyDbPush = Effect.fn("legacy.db.push")(function* (flags: Legacy true, ); if (!ok) { - return yield* Effect.fail(new LegacyDbPushCancelledError({ message: "context canceled" })); + return yield* Effect.fail( + new LegacyDbPushCancelledError({ message: "context canceled" }), + ); } yield* legacySeedGlobals( session, @@ -298,13 +283,15 @@ export const legacyDbPush = Effect.fn("legacy.db.push")(function* (flags: Legacy const ok = yield* legacyPromptYesNo( output, yes, - `Do you want to push these migrations to the ${databaseName}?\n${confirmPushAll(pending)}`, + `Do you want to push these migrations to the ${databaseName}?\n${confirmPushAll(pending.map((p) => path.basename(p)))}`, true, ); if (!ok) { - return yield* Effect.fail(new LegacyDbPushCancelledError({ message: "context canceled" })); + return yield* Effect.fail( + new LegacyDbPushCancelledError({ message: "context canceled" }), + ); } - yield* legacyUpsertVaultSecrets(session, readVaultDocument(document), applyError); + yield* legacyUpsertVaultSecrets(session, legacyReadVaultDocument(document), applyError); yield* legacyApplyMigrations(session, fs, path, pending, applyError); // Go best-effort caches the migrations catalog for pg-delta; a failure // only warns (`push.go:99-101`). The catalog cache is not yet ported, so @@ -322,7 +309,9 @@ export const legacyDbPush = Effect.fn("legacy.db.push")(function* (flags: Legacy true, ); if (!ok) { - return yield* Effect.fail(new LegacyDbPushCancelledError({ message: "context canceled" })); + return yield* Effect.fail( + new LegacyDbPushCancelledError({ message: "context canceled" }), + ); } yield* legacySeedData(session, fs, workdir, path, seeds, applyError); } else if (flags.includeSeed) { @@ -336,7 +325,7 @@ export const legacyDbPush = Effect.fn("legacy.db.push")(function* (flags: Legacy yield* output.success("Finished supabase db push.", { upToDate: false, dryRun: flags.dryRun, - migrations: pending.map((p) => baseName(p)), + migrations: pending.map((p) => path.basename(p)), seeds: seeds.map((s) => s.path), roles: globals, }); diff --git a/apps/cli/src/legacy/commands/db/push/push.integration.test.ts b/apps/cli/src/legacy/commands/db/push/push.integration.test.ts index ccaa31fe04..f43fa47d02 100644 --- a/apps/cli/src/legacy/commands/db/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/push/push.integration.test.ts @@ -32,7 +32,8 @@ import { import { legacyDbPush } from "./push.handler.ts"; import type { LegacyDbPushFlags } from "./push.command.ts"; -const LIST_MIGRATIONS = "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version"; +const LIST_MIGRATIONS = + "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version"; const SELECT_SEEDS = "SELECT path, hash FROM supabase_migrations.seed_files"; const READ_VAULT = "SELECT id, name FROM vault.secrets WHERE name = ANY($1)"; @@ -71,6 +72,7 @@ function mockConnection(opts: { remoteSeeds?: Readonly>; vaultRows?: ReadonlyArray<{ id: string; name: string }>; noSeedTable?: boolean; + failExec?: string; }) { const execs: Array = []; const queries: Array<{ sql: string; params?: ReadonlyArray }> = []; @@ -80,34 +82,47 @@ function mockConnection(opts: { extensionExists: () => Effect.succeed(false), copyToCsv: () => Effect.succeed(new Uint8Array()), queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), - exec: (sql: string) => - Effect.sync(() => { + exec: (sql: string): Effect.Effect => + Effect.suspend((): Effect.Effect => { execs.push(sql); - }), - query: (sql: string, params?: ReadonlyArray) => - Effect.suspend(() => { - queries.push({ sql, params }); - if (sql === LIST_MIGRATIONS) { - return Effect.succeed((opts.remoteMigrations ?? []).map((version) => ({ version }))); - } - if (sql === SELECT_SEEDS) { - if (opts.noSeedTable === true) { - return Effect.fail( - new LegacyDbExecError({ - message: 'relation "supabase_migrations.seed_files" does not exist', - code: "42P01", - }), - ); - } - return Effect.succeed( - Object.entries(opts.remoteSeeds ?? {}).map(([path, hash]) => ({ path, hash })), + if (opts.failExec !== undefined && sql === opts.failExec) { + return Effect.fail( + new LegacyDbExecError({ message: "ERROR: boom (SQLSTATE 42601)" }), ); } - if (sql === READ_VAULT) { - return Effect.succeed(opts.vaultRows ?? []); - } - return Effect.succeed([]); + return Effect.void; }), + query: ( + sql: string, + params?: ReadonlyArray, + ): Effect.Effect>, LegacyDbExecError> => + Effect.suspend( + (): Effect.Effect>, LegacyDbExecError> => { + queries.push({ sql, params }); + if (sql === LIST_MIGRATIONS) { + return Effect.succeed( + (opts.remoteMigrations ?? []).map((version) => ({ version })), + ); + } + if (sql === SELECT_SEEDS) { + if (opts.noSeedTable === true) { + return Effect.fail( + new LegacyDbExecError({ + message: 'relation "supabase_migrations.seed_files" does not exist', + code: "42P01", + }), + ); + } + return Effect.succeed( + Object.entries(opts.remoteSeeds ?? {}).map(([path, hash]) => ({ path, hash })), + ); + } + if (sql === READ_VAULT) { + return Effect.succeed(opts.vaultRows ?? []); + } + return Effect.succeed([]); + }, + ), }), }); return { @@ -137,6 +152,7 @@ function setup( remoteSeeds?: Readonly>; vaultRows?: ReadonlyArray<{ id: string; name: string }>; noSeedTable?: boolean; + failExec?: string; }, ) { if (opts.toml !== undefined) { @@ -239,7 +255,9 @@ describe("legacy db push", () => { // The migration body + history insert ran inside a transaction. expect(conn.execs).toContain("BEGIN"); expect(conn.execs).toContain("COMMIT"); - expect(conn.queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations"))).toBe(true); + expect(conn.queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations"))).toBe( + true, + ); }); }); @@ -343,9 +361,9 @@ describe("legacy db push", () => { return Effect.gen(function* () { yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); expect(out.stderrText).toContain("Seeding data from supabase/seed.sql..."); - expect(conn.queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations.seed_files"))).toBe( - true, - ); + expect( + conn.queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations.seed_files")), + ).toBe(true); }); }); @@ -400,4 +418,235 @@ describe("legacy db push", () => { expect(out.stderrText).toContain("Schema migrations are up to date."); }); }); + + it.live("returns context canceled when the roles prompt is declined", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/roles.sql": "create role app;" }, + confirm: [false], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush({ ...DEFAULT_FLAGS, includeRoles: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("context canceled"); + }); + }); + + it.live("returns context canceled when the seed prompt is declined", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/seed.sql": "insert into t values (1);" }, + confirm: [false], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("context canceled"); + }); + }); + + it.live("re-hashes a dirty seed without re-running its statements", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/seed.sql": "insert into t values (1);" }, + // Remote hash differs → dirty. + remoteSeeds: { "supabase/seed.sql": "stalehash" }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Updating seed hash to supabase/seed.sql..."); + // Dirty seed only upserts the hash; the body statement is not executed. + expect(conn.execs).not.toContain("insert into t values (1);"); + }); + }); + + it.live("treats every seed as pending when the seed_files table is absent", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/seed.sql": "insert into t values (1);" }, + noSeedTable: true, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Seeding data from supabase/seed.sql..."); + }); + }); + + it.live("warns and reports up to date when no seed files match", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.seed]\nsql_paths = ["missing.sql"]\n', + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("WARN: no files matched pattern: supabase/missing.sql"); + expect(out.stdoutText).toBe("Local database is up to date.\n"); + }); + }); + + it.live("reports seed files up to date when migrations push but no seeds match", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.seed]\nsql_paths = ["missing.sql"]\n', + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Seed files are up to date."); + }); + }); + + it.live("upserts vault secrets (update existing, create new) before migrating", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.vault]\nexisting = "v1"\nfresh = "v2"\n', + files: migrationFile("20240101000000"), + // `existing` already present remotely → update; `fresh` → create. + vaultRows: [{ id: "id-1", name: "existing" }], + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Updating vault secrets..."); + const sqls = conn.queries.map((q) => q.sql); + expect(sqls).toContain("SELECT vault.update_secret($1, $2)"); + expect(sqls).toContain("SELECT vault.create_secret($1, $2)"); + }); + }); + + it.live("defaults to the linked target when no target flag is set", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "push"], + isLocal: false, + projectRef: LEGACY_VALID_REF, + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, local: false }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Connecting to remote database..."); + expect(out.stdoutText).toBe("Remote database is up to date.\n"); + }); + }); + + it.live("surfaces an apply error with statement context", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000", "BOOM;"), + failExec: "BOOM", + confirm: [true], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("At statement: 0"); + } + }); + }); + + it.live("dry-run lists roles, migrations and seeds without applying", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + "supabase/roles.sql": "create role app;", + "supabase/seed.sql": "insert into t values (1);", + }, + }); + return Effect.gen(function* () { + yield* legacyDbPush({ + ...DEFAULT_FLAGS, + dryRun: true, + includeRoles: true, + includeSeed: true, + }).pipe(Effect.provide(layer)); + // The roles path is wrapped in Bold (ANSI), matching Go's utils.Bold. + expect(out.stderrText).toContain("Would create custom roles"); + expect(out.stderrText).toContain("roles.sql"); + expect(out.stderrText).toContain("Would push these migrations:"); + expect(out.stderrText).toContain("Would seed these files:"); + expect(conn.execs).not.toContain("BEGIN"); + }); + }); + + it.live("dry-run with only custom roles lists them without a migration section", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { "supabase/roles.sql": "create role app;" }, + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, dryRun: true, includeRoles: true }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).toContain("Would create custom roles"); + expect(out.stderrText).not.toContain("Would push these migrations:"); + }); + }); + + it.live("uses embedded defaults when no config file is present", () => { + const { layer, out } = setup(tmp.current, { + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + // No config.toml written → loadProjectConfig returns null → default config + // (migrations enabled), and the vault document is absent. + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + }); + }); + + it.live("fails when config.toml cannot be parsed", () => { + const { layer } = setup(tmp.current, { toml: "this is = = not [[[ valid toml" }); + return Effect.gen(function* () { + const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to parse supabase/config.toml"); + } + }); + }); + + it.live("announces a matching [remotes.*] override on the linked path", () => { + const { layer, out } = setup(tmp.current, { + toml: `project_id = "base"\n\n[remotes.preview]\nproject_id = "${LEGACY_VALID_REF}"\n`, + args: ["db", "push", "--linked"], + isLocal: false, + projectRef: LEGACY_VALID_REF, + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, local: false, linked: true }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).toContain("Loading config override: [remotes.preview]"); + }); + }); + + it.live("pushes to the linked project and caches the project ref (json)", () => { + const { layer, out, linkedCache } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + args: ["db", "push", "--linked"], + isLocal: false, + projectRef: LEGACY_VALID_REF, + format: "json", + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush({ ...DEFAULT_FLAGS, local: false, linked: true }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).toContain("Connecting to remote database..."); + expect(linkedCache.cached).toBe(true); + expect(linkedCache.cachedRef).toBe(LEGACY_VALID_REF); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["migrations"]).toEqual(["20240101000000_test.sql"]); + }); + }); }); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts b/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts index 98cc8bbf6b..4ae12ac325 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts @@ -162,12 +162,15 @@ const globOne = ( Effect.gen(function* () { // No metacharacters: a direct existence check (Go's `fs.Glob` fast path). if (!META_CHARS.test(pattern)) { - const exists = yield* fs.exists(path.join(workdir, pattern)).pipe(Effect.orElseSucceed(() => false)); + const exists = yield* fs + .exists(path.join(workdir, pattern)) + .pipe(Effect.orElseSucceed(() => false)); return exists ? [pattern] : []; } const { dir, file } = splitPath(pattern); // Resolve the directory level first (recursively if it, too, is a glob). - const dirs = dir === "" || !META_CHARS.test(dir) ? [dir] : yield* globOne(fs, path, workdir, dir); + const dirs = + dir === "" || !META_CHARS.test(dir) ? [dir] : yield* globOne(fs, path, workdir, dir); const result: Array = []; for (const d of dirs) { const absDir = d === "" ? workdir : path.join(workdir, d); @@ -192,9 +195,7 @@ const readRemoteSeeds = (session: LegacyDbSession) => return applied; }), Effect.catch((error: LegacyDbExecError) => - isUndefinedTable(error) - ? Effect.succeed(new Map()) - : Effect.fail(error), + isUndefinedTable(error) ? Effect.succeed(new Map()) : Effect.fail(error), ), ); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-vault.ts b/apps/cli/src/legacy/commands/db/shared/legacy-vault.ts index 736d9619cc..8e4f6bb184 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-vault.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-vault.ts @@ -19,6 +19,25 @@ const ENV_REFERENCE_PATTERN = /^env\([A-Z_][A-Z0-9_]*\)$/u; // ported, so encrypted entries are skipped rather than sent as ciphertext. const ENCRYPTED_PREFIX = "encrypted:"; +/** + * Extracts the raw `[db.vault]` string entries from a loaded config document. + * The document is the post-`env()` raw TOML (values are typed `unknown`), so + * non-string entries are defensively skipped. + */ +export function legacyReadVaultDocument( + document: Record | undefined, +): Readonly> | undefined { + const db = document?.["db"]; + const vault = + typeof db === "object" && db !== null ? (db as Record)["vault"] : undefined; + if (typeof vault !== "object" || vault === null) return undefined; + const result: Record = {}; + for (const [key, value] of Object.entries(vault)) { + if (typeof value === "string") result[key] = value; + } + return result; +} + /** * Selects the `[db.vault]` entries Go would sync. Go's secret decode * (`pkg/config/secret.go:86-108`) sets a non-empty `SHA256` — the gate @@ -72,6 +91,4 @@ export const legacyUpsertVaultSecrets = ( for (const [key, value] of toInsert) { yield* session.query(CREATE_VAULT_KV, [value, key]); } - }).pipe( - Effect.mapError((error: LegacyDbExecError) => mapError(error.message)), - ); + }).pipe(Effect.mapError((error: LegacyDbExecError) => mapError(error.message))); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-vault.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-vault.unit.test.ts new file mode 100644 index 0000000000..f0b9c8b115 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-vault.unit.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { legacyReadVaultDocument, legacySyncableVaultSecrets } from "./legacy-vault.ts"; + +describe("legacyReadVaultDocument", () => { + it("returns undefined when the document or db.vault is absent", () => { + expect(legacyReadVaultDocument(undefined)).toBeUndefined(); + expect(legacyReadVaultDocument({})).toBeUndefined(); + expect(legacyReadVaultDocument({ db: 5 })).toBeUndefined(); + expect(legacyReadVaultDocument({ db: { vault: "nope" } })).toBeUndefined(); + }); + + it("keeps only string-valued entries", () => { + expect(legacyReadVaultDocument({ db: { vault: { a: "x", b: 1, c: "y" } } })).toEqual({ + a: "x", + c: "y", + }); + }); +}); + +describe("legacySyncableVaultSecrets", () => { + it("returns nothing for an absent table", () => { + expect(legacySyncableVaultSecrets(undefined)).toEqual([]); + }); + + it("skips empty, env-reference, and encrypted values", () => { + const result = legacySyncableVaultSecrets({ + empty: "", + fromEnv: "env(MY_SECRET)", + encrypted: "encrypted:abc", + literal: "plain-value", + }); + expect(result).toEqual([{ key: "literal", value: "plain-value" }]); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index 684afd06f3..941fa64690 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -105,7 +105,9 @@ export const legacyApplyMigrationFile = ( mapError: (message: string) => E, ): Effect.Effect => Effect.gen(function* () { - yield* legacyCreateMigrationTable(session).pipe(Effect.mapError((e) => mapError(errMessage(e)))); + yield* legacyCreateMigrationTable(session).pipe( + Effect.mapError((e) => mapError(errMessage(e))), + ); yield* execMigrationBatch(session, fs, path, migrationPath, mapError, false); }); @@ -125,7 +127,9 @@ export const legacyApplyMigrations = ( Effect.gen(function* () { const output = yield* Output; if (pending.length === 0) return; - yield* legacyCreateMigrationTable(session).pipe(Effect.mapError((e) => mapError(errMessage(e)))); + yield* legacyCreateMigrationTable(session).pipe( + Effect.mapError((e) => mapError(errMessage(e))), + ); for (const migrationPath of pending) { yield* output.raw(`Applying migration ${path.basename(migrationPath)}...\n`, "stderr"); yield* execMigrationBatch(session, fs, path, migrationPath, mapError, false); From daf361ff534ed975a0a38da6b8f904d06ed119c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 19:49:37 +0000 Subject: [PATCH 07/29] docs(cli): document db push side effects and mark it ported Rewrite push/SIDE_EFFECTS.md for the native implementation and flip db push to `ported` in go-cli-porting-status.md. De-export internal-only helpers flagged by knip. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nv8pAJ695qfNs5Btbv5v7h --- apps/cli/docs/go-cli-porting-status.md | 4 +- .../legacy/commands/db/push/SIDE_EFFECTS.md | 95 +++++++++++++------ .../commands/db/shared/legacy-seed-ops.ts | 4 +- .../legacy/shared/legacy-migration-apply.ts | 2 +- 4 files changed, 72 insertions(+), 33 deletions(-) diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 18a2fd46a5..713da96c17 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -86,7 +86,7 @@ These commands exist in the TS CLI today but have no direct top-level equivalent | `db dump` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | | `db lint` | `ported` | `legacy/commands/db/lint/` | `n/a` | `n/a` | Native TS port. Runs `plpgsql_check` in a rolled-back transaction via LegacyDbConnection; emits Go-parity pretty JSON. | | `db pull` | `ported` | `legacy/commands/db/pull/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra migration + `--declarative` pg-delta export; reconciles `schema_migrations`. `--experimental` dump + initial-pull `pg_dump` (migra) delegate to the Go binary. | -| `db push` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `db push` | `ported` | `legacy/commands/db/push/` | `n/a` | `n/a` | Native TS port. Connects local/linked/`--db-url`; pushes pending migrations, `--include-seed` seeds (`seed_files` hash tracking), `--include-roles`, `[db.vault]` secrets; `--dry-run`. `encrypted:` vault secrets + best-effort pg-delta catalog cache not ported (no output impact). | | `db reset` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | | `db start` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | | `inspect report` | `ported` | `legacy/commands/inspect/report/` | `n/a` | `n/a` | Native TS port. Runs every inspect query via server-side `COPY ... CSV`, writes 14 CSVs under `//`, then renders a Go-parity Glamour rules summary (bounded csvq-subset evaluator; custom `[experimental.inspect.rules]` supported). | @@ -300,7 +300,7 @@ Legend: | `seed buckets` | `ported` | [`../src/legacy/commands/seed/buckets/buckets.command.ts`](../src/legacy/commands/seed/buckets/buckets.command.ts) | | `db diff` | `ported` | [`../src/legacy/commands/db/diff/diff.command.ts`](../src/legacy/commands/db/diff/diff.command.ts) — native pg-delta / migra; `--use-pgadmin` / `--use-pg-schema` delegate to Go | | `db dump` | `ported` | [`../src/legacy/commands/db/dump/dump.command.ts`](../src/legacy/commands/db/dump/dump.command.ts) | -| `db push` | `wrapped` | [`../src/legacy/commands/db/push/push.command.ts`](../src/legacy/commands/db/push/push.command.ts) | +| `db push` | `ported` | [`../src/legacy/commands/db/push/push.command.ts`](../src/legacy/commands/db/push/push.command.ts) | | `db pull` | `ported` | [`../src/legacy/commands/db/pull/pull.command.ts`](../src/legacy/commands/db/pull/pull.command.ts) — native pg-delta / migra; `--declarative` (deprecated alias `--use-pg-delta`) + `--diff-engine` (migra\|pg-delta); `--experimental` / initial `pg_dump` delegate to Go | | `db reset` | `wrapped` | [`../src/legacy/commands/db/reset/reset.command.ts`](../src/legacy/commands/db/reset/reset.command.ts) | | `db lint` | `ported` | [`../src/legacy/commands/db/lint/lint.command.ts`](../src/legacy/commands/db/lint/lint.command.ts) | diff --git a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md index 11337a8955..cd2f58feec 100644 --- a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md @@ -1,59 +1,98 @@ # `supabase db push` +Native TypeScript port of `apps/cli-go/internal/db/push/push.go`. Applies pending +local migrations (and optionally seed data and custom roles) to the local or +linked/remote Postgres database. + ## Files Read -| Path | Format | When | -| -------------------------------- | ---------- | ------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | -| `/supabase/migrations/` | directory | always, to list migration files to push | -| `/supabase/roles.sql` | SQL | when `--include-roles` is set | -| seed files from config | SQL | when `--include-seed` is set | +| Path | Format | When | +| ---------------------------------------- | ---------- | -------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always (embedded defaults used when absent) | +| `~/.supabase//project-ref` | plain text | on the `--linked` path (and the default target), to resolve the ref | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and a linked temp-role is minted | +| `/supabase/migrations/` | directory | when `[db.migrations].enabled` (default true), to list local files | +| `/supabase/migrations/*.sql` | SQL | for each pending migration, when applied (and not `--dry-run`) | +| seed files from `[db.seed].sql_paths` | SQL | when `--include-seed` and `[db.seed].enabled` (paths under `supabase/`) | +| `/supabase/roles.sql` | SQL | when `--include-roles` (existence check + apply) | ## Files Written | Path | Format | When | | ---- | ------ | ---- | -| — | — | — | +| `~/.supabase//linked-project.json` | JSON | on the `--linked` path (post-run cache, Go's `ensureProjectGroupsCached`) | +| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | + +No project files are written. All other effects are database mutations (below). + +## Database Mutations + +| Statement | When | +| --------- | ---- | +| `RESET ALL` + `BEGIN` … migration statements … `INSERT INTO supabase_migrations.schema_migrations(version, name, statements)` … `COMMIT` | per pending migration (after confirmation) | +| `CREATE SCHEMA/TABLE … supabase_migrations.schema_migrations`, `ALTER TABLE … ADD COLUMN …` | once before applying migrations (idempotent) | +| `RESET ALL` + `BEGIN` … roles.sql statements … `COMMIT` (no history row) | per `--include-roles` globals file (after confirmation) | +| `SELECT id, name FROM vault.secrets …`, `SELECT vault.update_secret(...)`, `SELECT vault.create_secret(...)` | when `[db.vault]` has syncable secrets and migrations are applied | +| `CREATE TABLE … supabase_migrations.seed_files`, seed statements, `INSERT … seed_files(path, hash) … ON CONFLICT …` | per pending seed file with `--include-seed` (after confirmation); a dirty seed only refreshes the hash | ## API Routes | Method | Path | Auth | Request body | Response (used fields) | | ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | +| — | — | — | — | The native handler connects to Postgres directly. On the `--linked` path the db-config resolver may call the Management API to mint a temporary login role (inherited from the shared resolver). | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | --------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` mode | no (falls back to keyring → `~/.supabase/access-token`) | -| `DB_PASSWORD` | password for direct database connection | no | +| Variable | Purpose | Required? | +| ----------------------- | ------------------------------------------------------ | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no (`--password`/`-p` takes precedence) | +| `SUPABASE_YES` | auto-confirm prompts (Go's `viper YES`) | no (also `--yes`) | ## Exit Codes -| Code | Condition | -| ---- | --------------------------- | -| `0` | success | -| `1` | database connection failure | -| `1` | migration apply error | +| Code | Condition | +| ---- | ------------------------------------------------------------------------- | +| `0` | success (including "up to date") | +| `1` | mutually exclusive target flags (`[db-url linked local]`) | +| `1` | `ErrMissingLocal` — remote versions absent locally (suggests repair/pull) | +| `1` | `ErrMissingRemote` without `--include-all` (suggests `--include-all`) | +| `1` | user declined a confirmation prompt (`context canceled`) | +| `1` | `config.toml` parse failure | +| `1` | database connection / migration / seed / roles / vault apply failure | ## Output -### `--output-format text` (Go CLI compatible) +Diagnostics ("Connecting to…", "Applying migration…", "Seeding…", "Updating vault +secrets…", skip/up-to-date notices, dry-run plan, prompts) go to **stderr**. The +two summary lines Go prints to **stdout** — ` is up to date.` and +`Finished supabase db push.` (the command name in Aqua) — go to stdout in text +mode; in machine modes they are suppressed and a structured result is emitted. -Prints applied migration versions to stderr. With `--dry-run`, prints the migrations that would be applied. +### `--output-format text` (Go CLI compatible) -### `--output-format json` +Byte-matches Go: connection status, per-item progress, prompts, and the stdout +summary line, including ANSI color (Aqua command name, Bold file paths). -Not applicable. +### `--output-format json` / `stream-json` -### `--output-format stream-json` +stdout is payload-only. A single `result` object is emitted: -Not applicable. +```json +{ "upToDate": false, "dryRun": false, "migrations": [".sql"], "seeds": ["supabase/seed.sql"], "roles": ["supabase/roles.sql"] } +``` ## Notes -- `--dry-run` prints the migrations that would be applied without applying them. -- `--include-all` includes all migrations not found in remote history table. -- `--include-roles` includes custom roles from the roles file. -- `--include-seed` includes seed data from config. -- `--db-url`, `--linked` (default true), and `--local` are mutually exclusive. +- **Targets**: `--db-url`, `--linked` (default), and `--local` are mutually + exclusive; with no flag the target defaults to linked, matching Go. +- **Prompt order**: custom roles → migrations → seeds; each defaults to "yes" and + declining returns `context canceled`. +- **`--dry-run`** prints the plan (roles / migrations / seeds) and applies nothing. +- **`[db.migrations].enabled = false`** / **`[db.seed].enabled = false`** print a + skip notice naming the project ref (empty for local/db-url). +- **Vault**: only non-empty, non-`env()` `[db.vault]` literals are synced (Go syncs + secrets with a non-empty SHA256). **Known gap vs Go**: `encrypted:`-prefixed + vault secrets are currently skipped — dotenvx/ECIES decryption is not yet ported. +- **Migrations catalog cache** (Go's best-effort `pgcache.TryCacheMigrationsCatalog`, + warning-only) is not ported; it produces no output, so parity is preserved. diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts b/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts index 4ae12ac325..e540e07213 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts @@ -96,7 +96,7 @@ export function legacyMatchPattern(pattern: string, name: string): boolean { } /** Result of resolving `[db.seed].sql_paths` against the workspace. */ -export interface LegacyGlobResult { +interface LegacyGlobResult { /** Workdir-relative, forward-slashed matches, deduplicated in pattern order. */ readonly files: ReadonlyArray; /** Per-pattern warnings (`no files matched pattern: …`), joined by Go's `errors.Join`. */ @@ -111,7 +111,7 @@ export interface LegacyGlobResult { * first-seen order across patterns. A pattern that matches nothing contributes a * `no files matched pattern: ` warning but is not fatal. */ -export const legacyGlobSeedFiles = Effect.fnUntraced(function* ( +const legacyGlobSeedFiles = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, path: Path.Path, patterns: ReadonlyArray, diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index 941fa64690..22d773b636 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -22,7 +22,7 @@ const INSERT_MIGRATION_VERSION = const MIGRATE_FILE_PATTERN = /^([0-9]+)_(.*)\.sql$/; /** Creates the migration-history schema/table (idempotent). Go's `CreateMigrationTable`. */ -export const legacyCreateMigrationTable = (session: LegacyDbSession) => +const legacyCreateMigrationTable = (session: LegacyDbSession) => Effect.gen(function* () { yield* session.exec(SET_LOCK_TIMEOUT); yield* session.exec(CREATE_VERSION_SCHEMA); From 16214c029273b116c0439887f1be8562cf96018d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 08:06:03 +0000 Subject: [PATCH 08/29] feat(cli): implement native db reset remote path Port internal/db/reset/reset.go's remote path: --version/--last validation, drop user schemas (embedded drop.sql), vault upsert, and MigrateAndSeed (partial migrations + seed). The local and --experimental paths delegate to the Go binary (telemetry-disabled) as the documented interim until the container-bootstrap seam lands. 19 integration tests, handler at 98.7% branch. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nv8pAJ695qfNs5Btbv5v7h --- .../legacy/commands/db/reset/reset.command.ts | 24 +- .../legacy/commands/db/reset/reset.errors.ts | 60 +++ .../legacy/commands/db/reset/reset.handler.ts | 237 +++++++- .../db/reset/reset.integration.test.ts | 509 ++++++++++++++++++ .../legacy/commands/db/reset/reset.layers.ts | 67 +++ .../commands/db/shared/legacy-drop-schemas.ts | 166 ++++++ 6 files changed, 1057 insertions(+), 6 deletions(-) create mode 100644 apps/cli/src/legacy/commands/db/reset/reset.errors.ts create mode 100644 apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/db/reset/reset.layers.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-drop-schemas.ts diff --git a/apps/cli/src/legacy/commands/db/reset/reset.command.ts b/apps/cli/src/legacy/commands/db/reset/reset.command.ts index 11764e9db7..22cddf27cd 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.command.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.command.ts @@ -1,6 +1,10 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyDbReset } from "./reset.handler.ts"; +import { legacyDbResetRuntimeLayer } from "./reset.layers.ts"; const config = { dbUrl: Flag.string("db-url").pipe( @@ -33,5 +37,23 @@ export type LegacyDbResetFlags = CliCommand.Command.Config.Infer; export const legacyDbResetCommand = Command.make("reset", config).pipe( Command.withDescription("Resets the local database to current migrations."), Command.withShortDescription("Resets the local database to current migrations"), - Command.withHandler((flags) => legacyDbReset(flags)), + Command.withHandler((flags) => + legacyDbReset(flags).pipe( + withLegacyCommandInstrumentation({ + flags: { + "db-url": flags.dbUrl, + linked: flags.linked, + local: flags.local, + "no-seed": flags.noSeed, + version: flags.version, + last: flags.last, + }, + // Go marks `--version` telemetry-safe for migration/squash; reset reuses + // the same package-level var, so keep it safe here too. + safeFlags: ["version"], + }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyDbResetRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.errors.ts b/apps/cli/src/legacy/commands/db/reset/reset.errors.ts new file mode 100644 index 0000000000..6e8a64ee98 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/reset/reset.errors.ts @@ -0,0 +1,60 @@ +import { Data } from "effect"; + +/** + * Conflicting database-target flags. Reproduces cobra's + * `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` (`cmd/db.go:573`). + */ +export class LegacyDbResetTargetFlagsError extends Data.TaggedError( + "LegacyDbResetTargetFlagsError", +)<{ + readonly message: string; +}> {} + +/** + * `--version` and `--last` together. Reproduces cobra's + * `MarkFlagsMutuallyExclusive("version", "last")` (`cmd/db.go:576`). + */ +export class LegacyDbResetVersionFlagsError extends Data.TaggedError( + "LegacyDbResetVersionFlagsError", +)<{ + readonly message: string; +}> {} + +/** + * `--version` is not a valid integer. Byte-matches Go's + * `failed to parse : invalid version number` (`repair.go:24-29`). + */ +export class LegacyDbResetInvalidVersionError extends Data.TaggedError( + "LegacyDbResetInvalidVersionError", +)<{ + readonly message: string; +}> {} + +/** + * No migration file matches `--version`. Byte-matches Go's + * `glob supabase/migrations/_*.sql: file does not exist` + * (`repair.GetMigrationFile`). + */ +export class LegacyDbResetMigrationFileError extends Data.TaggedError( + "LegacyDbResetMigrationFileError", +)<{ + readonly message: string; +}> {} + +/** + * The user declined the reset confirmation. Go returns + * `errors.New(context.Canceled)` (`internal/db/reset/reset.go:248`). + */ +export class LegacyDbResetCancelledError extends Data.TaggedError("LegacyDbResetCancelledError")<{ + readonly message: string; +}> {} + +/** `supabase/config.toml` failed to parse. */ +export class LegacyDbResetConfigLoadError extends Data.TaggedError("LegacyDbResetConfigLoadError")<{ + readonly message: string; +}> {} + +/** A drop / migrate / seed / vault statement failed during the remote reset. */ +export class LegacyDbResetApplyError extends Data.TaggedError("LegacyDbResetApplyError")<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts index 406485fe53..592f122024 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts @@ -1,15 +1,242 @@ -import { Effect, Option } from "effect"; +import { + loadProjectConfig, + type LoadProjectConfigOptions, + ProjectConfigSchema, +} from "@supabase/config"; +import { Effect, FileSystem, Option, Path, Schema } from "effect"; + +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { + LegacyDnsResolverFlag, + LegacyExperimentalFlag, +} from "../../../../shared/legacy/global-flags.ts"; +import { legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; +import { legacyApplyMigrations } from "../../../shared/legacy-migration-apply.ts"; +import { legacyPromptYesNo } from "../../../shared/legacy-prompt-yes-no.ts"; +import { resolveLegacyDbTargetFlags } from "../../../shared/legacy-db-target-flags.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { legacyDropUserSchemas } from "../shared/legacy-drop-schemas.ts"; +import { legacyListLocalMigrations } from "../shared/legacy-pgdelta.cache.ts"; +import { legacyGetPendingSeeds, legacySeedData } from "../shared/legacy-seed-ops.ts"; +import { legacyReadVaultDocument, legacyUpsertVaultSecrets } from "../shared/legacy-vault.ts"; import type { LegacyDbResetFlags } from "./reset.command.ts"; +import { + LegacyDbResetApplyError, + LegacyDbResetCancelledError, + LegacyDbResetConfigLoadError, + LegacyDbResetInvalidVersionError, + LegacyDbResetMigrationFileError, + LegacyDbResetTargetFlagsError, + LegacyDbResetVersionFlagsError, +} from "./reset.errors.ts"; -export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: LegacyDbResetFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["db", "reset"]; +const decodeDefaultConfig = Schema.decodeUnknownSync(ProjectConfigSchema); + +const INTEGER_PATTERN = /^[+-]?\d+$/u; +const MIGRATE_FILE_PATTERN = /^([0-9]+)_(.*)\.sql$/u; + +const applyError = (message: string) => new LegacyDbResetApplyError({ message }); + +/** Go's `toLogMessage` (`internal/db/reset/reset.go:88-91`). */ +const toLogMessage = (version: string): string => + version.length > 0 ? ` to version: ${version}` : "..."; + +/** Rebuilds the `db reset` argv for the Go-delegated (local / experimental) paths. */ +const buildResetArgs = (flags: LegacyDbResetFlags): Array => { + const args = ["db", "reset"]; if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); if (flags.linked) args.push("--linked"); if (flags.local) args.push("--local"); if (flags.noSeed) args.push("--no-seed"); if (Option.isSome(flags.version)) args.push("--version", flags.version.value); if (Option.isSome(flags.last)) args.push("--last", String(flags.last.value)); - yield* proxy.exec(args); + return args; +}; + +/** + * `supabase db reset` — reinitialise a database from local migrations (+ seed). + * + * Strict 1:1 port of `apps/cli-go/internal/db/reset/reset.go`. The remote path + * (`--linked` / a remote `--db-url`) is native. The local path (and the niche + * `--experimental` schema-files path) delegate to the Go binary as a documented + * interim until the container-bootstrap seam is ported (CLI-1325 Stage 3). + */ +export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: LegacyDbResetFlags) { + const output = yield* Output; + const resolver = yield* LegacyDbConfigResolver; + const dbConn = yield* LegacyDbConnection; + const proxy = yield* LegacyGoProxy; + const cliConfig = yield* LegacyCliConfig; + const telemetryState = yield* LegacyTelemetryState; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cliArgs = yield* CliArgs; + const dnsResolver = yield* LegacyDnsResolverFlag; + const experimental = yield* LegacyExperimentalFlag; + const yes = yield* legacyResolveYes; + + const workdir = cliConfig.workdir; + const migrationsDir = path.join(workdir, "supabase", "migrations"); + let linkedRefForCache: string | undefined; + + const body = Effect.gen(function* () { + const target = resolveLegacyDbTargetFlags(cliArgs.args); + // cobra MarkFlagsMutuallyExclusive("db-url", "linked", "local"). + if (target.setFlags.length > 1) { + return yield* Effect.fail( + new LegacyDbResetTargetFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, + }), + ); + } + // cobra MarkFlagsMutuallyExclusive("version", "last") — alphabetical group. + if (Option.isSome(flags.version) && Option.isSome(flags.last)) { + return yield* Effect.fail( + new LegacyDbResetVersionFlagsError({ + message: + "if any flags in the group [last version] are set none of the others can be; [last version] were all set", + }), + ); + } + + // Version / last resolution (Go's reset.Run lines 34-52), filesystem only. + let resolvedVersion = ""; + if (Option.isSome(flags.version)) { + const v = flags.version.value; + if (!INTEGER_PATTERN.test(v)) { + return yield* Effect.fail( + new LegacyDbResetInvalidVersionError({ + message: `failed to parse ${v}: invalid version number`, + }), + ); + } + const locals = yield* legacyListLocalMigrations(fs, path, migrationsDir); + const found = locals.some((p) => path.basename(p).startsWith(`${v}_`)); + if (!found) { + return yield* Effect.fail( + new LegacyDbResetMigrationFileError({ + message: `glob supabase/migrations/${v}_*.sql: file does not exist`, + }), + ); + } + resolvedVersion = v; + } else if (Option.isSome(flags.last) && flags.last.value > 0) { + const locals = yield* legacyListLocalMigrations(fs, path, migrationsDir); + const versions = locals.flatMap((p) => { + const m = MIGRATE_FILE_PATTERN.exec(path.basename(p)); + return m?.[1] !== undefined ? [m[1]] : []; + }); + const total = versions.length; + const last = flags.last.value; + resolvedVersion = last < total ? versions[total - last - 1]! : "-"; + } + + const connType = target.connType ?? "local"; + const cfg = yield* resolver.resolve({ dbUrl: flags.dbUrl, connType, dnsResolver }); + + // Local target → container reset, not yet ported. Delegate to the Go binary + // (telemetry disabled so the TS instrumentation wrapper counts the run once). + if (cfg.isLocal) { + yield* proxy.exec(buildResetArgs(flags), { env: { SUPABASE_TELEMETRY_DISABLED: "1" } }); + return; + } + + // Remote path. The niche `--experimental` schema-files apply path + // (`apply.MigrateAndSeed`) is not ported; delegate it too. + if (experimental && resolvedVersion === "") { + yield* proxy.exec(buildResetArgs(flags), { env: { SUPABASE_TELEMETRY_DISABLED: "1" } }); + return; + } + + const linkedRef = Option.getOrUndefined(cfg.ref ?? Option.none()); + if (connType === "linked" && linkedRef !== undefined) linkedRefForCache = linkedRef; + + const loadOptions: LoadProjectConfigOptions | undefined = + connType === "linked" && linkedRef !== undefined ? { projectRef: linkedRef } : undefined; + const loaded = yield* loadProjectConfig(workdir, loadOptions).pipe( + Effect.catchTag( + "ProjectConfigParseError", + (cause) => + new LegacyDbResetConfigLoadError({ + message: `failed to parse supabase/config.toml: ${String(cause.cause)}`, + }), + ), + ); + const config = loaded === null ? decodeDefaultConfig({}) : loaded.config; + const document = loaded === null ? undefined : loaded.document; + if (loaded !== null && loaded.appliedRemote !== undefined) { + yield* output.raw(`Loading config override: [remotes.${loaded.appliedRemote}]\n`, "stderr"); + } + + // Go's resetRemote: prompt (default false) → cancel, then ResetAll. + const shouldReset = yield* legacyPromptYesNo( + output, + yes, + "Do you want to reset the remote database?", + false, + ); + if (!shouldReset) { + return yield* Effect.fail(new LegacyDbResetCancelledError({ message: "context canceled" })); + } + yield* output.raw(`Resetting remote database${toLogMessage(resolvedVersion)}\n`, "stderr"); + + // Go connects with io.Discard, so NO "Connecting to ... database..." line. + yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* dbConn.connect(cfg.conn, { isLocal: false, dnsResolver }); + // ResetAll: drop user schemas → upsert vault → migrate + seed. + yield* legacyDropUserSchemas(session, applyError); + yield* legacyUpsertVaultSecrets(session, legacyReadVaultDocument(document), applyError); + + if (config.db.migrations.enabled) { + const locals = yield* legacyListLocalMigrations(fs, path, migrationsDir); + // LoadPartialMigrations filter: version === "" || v <= version. + const pending = locals.filter((p) => { + if (resolvedVersion === "") return true; + const m = MIGRATE_FILE_PATTERN.exec(path.basename(p)); + return m?.[1] !== undefined && m[1] <= resolvedVersion; + }); + yield* legacyApplyMigrations(session, fs, path, pending, applyError); + } + + // `--no-seed` forces seed disabled (Go sets Config.Db.Seed.Enabled=false). + if (config.db.seed.enabled && !flags.noSeed) { + const seeds = yield* legacyGetPendingSeeds( + session, + fs, + path, + config.db.seed.sql_paths, + workdir, + ); + yield* legacySeedData(session, fs, workdir, path, seeds, applyError); + } + // Go's best-effort pgcache catalog warning is not ported (no output impact). + }), + ); + + if (output.format !== "text") { + yield* output.success("Reset remote database.", { + target: "remote", + version: resolvedVersion, + }); + } + }); + + yield* body.pipe( + Effect.ensuring( + Effect.suspend(() => + linkedRefForCache !== undefined && linkedRefForCache !== "" + ? linkedProjectCache.cache(linkedRefForCache) + : Effect.void, + ), + ), + Effect.ensuring(telemetryState.flush), + ); }); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts new file mode 100644 index 0000000000..93b297f3ac --- /dev/null +++ b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts @@ -0,0 +1,509 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option } from "effect"; + +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { + LEGACY_VALID_REF, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { + LegacyDnsResolverFlag, + LegacyExperimentalFlag, + LegacyYesFlag, +} from "../../../../shared/legacy/global-flags.ts"; +import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import type { OutputFormat } from "../../../../shared/output/types.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import type { + LegacyDbConfigFlags, + LegacyResolvedDbConfig, +} from "../../../shared/legacy-db-config.types.ts"; +import { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts"; +import { + LegacyDbConnection, + type LegacyPgConnInput, +} from "../../../shared/legacy-db-connection.service.ts"; +import { legacyDbReset } from "./reset.handler.ts"; +import type { LegacyDbResetFlags } from "./reset.command.ts"; + +const LIST_MIGRATIONS = + "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version"; +const SELECT_SEEDS = "SELECT path, hash FROM supabase_migrations.seed_files"; + +const CONN: LegacyPgConnInput = { + host: "db.example.supabase.co", + port: 5432, + user: "postgres", + password: "secret", + database: "postgres", +}; + +const DEFAULT_FLAGS: LegacyDbResetFlags = { + dbUrl: Option.none(), + linked: false, + local: false, + noSeed: false, + version: Option.none(), + last: Option.none(), +}; + +function mockResolver(opts: { isLocal: boolean; ref?: string; omitRef?: boolean }) { + return Layer.succeed(LegacyDbConfigResolver, { + resolve: (_flags: LegacyDbConfigFlags) => + Effect.succeed( + (opts.omitRef === true + ? { conn: CONN, isLocal: opts.isLocal } + : { + conn: CONN, + isLocal: opts.isLocal, + ref: opts.ref !== undefined ? Option.some(opts.ref) : Option.none(), + }) satisfies LegacyResolvedDbConfig, + ), + resolvePoolerFallback: () => Effect.succeed(Option.none()), + }); +} + +function mockConnection(opts: { remoteSeeds?: Readonly> }) { + const execs: Array = []; + const queries: Array<{ sql: string; params?: ReadonlyArray }> = []; + const layer = Layer.succeed(LegacyDbConnection, { + connect: () => + Effect.succeed({ + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + exec: (sql: string): Effect.Effect => + Effect.sync(() => { + execs.push(sql); + }), + query: ( + sql: string, + params?: ReadonlyArray, + ): Effect.Effect>, LegacyDbExecError> => + Effect.suspend( + (): Effect.Effect>, LegacyDbExecError> => { + queries.push({ sql, params }); + if (sql === SELECT_SEEDS) { + return Effect.succeed( + Object.entries(opts.remoteSeeds ?? {}).map(([path, hash]) => ({ path, hash })), + ); + } + if (sql === LIST_MIGRATIONS) return Effect.succeed([]); + return Effect.succeed([]); + }, + ), + }), + }); + return { + layer, + get execs() { + return execs; + }, + get queries() { + return queries; + }, + }; +} + +function mockProxy() { + const calls: Array<{ args: ReadonlyArray; env?: Record }> = []; + const layer = Layer.succeed(LegacyGoProxy, { + exec: (args, opts) => + Effect.sync(() => { + calls.push({ args, env: opts?.env }); + }), + execCapture: () => Effect.succeed(""), + }); + return { + layer, + get calls() { + return calls; + }, + }; +} + +function setup( + workdir: string, + opts: { + toml?: string; + files?: Readonly>; + format?: OutputFormat; + confirm?: ReadonlyArray; + args?: ReadonlyArray; + isLocal?: boolean; + ref?: string; + experimental?: boolean; + remoteSeeds?: Readonly>; + yes?: boolean; + omitRef?: boolean; + }, +) { + if (opts.toml !== undefined) { + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), opts.toml); + } + for (const [rel, content] of Object.entries(opts.files ?? {})) { + const abs = join(workdir, rel); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, content); + } + + const out = mockOutput({ format: opts.format ?? "text", promptConfirmResponses: opts.confirm }); + const conn = mockConnection(opts); + const proxy = mockProxy(); + const telemetry = mockLegacyTelemetryStateTracked(); + const linkedCache = mockLegacyLinkedProjectCacheTracked(); + + const layer = Layer.mergeAll( + out.layer, + conn.layer, + proxy.layer, + mockResolver({ + isLocal: opts.isLocal ?? false, + ref: opts.ref ?? LEGACY_VALID_REF, + omitRef: opts.omitRef, + }), + mockLegacyCliConfig({ workdir }), + BunServices.layer, + Layer.succeed(CliArgs, { args: opts.args ?? ["db", "reset", "--linked"] }), + Layer.succeed(LegacyYesFlag, opts.yes ?? false), + Layer.succeed(LegacyDnsResolverFlag, "native"), + Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? false), + telemetry.layer, + linkedCache.layer, + ); + return { layer, out, conn, proxy, telemetry, linkedCache }; +} + +const migrationFile = (version: string, body = "create table t ();") => ({ + [`supabase/migrations/${version}_test.sql`]: body, +}); + +describe("legacy db reset", () => { + const tmp = useLegacyTempWorkdir("supabase-db-reset-"); + + it.live("delegates a local reset to the Go binary with telemetry disabled", () => { + const { layer, proxy, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset"], + isLocal: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(proxy.calls).toHaveLength(1); + expect(proxy.calls[0]!.args).toEqual(["db", "reset"]); + expect(proxy.calls[0]!.env).toEqual({ SUPABASE_TELEMETRY_DISABLED: "1" }); + // No native DB work on the delegated path. + expect(conn.execs).toHaveLength(0); + }); + }); + + it.live("rejects mutually exclusive target flags", () => { + const { layer } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset", "--linked", "--local"], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }); + }); + + it.live("rejects --version together with --last", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + version: Option.some("20240101000000"), + last: Option.some(1), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("[last version]"); + }); + }); + + it.live("rejects a non-integer --version", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + version: Option.some("not-a-number"), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) + expect(JSON.stringify(exit.cause)).toContain("invalid version number"); + }); + }); + + it.live("fails when --version has no matching migration file", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + version: Option.some("20240101000000"), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "glob supabase/migrations/20240101000000_*.sql: file does not exist", + ); + } + }); + }); + + it.live("returns context canceled when the reset prompt is declined", () => { + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + confirm: [false], + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("context canceled"); + expect(conn.execs).toHaveLength(0); + }); + }); + + it.live("drops schemas and applies migrations + seed on a confirmed remote reset", () => { + const { layer, out, conn, linkedCache } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + "supabase/seed.sql": "insert into t values (1);", + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Resetting remote database..."); + // No "Connecting to ... database..." line (Go uses io.Discard). + expect(out.stderrText).not.toContain("Connecting to"); + // Drop block ran, then the migration applied. + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + expect(out.stderrText).toContain("Seeding data from supabase/seed.sql..."); + expect(linkedCache.cached).toBe(true); + }); + }); + + it.live("resets to a specific version, applying only migrations up to it", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + ...migrationFile("20240202000000"), + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + version: Option.some("20240101000000"), + }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Resetting remote database to version: 20240101000000"); + expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); + expect(out.stderrText).not.toContain("Applying migration 20240202000000_test.sql..."); + expect(conn).toBeDefined(); + }); + }); + + it.live("resolves --last to a version prefix", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + ...migrationFile("20240202000000"), + }, + confirm: [true], + }); + return Effect.gen(function* () { + // last=1 → revert the most recent → reset to version 20240101000000. + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true, last: Option.some(1) }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).toContain("Resetting remote database to version: 20240101000000"); + }); + }); + + it.live("reverts all migrations when --last covers the full history", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { ...migrationFile("20240101000000"), ...migrationFile("20240202000000") }, + confirm: [true], + }); + return Effect.gen(function* () { + // last=2 with 2 local migrations → revert all → version "-". + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true, last: Option.some(2) }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).toContain("Resetting remote database to version: -"); + }); + }); + + it.live("skips seeding with --no-seed", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { + ...migrationFile("20240101000000"), + "supabase/seed.sql": "insert into t values (1);", + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true, noSeed: true }).pipe( + Effect.provide(layer), + ); + expect(out.stderrText).not.toContain("Seeding data from"); + }); + }); + + it.live("delegates an experimental remote reset to the Go binary", () => { + const { layer, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(proxy.calls).toHaveLength(1); + expect(proxy.calls[0]!.env).toEqual({ SUPABASE_TELEMETRY_DISABLED: "1" }); + }); + }); + + it.live("forwards all flags to the Go binary on the delegated local path", () => { + const { layer, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { ...migrationFile("20240101000000"), ...migrationFile("20240202000000") }, + args: ["db", "reset", "--local"], + isLocal: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + local: true, + noSeed: true, + last: Option.some(1), + }).pipe(Effect.provide(layer)); + expect(proxy.calls[0]!.args).toEqual(["db", "reset", "--local", "--no-seed", "--last", "1"]); + }); + }); + + it.live("forwards --db-url and --version when delegating a local db-url reset", () => { + const { layer, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + args: ["db", "reset", "--db-url", "postgresql://localhost:54322/postgres"], + isLocal: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + dbUrl: Option.some("postgresql://localhost:54322/postgres"), + version: Option.some("20240101000000"), + }).pipe(Effect.provide(layer)); + expect(proxy.calls[0]!.args).toEqual([ + "db", + "reset", + "--db-url", + "postgresql://localhost:54322/postgres", + "--version", + "20240101000000", + ]); + }); + }); + + it.live("resets a remote --db-url target without loading a remote config override", () => { + const { layer, out, conn } = setup(tmp.current, { + // No config file → embedded defaults (migrations + seed enabled). + files: migrationFile("20240101000000"), + args: ["db", "reset", "--db-url", "postgresql://db.example.com:5432/postgres"], + isLocal: false, + omitRef: true, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + dbUrl: Option.some("postgresql://db.example.com:5432/postgres"), + }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Resetting remote database..."); + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + }); + }); + + it.live("announces a matching [remotes.*] override", () => { + const { layer, out } = setup(tmp.current, { + toml: `project_id = "base"\n\n[remotes.preview]\nproject_id = "${LEGACY_VALID_REF}"\n`, + confirm: [true], + ref: LEGACY_VALID_REF, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Loading config override: [remotes.preview]"); + }); + }); + + it.live("skips migrations and seed when both are disabled in config", () => { + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.migrations]\nenabled = false\n\n[db.seed]\nenabled = false\n', + files: { + ...migrationFile("20240101000000"), + "supabase/seed.sql": "insert into t values (1);", + }, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + // Schemas are still dropped, but nothing is applied or seeded. + expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); + expect(out.stderrText).not.toContain("Applying migration"); + expect(out.stderrText).not.toContain("Seeding data from"); + }); + }); + + it.live("emits a json result for a confirmed remote reset (--yes)", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + format: "json", + yes: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["target"]).toBe("remote"); + }); + }); + + it.live("emits a json result for a confirmed remote reset", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + format: "json", + }); + return Effect.gen(function* () { + // json mode is non-interactive → prompt takes the default (false) → cancel. + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + // default-false prompt in non-text mode declines → context canceled. + expect(Exit.isFailure(exit)).toBe(true); + expect(out).toBeDefined(); + }); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.layers.ts b/apps/cli/src/legacy/commands/db/reset/reset.layers.ts new file mode 100644 index 0000000000..e88a280f32 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/reset/reset.layers.ts @@ -0,0 +1,67 @@ +import { Layer } from "effect"; + +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { legacyCredentialsLayer } from "../../../auth/legacy-credentials.layer.ts"; +import { legacyHttpClientLayer } from "../../../auth/legacy-http-debug.layer.ts"; +import { legacyPlatformApiFactoryLayer } from "../../../auth/legacy-platform-api-factory.layer.ts"; +import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; +import { legacyProjectRefLayer } from "../../../config/legacy-project-ref.layer.ts"; +import { legacyDbConfigLayer } from "../../../shared/legacy-db-config.layer.ts"; +import { legacyDbConnectionLayer } from "../../../shared/legacy-db-connection.layer.ts"; +import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; +import { legacyIdentityStitchLayer } from "../../../shared/legacy-identity-stitch.ts"; +import { legacyLinkedProjectCacheLayer } from "../../../telemetry/legacy-linked-project-cache.layer.ts"; +import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; + +/** + * Runtime layer for `supabase db reset`. Same composition as `db push` / `db lint`: + * the Postgres connection, the db-config resolver, project-ref resolution, and the + * linked-project cache, all over the lazy management-API factory so the local / + * `--db-url` paths never resolve an access token at layer-build time. `LegacyGoProxy` + * (used to delegate the local / experimental reset paths) is ambient from the root. + */ +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const credentials = legacyCredentialsLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), +); + +const platformApiFactory = legacyPlatformApiFactoryLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(legacyIdentityStitchLayer), +); + +const projectRef = legacyProjectRefLayer.pipe( + Layer.provide(platformApiFactory), + Layer.provide(cliConfig), +); + +const linkedProjectCache = legacyLinkedProjectCacheLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliConfig), + Layer.provide(httpClient), + Layer.provide(legacyIdentityStitchLayer), +); + +const dbConfig = legacyDbConfigLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDbConnectionLayer), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(legacyIdentityStitchLayer), +); + +export const legacyDbResetRuntimeLayer = Layer.mergeAll( + dbConfig, + legacyDbConnectionLayer, + cliConfig, + httpClient, + credentials, + projectRef, + linkedProjectCache, + legacyIdentityStitchLayer, + legacyTelemetryStateLayer, + commandRuntimeLayer(["db", "reset"]), +); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-drop-schemas.ts b/apps/cli/src/legacy/commands/db/shared/legacy-drop-schemas.ts new file mode 100644 index 0000000000..b0c5f4d631 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-drop-schemas.ts @@ -0,0 +1,166 @@ +import { Effect } from "effect"; + +import type { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts"; +import type { LegacyDbSession } from "../../../shared/legacy-db-connection.service.ts"; + +/** + * Verbatim port of Go's embedded `pkg/migration/queries/drop.sql` + * (`DropUserSchemas`). A single PL/pgSQL `DO` block that drops user schemas, + * extensions, public-schema objects, and non-managed publications, then + * truncates the auth / supabase_functions / supabase_migrations tables. Run as a + * single simple-query statement, matching Go's one-statement `ExecBatch`. + */ +const DROP_OBJECTS = `do $$ declare + rec record; +begin + -- schemas + for rec in + select pn.* + from pg_namespace pn + left join pg_depend pd on pd.objid = pn.oid + where pd.deptype is null + and not pn.nspname like any(array['information\\_schema', 'pg\\_%', '\\_analytics', '\\_realtime', '\\_supavisor', 'pgbouncer', 'pgmq', 'pgsodium', 'pgtle', 'supabase\\_migrations', 'vault', 'extensions', 'public']) + and pn.nspowner::regrole::text != 'supabase_admin' + loop + -- If an extension uses a schema it doesn't create, dropping the schema will cascade to also + -- drop the extension. But if an extension creates its own schema, dropping the schema will + -- throw an error. Hence, we drop schemas first while excluding those created by extensions. + raise notice 'dropping schema: %', rec.nspname; + execute format('drop schema if exists %I cascade', rec.nspname); + end loop; + + -- extensions + for rec in + select * + from pg_extension p + where p.extname not in ('pg_graphql', 'pg_net', 'pg_stat_statements', 'pgcrypto', 'pgjwt', 'pgsodium', 'plpgsql', 'supabase_vault', 'uuid-ossp') + loop + raise notice 'dropping extension: %', rec.extname; + execute format('drop extension if exists %I cascade', rec.extname); + end loop; + + -- functions + for rec in + select * + from pg_proc p + where p.pronamespace::regnamespace::name = 'public' + loop + -- supports aggregate, function, and procedure + raise notice 'dropping function: %.%', rec.pronamespace::regnamespace::name, rec.proname; + execute format('drop routine if exists %I.%I(%s) cascade', rec.pronamespace::regnamespace::name, rec.proname, pg_catalog.pg_get_function_identity_arguments(rec.oid)); + end loop; + + -- views (necessary for views referencing objects in Supabase-managed schemas) + for rec in + select * + from pg_class c + where + c.relnamespace::regnamespace::name = 'public' + and c.relkind = 'v' + loop + raise notice 'dropping view: %.%', rec.relnamespace::regnamespace::name, rec.relname; + execute format('drop view if exists %I.%I cascade', rec.relnamespace::regnamespace::name, rec.relname); + end loop; + + -- materialized views (necessary for materialized views referencing objects in Supabase-managed schemas) + for rec in + select * + from pg_class c + where + c.relnamespace::regnamespace::name = 'public' + and c.relkind = 'm' + loop + raise notice 'dropping materialized view: %.%', rec.relnamespace::regnamespace::name, rec.relname; + execute format('drop materialized view if exists %I.%I cascade', rec.relnamespace::regnamespace::name, rec.relname); + end loop; + + -- tables (cascade to dependent objects) + for rec in + select * + from pg_class c + where + c.relnamespace::regnamespace::name = 'public' + and c.relkind not in ('c', 'S', 'v', 'm') + order by c.relkind desc + loop + -- supports all table like relations, except views, complex types, and sequences + raise notice 'dropping table: %.%', rec.relnamespace::regnamespace::name, rec.relname; + execute format('drop table if exists %I.%I cascade', rec.relnamespace::regnamespace::name, rec.relname); + end loop; + + -- truncate tables in auth, webhooks, and migrations schema + for rec in + select * + from pg_class c + where + (c.relnamespace::regnamespace::name = 'auth' and c.relname != 'schema_migrations' + or c.relnamespace::regnamespace::name = 'supabase_functions' and c.relname != 'migrations' + or c.relnamespace::regnamespace::name = 'supabase_migrations') + and c.relkind = 'r' + loop + raise notice 'truncating table: %.%', rec.relnamespace::regnamespace::name, rec.relname; + execute format('truncate %I.%I cascade', rec.relnamespace::regnamespace::name, rec.relname); + end loop; + + -- sequences + for rec in + select * + from pg_class c + where + c.relnamespace::regnamespace::name = 'public' + and c.relkind = 's' + loop + raise notice 'dropping sequence: %.%', rec.relnamespace::regnamespace::name, rec.relname; + execute format('drop sequence if exists %I.%I cascade', rec.relnamespace::regnamespace::name, rec.relname); + end loop; + + -- types + for rec in + select * + from pg_type t + where + t.typnamespace::regnamespace::name = 'public' + and typtype != 'b' + loop + raise notice 'dropping type: %.%', rec.typnamespace::regnamespace::name, rec.typname; + execute format('drop type if exists %I.%I cascade', rec.typnamespace::regnamespace::name, rec.typname); + end loop; + + -- policies + for rec in + select * + from pg_policies p + loop + raise notice 'dropping policy: %', rec.policyname; + execute format('drop policy if exists %I on %I.%I cascade', rec.policyname, rec.schemaname, rec.tablename); + end loop; + + -- publications + for rec in + select * + from pg_publication p + where + not p.pubname like any(array['supabase\\_realtime%', 'realtime\\_messages%']) + loop + raise notice 'dropping publication: %', rec.pubname; + execute format('drop publication if exists %I', rec.pubname); + end loop; +end $$;`; + +/** + * Drops all user-created database objects, mirroring Go's + * `migration.DropUserSchemas` (`pkg/migration/drop.go:34-38`): the `drop.sql` `DO` + * block runs as a single transactional statement (no migration-history row). + */ +export const legacyDropUserSchemas = ( + session: LegacyDbSession, + mapError: (message: string) => E, +): Effect.Effect => + Effect.gen(function* () { + yield* session.exec("RESET ALL"); + yield* session.exec("BEGIN"); + yield* session + .exec(DROP_OBJECTS) + .pipe(Effect.tapError(() => session.exec("ROLLBACK").pipe(Effect.ignore))); + yield* session.exec("COMMIT"); + }).pipe(Effect.mapError((error: LegacyDbExecError) => mapError(error.message))); From 2c227b10691592e4e6c4c32f5486958fd3fe1958 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 08:07:31 +0000 Subject: [PATCH 09/29] docs(cli): document db reset side effects and mark remote path ported Rewrite reset/SIDE_EFFECTS.md for the native remote path and the local/experimental Go delegation; mark db reset `partial` in go-cli-porting-status.md. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nv8pAJ695qfNs5Btbv5v7h --- apps/cli/docs/go-cli-porting-status.md | 4 +- .../legacy/commands/db/reset/SIDE_EFFECTS.md | 93 ++++++++++++++----- 2 files changed, 70 insertions(+), 27 deletions(-) diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 713da96c17..fb52ad1a30 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -87,7 +87,7 @@ These commands exist in the TS CLI today but have no direct top-level equivalent | `db lint` | `ported` | `legacy/commands/db/lint/` | `n/a` | `n/a` | Native TS port. Runs `plpgsql_check` in a rolled-back transaction via LegacyDbConnection; emits Go-parity pretty JSON. | | `db pull` | `ported` | `legacy/commands/db/pull/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra migration + `--declarative` pg-delta export; reconciles `schema_migrations`. `--experimental` dump + initial-pull `pg_dump` (migra) delegate to the Go binary. | | `db push` | `ported` | `legacy/commands/db/push/` | `n/a` | `n/a` | Native TS port. Connects local/linked/`--db-url`; pushes pending migrations, `--include-seed` seeds (`seed_files` hash tracking), `--include-roles`, `[db.vault]` secrets; `--dry-run`. `encrypted:` vault secrets + best-effort pg-delta catalog cache not ported (no output impact). | -| `db reset` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `db reset` | `partial` | `legacy/commands/db/reset/` | `n/a` | `n/a` | Remote path (`--linked` / remote `--db-url`) native: drop user schemas, vault upsert, MigrateAndSeed (partial migrations + seed), `--version`/`--last`. Local reset + `--experimental` schema-files path delegate to the Go binary (telemetry-disabled) pending the container-bootstrap seam (Stage 3). | | `db start` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | | `inspect report` | `ported` | `legacy/commands/inspect/report/` | `n/a` | `n/a` | Native TS port. Runs every inspect query via server-side `COPY ... CSV`, writes 14 CSVs under `//`, then renders a Go-parity Glamour rules summary (bounded csvq-subset evaluator; custom `[experimental.inspect.rules]` supported). | | `inspect db db-stats` | `ported` | `legacy/commands/inspect/db/db-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | @@ -302,7 +302,7 @@ Legend: | `db dump` | `ported` | [`../src/legacy/commands/db/dump/dump.command.ts`](../src/legacy/commands/db/dump/dump.command.ts) | | `db push` | `ported` | [`../src/legacy/commands/db/push/push.command.ts`](../src/legacy/commands/db/push/push.command.ts) | | `db pull` | `ported` | [`../src/legacy/commands/db/pull/pull.command.ts`](../src/legacy/commands/db/pull/pull.command.ts) — native pg-delta / migra; `--declarative` (deprecated alias `--use-pg-delta`) + `--diff-engine` (migra\|pg-delta); `--experimental` / initial `pg_dump` delegate to Go | -| `db reset` | `wrapped` | [`../src/legacy/commands/db/reset/reset.command.ts`](../src/legacy/commands/db/reset/reset.command.ts) | +| `db reset` | `partial` | [`../src/legacy/commands/db/reset/reset.command.ts`](../src/legacy/commands/db/reset/reset.command.ts) — remote path native (drop + vault + MigrateAndSeed); local + `--experimental` delegate to the Go binary pending the container-bootstrap seam | | `db lint` | `ported` | [`../src/legacy/commands/db/lint/lint.command.ts`](../src/legacy/commands/db/lint/lint.command.ts) | | `db start` | `wrapped` | [`../src/legacy/commands/db/start/start.command.ts`](../src/legacy/commands/db/start/start.command.ts) | | `db query` | `ported` | [`../src/legacy/commands/db/query/query.command.ts`](../src/legacy/commands/db/query/query.command.ts) | diff --git a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md index a2c24f24d2..fe047346dd 100644 --- a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md @@ -1,57 +1,100 @@ # `supabase db reset` +Native TypeScript port of `apps/cli-go/internal/db/reset/reset.go`. Reinitialises a +database from local migrations (plus seed). The **remote** path (`--linked`, or a +remote `--db-url`) is native: drop all user schemas, upsert vault secrets, then +re-apply migrations and seed. The **local** path (`--local`/default, or a `--db-url` +pointing at the local stack) and the niche `--experimental` schema-files path +delegate to the bundled Go binary — an interim until the container-bootstrap seam +is ported (CLI-1325 Stage 3). + ## Files Read -| Path | Format | When | -| -------------------------------- | ---------- | ------------------------------------------------- | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | -| `/supabase/migrations/` | directory | always, to load migration files | -| seed files from config | SQL | unless `--no-seed` is set | +| Path | Format | When | +| -------------------------------- | ---------- | --------------------------------------------------------------- | +| `/supabase/migrations/` | directory | to validate `--version` / resolve `--last`, and to load migrations | +| `/supabase/config.toml` | TOML | remote path (embedded defaults when absent) | +| `~/.supabase//project-ref` | plain text | `--linked`, to resolve the ref | +| `~/.supabase/access-token` | plain text | `--linked`, when `SUPABASE_ACCESS_TOKEN` unset and a temp role is minted | +| seed files from `[db.seed].sql_paths` | SQL | remote path, when `[db.seed].enabled` and not `--no-seed` | ## Files Written | Path | Format | When | | ---- | ------ | ---- | -| — | — | — | +| `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | +| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | + +The local / experimental paths additionally produce whatever the delegated Go +binary writes (container volumes, `_current_branch`, etc.). + +## Database Mutations (remote path) + +| Statement | When | +| --------- | ---- | +| `drop.sql` `DO` block (drops user schemas/extensions/public objects, truncates auth/migrations) | always, first | +| `SELECT vault.update_secret(...)` / `vault.create_secret(...)` | when `[db.vault]` has syncable secrets | +| migration statements + `schema_migrations` history insert (per file, transactional) | when `[db.migrations].enabled`, for migrations `≤ --version` | +| seed statements + `seed_files` hash upsert | when `[db.seed].enabled` and not `--no-seed` | ## API Routes | Method | Path | Auth | Request body | Response (used fields) | | ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | +| — | — | — | — | Connects to Postgres directly. The `--linked` db-config resolver may call the Management API to mint a temporary login role. | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | --------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` mode | no (falls back to keyring → `~/.supabase/access-token`) | -| `DB_PASSWORD` | password for direct database connection | no | +| Variable | Purpose | Required? | +| ----------------------- | --------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no | +| `SUPABASE_YES` | auto-confirm the reset prompt | no (also `--yes`) | +| `SUPABASE_EXPERIMENTAL` | routes the experimental schema-files path to Go | no (also `--experimental`) | ## Exit Codes -| Code | Condition | -| ---- | --------------------------- | -| `0` | success | -| `1` | database connection failure | -| `1` | migration apply error | +| Code | Condition | +| ---- | ---------------------------------------------------------------- | +| `0` | success | +| `1` | mutually exclusive target flags (`[db-url linked local]`) | +| `1` | `--version` + `--last` together (`[last version]`) | +| `1` | `--version` not an integer (`invalid version number`) | +| `1` | `--version` has no matching migration file | +| `1` | user declined the reset confirmation (`context canceled`) | +| `1` | `config.toml` parse failure | +| `1` | drop / migrate / seed / vault apply failure, or connection error | ## Output +The remote path prints `Resetting remote database…` to **stderr**, then the +drop/migrate/seed progress (`Applying migration …`, `Seeding data from …`). Unlike +`db push`, Go connects with `io.Discard`, so there is **no** `Connecting to … +database…` line and **no** `Finished …` line. + ### `--output-format text` (Go CLI compatible) -Prints progress to stderr as migrations are applied. +Byte-matches Go's stderr progress for the remote path. The local / experimental +paths pass the delegated Go binary's output through unchanged. -### `--output-format json` +### `--output-format json` / `stream-json` -Not applicable. +stdout is payload-only; on a confirmed remote reset a `result` object is emitted: -### `--output-format stream-json` +```json +{ "target": "remote", "version": "" } +``` -Not applicable. +In machine modes the confirmation prompt is non-interactive and takes its default +(`false`), so a remote reset is declined unless `--yes` is set. ## Notes -- `--no-seed` skips running the seed script after reset. -- `--version` resets up to the specified migration version. -- `--last` resets up to the last n migration versions; mutually exclusive with `--version`. -- `--db-url`, `--linked`, and `--local` (default true) are mutually exclusive. +- **Target/local split** follows Go's `IsLocalDatabase(resolved config)`, not the + flag name: a `--db-url` pointing at the local stack is treated as a local reset + and delegated. +- `--no-seed` forces seeding off (Go sets `Config.Db.Seed.Enabled = false`). +- `--last n` reverts the most recent `n` migrations; if `n ≥ total`, the reset + target version becomes `-` (revert everything). +- **Known interim**: local `db reset` and `--experimental` remote resets run via the + Go binary; the best-effort pg-delta catalog cache is not ported (no output impact). From e3f91686fddc01117b1ee0271ac790ebe125bb1e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 08:43:41 +0000 Subject: [PATCH 10/29] docs(cli): add CLI-1325 Stage 3 handoff for db start / db reset --local Self-contained handoff covering the container-bootstrap work remaining (hidden __db-bootstrap Go seam + native orchestration), exact Go behavior to match, reusable helpers, build/test commands, and parity gotchas. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nv8pAJ695qfNs5Btbv5v7h --- apps/cli/docs/CLI-1325-STAGE3-HANDOFF.md | 286 +++++++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 apps/cli/docs/CLI-1325-STAGE3-HANDOFF.md diff --git a/apps/cli/docs/CLI-1325-STAGE3-HANDOFF.md b/apps/cli/docs/CLI-1325-STAGE3-HANDOFF.md new file mode 100644 index 0000000000..2e5e26b56f --- /dev/null +++ b/apps/cli/docs/CLI-1325-STAGE3-HANDOFF.md @@ -0,0 +1,286 @@ +# CLI-1325 — Stage 3 Handoff (`db start` + `db reset --local`) + +> **Purpose**: hand off the remaining work on CLI-1325 ("Port `supabase db reset` / +> `supabase db push` / `supabase db start`") to a local agent that has Docker and +> can build the Go binary. Stages 1–2 are done and on this branch +> (`claude/gifted-knuth-mslxnh`). Stage 3 is the container-bootstrap work that +> could not be validated in the cloud environment. + +--- + +## 1. Context + +- **Repo**: `supabase/cli`. Branch: `claude/gifted-knuth-mslxnh`. +- **What this is**: a strict **1:1 port** of the Go CLI into the TypeScript + **legacy shell** at `apps/cli/src/legacy/`. The authoritative reference is the + vendored Go source at `apps/cli-go/`. Match Go's stdout/stderr text, flags, + filesystem effects, API routes, and exit codes **exactly**. +- **Read first**: `apps/cli/CLAUDE.md` (the legacy-port playbook — naming, telemetry + parity, `--output-format`, SIDE_EFFECTS, testing rules) and the repo-root + `CLAUDE.md`. +- **Chosen architecture for Stage 3** (decided with the issue owner): a **hidden Go + seam**. Native TS orchestrates; container provisioning that isn't ported stays in + Go behind a hidden `__db-bootstrap` command, mirroring the existing `__shadow` + seam at `apps/cli-go/cmd/db.go:219`. The TS side shells out to it via + `LegacyGoProxy`. + +--- + +## 2. What is already done (Stages 1–2, on this branch) + +| Command | Status | Where | +| --- | --- | --- | +| `db push` | **ported** (fully native) | `apps/cli/src/legacy/commands/db/push/` | +| `db reset` | **partial** — remote path native; local + `--experimental` delegate to Go | `apps/cli/src/legacy/commands/db/reset/` | +| `db start` | **wrapped** (Go proxy, untouched) | `apps/cli/src/legacy/commands/db/start/` | + +Commits (newest first): `docs(cli): document db reset…`, `feat(cli): implement +native db reset remote path`, `docs(cli): document db push…`, `test(cli): expand db +push coverage…`, `test(cli): integration tests for native db push`, `feat(cli): +implement native db push handler`, `feat(cli): add vault upsert…`, `feat(cli): add +seed-file ops…`, `feat(cli): add pending-migration reconciliation…`. + +### Reusable shared helpers already built (use these — do not re-implement) + +All under `apps/cli/src/legacy/`: + +- `commands/db/shared/legacy-migration-pending.ts` — `legacyFindPendingMigrations`, + `legacyIncludeAllPending`, `legacySuggestRevertHistory`, `legacySuggestIgnoreFlag`. +- `commands/db/shared/legacy-seed-ops.ts` — `legacyGetPendingSeeds`, + `legacySeedData`, `LegacySeedFile`, `legacyMatchPattern` (Go `fs.Glob`/`path.Match` + port). Seed paths resolve under `supabase/` and dedupe like Go's `config.Glob.Files`. +- `commands/db/shared/legacy-vault.ts` — `legacyUpsertVaultSecrets`, + `legacyReadVaultDocument`, `legacySyncableVaultSecrets`. **Gap**: `encrypted:` + vault secrets are skipped (ECIES/dotenvx decryption not ported). +- `commands/db/shared/legacy-drop-schemas.ts` — `legacyDropUserSchemas` (embedded + `drop.sql` `DO` block). +- `shared/legacy-migration-apply.ts` — `legacyApplyMigrations` (emits + `Applying migration ...`), `legacySeedGlobals`, `legacyApplyMigrationFile`. +- `commands/db/shared/legacy-pgdelta.cache.ts` — `legacyListLocalMigrations` + (Go-faithful local migration listing, with the deprecated-init skip). + +### Existing Docker / container infra in the legacy shell (for Stage 3) + +- `shared/legacy-docker-run.service.ts` + `.layer.ts` — `LegacyDockerRun` + (`run`/`runCapture`/`runStream`). +- `shared/legacy-container-cli.ts` — `LegacyContainerCli`. +- `shared/legacy-docker-registry.ts` — `LegacyDockerRegistry`. +- `shared/legacy/go-proxy.service.ts` — `LegacyGoProxy` (`exec`, `execCapture`), + **ambient** (provided at root in `legacy/cli/root.ts`). + +### Patterns established in Stages 1–2 (copy these) + +- Command file wires `withLegacyCommandInstrumentation({ flags, safeFlags? })` + + `withJsonErrorHandling` + `Command.provide()`. +- Runtime layers mirror `commands/db/push/push.layers.ts` (and `lint.layers.ts`): + lazy `legacyPlatformApiFactoryLayer` so `--local`/`--db-url` never resolve a token + at layer-build time; single shared `legacyIdentityStitchLayer`. +- Handler body wrapped in `.pipe(Effect.ensuring(linkedProjectCache.cache(ref)), + Effect.ensuring(telemetryState.flush))`. +- **Delegating to Go without double-counting telemetry**: call + `proxy.exec(args, { env: { SUPABASE_TELEMETRY_DISABLED: "1" } })`. The TS + instrumentation wrapper then fires `cli_command_executed` exactly once. This is + how `db reset`'s local/experimental paths already work + (`reset.handler.ts:147,154`). + +--- + +## 3. Stage 3 scope + +Make `db start` native, and replace `db reset --local`'s Go delegation +(`reset.handler.ts:146-149`) with a native local reset. Both lean on a new hidden +Go seam for the parts that aren't ported (container create/recreate, init schema, +service restarts). + +### 3a. Go behavior to match — `db start` (`apps/cli-go/internal/db/start/start.go`) + +Entry: `cmd/db.go:337` → `start.Run(ctx, fromBackup, fsys)`. Flag: `--from-backup` +(string, `cmd/db.go:590`). `Run` (lines 44-61): + +1. `flags.LoadConfig(fsys)`. +2. `AssertSupabaseDbIsRunning()`: if already running → `fmt.Fprintln(os.Stderr, + "Postgres database is already running.")` and **return nil** (exit 0). If the + error is anything other than `utils.ErrNotRunning`, return it. +3. `StartDatabase(ctx, fromBackup, fsys, os.Stderr)`; on error, + `utils.DockerRemoveAll(...)` cleanup then return the error. + +`StartDatabase` (lines 133-190): builds container/host/network config +(`NewContainerConfig`/`NewHostConfig`), handles `--from-backup` (restore entrypoint ++ bind mount `/etc/backup.sql:ro`), inspects the db volume to set +`utils.NoBackupVolume`, then: +- `NoBackupVolume` → `Starting database...`; else if `--from-backup` → + `Starting database from backup...` (lines 168-174; both to the writer `w` = + stderr). +- `WaitForHealthyService(ctx, Config.Db.HealthTimeout, utils.DbId)` — health check + **skipped** when `--from-backup` is set (line 180). +- If `NoBackupVolume && no --from-backup` → `SetupLocalDatabase(ctx, "", fsys, w)` + (line 185), which prints `Initialising schema...` (line 244) and applies initial + schema + roles + migrations + seed. +- `initCurrentBranch(fsys)` (line 189) — writes the `_current_branch` file. + +**Important parity facts**: `db start` does **NOT** print the full status table and +does **NOT** fire `cli_stack_started` — those belong to the top-level `supabase +start` (`internal/start/start.go`), not `db start`. No `Finished` line. + +### 3b. Go behavior to match — `db reset` local (`apps/cli-go/internal/db/reset/reset.go`) + +The local path is reached when `utils.IsLocalDatabase(config)` is true (Go +`reset.go:53`). In the TS handler this is the `cfg.isLocal` branch currently +delegating to Go (`reset.handler.ts:146`). Version/`--last` resolution +(`reset.go:34-52`) is **already ported** and runs before the split — reuse it. + +Local flow (`reset.go:57-77`): +1. `AssertSupabaseDbIsRunning()` — error if the db container isn't up. +2. `resetDatabase(ctx, version, fsys)` → `Resetting local database` + (line 81), then branch on `Config.Db.MajorVersion`: + - **≤ 14** → `resetDatabase14` (line 95): `recreateDatabase` (drop/recreate + `postgres` + `_supabase` dbs), `initDatabase`, `RestartDatabase` + (`Restarting containers...`), connect, `apply.MigrateAndSeed(ctx, version, + conn, fsys)`. + - **≥ 15** → `resetDatabase15` (line 113): `Docker.ContainerRemove(DbId, Force)`, + `Docker.VolumeRemove(DbId, force)`, `Recreating database...` (line 129), + recreate container via `DockerStart(NewContainerConfig()/NewHostConfig())`, + wait healthy, `start.SetupLocalDatabase(ctx, version, fsys)`, + `Restarting containers...` (line 139), `restartServices(ctx)` (line 140). +3. If the storage container is healthy → `buckets.Run(ctx, "", false, fsys)` to seed + `supabase/buckets/` (reset.go:65-74). The **legacy `seed buckets` command is + already ported** (`commands/seed/buckets/`) — reuse `legacySeedBuckets`/its core. +4. `branch := utils.GetGitBranch(fsys)`; `Finished supabase db reset on branch + .` to stderr (line 76; `supabase db reset` and `` are Aqua). + +`restartServices` (reset.go:226-240) restarts `[StorageId, GotrueId, RealtimeId, +PoolerId]`. `apply.MigrateAndSeed` is the same routine `db reset` remote uses — the +TS equivalent (drop is NOT done locally; instead the db is recreated) is +`legacyApplyMigrations` (partial migrations) + `legacyGetPendingSeeds` + +`legacySeedData`, already wired in `reset.handler.ts` for the remote path. Factor +that "migrate + seed against a connected session" block into a shared helper so both +the remote and local paths call it. + +### 3c. The hidden `__db-bootstrap` Go seam + +Mirror `__shadow` (`apps/cli-go/cmd/db.go:219-268`). Add a hidden command that +exposes the un-ported container primitives so the TS side can invoke them and then +do the SQL orchestration itself (connect, migrate, seed, restart). Suggested +sub-operations (drive via a `--mode` flag like `__shadow`): +- `start` → `start.StartDatabase(fromBackup)` (create + health + `SetupLocalDatabase` + + `initCurrentBranch`). +- `recreate` → `reset.resetDatabase15` container remove/volume-remove/recreate + + `SetupLocalDatabase(version)` (the PG15 path), or `resetDatabase14` for PG≤14. +- `restart-services` → `reset.restartServices`. +- An "is running" probe so the TS side can replicate `AssertSupabaseDbIsRunning` + without porting Docker inspect (or use `LegacyDockerRun`/`LegacyContainerCli`). + +Decide how much SQL stays native vs behind the seam. The thinnest correct split: +the seam does **only** container lifecycle + `SetupLocalDatabase` (initial schema / +roles / first migrate+seed), and the TS side does the "already running?" check, +user-facing messages, the bucket seeding (reuse `legacySeedBuckets`), the +git-branch `Finished …` line, and `--output-format` shaping. Keep the seam's stdout +machine-parseable (newline-separated, no secrets) like `__shadow`. + +When you add the seam: update the three `__shadow`-style steps in `cmd/db.go`, +rebuild the bundled Go binary (`apps/cli/scripts` / `pnpm --filter @supabase/cli +build:go-sidecar` — check `apps/cli/package.json` scripts), and confirm +`LegacyGoProxy` can reach it. + +--- + +## 4. Deliverables checklist (Stage 3) + +- [ ] `db start` native handler + `start.layers.ts` + `start.errors.ts`; replace the + proxy in `commands/db/start/`. Match every string in §3a. No `cli_stack_started`. +- [ ] `db reset` local path native in `reset.handler.ts` (replace the `cfg.isLocal` + Go delegation); reuse the shared migrate+seed block and `legacySeedBuckets`. +- [ ] Hidden `__db-bootstrap` Go command in `apps/cli-go/cmd/db.go`; rebuild the + bundled binary. +- [ ] Shared helper for "migrate + seed against a connected session" (extract from + the remote reset path so local + remote share it). +- [ ] `commands/db/start/SIDE_EFFECTS.md` (rewrite from proxy stub) and update + `commands/db/reset/SIDE_EFFECTS.md` for the now-native local path. +- [ ] Integration tests (handler, ~100% branch — see precedent: one unreachable + defensive guard is acceptable, as in push/reset). Mock `LegacyDockerRun` / + the seam / `LegacyDbConnection`. +- [ ] **E2E** (`*.e2e.test.ts`) golden paths via `tests/helpers/cli.ts` `runSupabase` + against a **real local stack** — this is the part requiring Docker. Cover + `db start` (fresh + already-running) and `db reset` local. +- [ ] Flip `db start` → `ported` and `db reset` → `ported` in + `apps/cli/docs/go-cli-porting-status.md` (two tables: the leaf table ~line 89-91 + and the status table ~line 303-307). +- [ ] Telemetry: confirm no custom events for `db start`/`db reset` (none in Go); + keep `withLegacyCommandInstrumentation`. + +--- + +## 5. How to build / test / verify in this repo + +`nx` is **not** on PATH; invoke tools directly from `apps/cli/`: + +```sh +# from repo root, once: +pnpm install # (and `pnpm repos:install` if .repos/effect is missing) + +# from apps/cli/ : +# typecheck (tsgo, the repo's TS checker): +./node_modules/.bin/tsgo --noEmit -p tsconfig.json + +# tests MUST run under the Bun runtime (they import @effect/platform-bun): +bun --bun ./node_modules/vitest/vitest.mjs run --project unit +bun --bun ./node_modules/vitest/vitest.mjs run --project integration +# coverage (istanbul) for a handler — aim ~100% branch: +bun --bun ./node_modules/vitest/vitest.mjs run --project integration \ + --coverage --coverage.reporter=json --coverage.reportsDirectory=/tmp/cov \ + --coverage.include='src/legacy/commands/db/start/start.handler.ts' + +# e2e (needs Docker; do not run the full suite — target the file): +bun --bun ./node_modules/vitest/vitest.mjs run --project e2e .e2e.test.ts + +# lint / format / unused-exports: +./node_modules/.bin/oxfmt # writes; add --check to verify +./node_modules/.bin/oxlint +./node_modules/.bin/knip # de-export internal-only helpers it flags +``` + +The canonical full gate (CLAUDE.md): `bun run test` + `bun run --parallel "*:check"` +(these go through `nx`; if `nx` is unavailable, run the direct commands above). + +--- + +## 6. Gotchas / parity rules learned in Stages 1–2 + +- **Bun runtime for tests**: plain `pnpm vitest` fails (`Cannot find package 'bun'`); + always `bun --bun ./node_modules/vitest/vitest.mjs`. +- **Telemetry double-count**: any Go delegation must pass + `env: { SUPABASE_TELEMETRY_DISABLED: "1" }` so the child doesn't also emit + `cli_command_executed`. +- **stdout vs stderr**: progress/diagnostics → stderr; the only stdout text Go emits + for these commands is suppressed in `json`/`stream-json` mode (emit a structured + `output.success(...)` instead). See CLI-1546 notes in `apps/cli/CLAUDE.md`. +- **Colors**: match Go's `utils.Aqua` (cyan) / `utils.Bold` via `legacy-colors.ts` + (`legacyAqua`, `legacyBold`). Tests assert with `toContain` to tolerate ANSI. +- **`db start` connects via `io.Discard` in `db reset` remote** — note `db start` + itself writes progress to stderr; double-check each `Fprintln(w, …)` target. +- **Coverage**: handler integration tests target 100% branch; a single genuinely + unreachable defensive guard is acceptable (push and reset each have one). + Relocate defensive parsing into unit-tested pure helpers where practical (see how + `legacyReadVaultDocument` was moved into `legacy-vault.ts`). +- **Config access**: use `loadProjectConfig(workdir, { projectRef? })` from + `@supabase/config`; `config.db.{migrations.enabled, seed.enabled, seed.sql_paths}`; + raw `[db.vault]` via the returned `document`. `MajorVersion` is at + `config.db.major_version` — needed for the PG14 vs PG15 reset branch. +- **`--no-seed`**: forces seed disabled (Go sets `Config.Db.Seed.Enabled = false`). + +--- + +## 7. Key Go reference files (read these on the local machine) + +- `apps/cli-go/cmd/db.go` — flag defs + `__shadow` seam to mirror (lines ~219-268, + 337-343, 567-591). +- `apps/cli-go/internal/db/start/start.go` — `Run`, `StartDatabase`, + `SetupLocalDatabase`, `WaitForHealthyService`, `initCurrentBranch`, + `NewContainerConfig`/`NewHostConfig`. +- `apps/cli-go/internal/db/reset/reset.go` — local `resetDatabase14`/`15`, + `recreateDatabase`, `initDatabase`, `RestartDatabase`, `restartServices`, + `toLogMessage`. +- `apps/cli-go/internal/migration/apply/apply.go` — `MigrateAndSeed` (already + mirrored in the TS remote reset path). +- `apps/cli-go/internal/seed/buckets/buckets.go` — bucket seeding invoked by local + reset (TS port exists at `commands/seed/buckets/`). From 9f547c5854faf6b0f0ffd1da6aef43800f206202 Mon Sep 17 00:00:00 2001 From: avallete Date: Thu, 25 Jun 2026 13:15:44 +0200 Subject: [PATCH 11/29] feat(cli): implement native db start via hidden Go bootstrap seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port `supabase db start` to a native TypeScript handler. The handler validates config, runs Go's `AssertSupabaseDbIsRunning` check (printing the "already running" line), and otherwise delegates the container bootstrap to a new hidden Go `db __db-bootstrap` seam that mirrors `db __shadow`: it exposes the un-ported container primitives (StartDatabase + DockerRemoveAll cleanup, the PG14/PG15 reset recreate, and the storage health gate) behind `--mode`. No status table and no `cli_stack_started` event — those belong to the top-level `supabase start`, not `db start`. `--output-format json` emits a structured `{ status }` result; progress stays on stderr. The TS seam service/layer (`legacy-db-bootstrap.seam.*`) shells out to the bundled binary with telemetry disabled and stderr inherited. The recreate / await-storage modes are landed here for the upcoming native db reset --local. Co-Authored-By: Claude Opus 4.8 --- apps/cli-go/cmd/db.go | 64 ++++++ apps/cli-go/internal/db/reset/reset.go | 32 +++ apps/cli/docs/CLI-1325-STAGE3-HANDOFF.md | 34 ++-- apps/cli/docs/go-cli-porting-status.md | 92 ++++----- .../legacy/commands/db/push/SIDE_EFFECTS.md | 62 +++--- .../legacy/commands/db/reset/SIDE_EFFECTS.md | 50 ++--- .../db/shared/legacy-db-bootstrap.errors.ts | 14 ++ .../shared/legacy-db-bootstrap.seam.layer.ts | 180 +++++++++++++++++ .../legacy-db-bootstrap.seam.service.ts | 63 ++++++ .../legacy/commands/db/start/SIDE_EFFECTS.md | 67 ++++-- .../legacy/commands/db/start/start.command.ts | 16 +- .../legacy/commands/db/start/start.errors.ts | 10 + .../legacy/commands/db/start/start.handler.ts | 72 ++++++- .../db/start/start.integration.test.ts | 190 ++++++++++++++++++ .../legacy/commands/db/start/start.layers.ts | 27 +++ 15 files changed, 834 insertions(+), 139 deletions(-) create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.errors.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts create mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.service.ts create mode 100644 apps/cli/src/legacy/commands/db/start/start.errors.ts create mode 100644 apps/cli/src/legacy/commands/db/start/start.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/db/start/start.layers.ts diff --git a/apps/cli-go/cmd/db.go b/apps/cli-go/cmd/db.go index 3f8d3d82a8..3f46802228 100644 --- a/apps/cli-go/cmd/db.go +++ b/apps/cli-go/cmd/db.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "fmt" "os" "path/filepath" @@ -267,6 +268,63 @@ var ( }, } + bootstrapMode string + bootstrapFromBackup string + bootstrapVersion string + + // dbBootstrapCmd is a hidden seam used by the native-TypeScript `db start` and + // `db reset --local` commands to drive the container-bootstrap primitives that + // are not yet ported to TypeScript: creating/recreating the local Postgres + // container, applying the initial schema, and the storage health gate. The TS + // caller orchestrates everything else (the "already running?" check and its + // message, version/last resolution, bucket seeding, the git-branch "Finished…" + // line, telemetry, and --output-format shaping); the seam stays in Go only for + // the Docker lifecycle. It mirrors the existing db __shadow seam: it carries no + // db-url/local/linked target flags, so it loads supabase/config.toml explicitly + // (the root PersistentPreRunE only loads it when a target flag is set). Progress + // goes to stderr; the only stdout output is a single machine-parseable marker + // for --mode await-storage ("ready" or "absent"). + dbBootstrapCmd = &cobra.Command{ + Use: "__db-bootstrap", + Hidden: true, + Short: "Internal: container bootstrap for the native db start / db reset commands", + RunE: func(cmd *cobra.Command, args []string) error { + fsys := afero.NewOsFs() + if err := flags.LoadConfig(fsys); err != nil { + return err + } + switch bootstrapMode { + case "start": + // Mirror start.Run minus the "already running?" check, which the TS + // caller performs (and prints "Postgres database is already running."). + if err := start.StartDatabase(cmd.Context(), bootstrapFromBackup, fsys, os.Stderr); err != nil { + if rmErr := utils.DockerRemoveAll(context.Background(), os.Stderr, utils.Config.ProjectId); rmErr != nil { + fmt.Fprintln(os.Stderr, rmErr) + } + return err + } + return nil + case "recreate": + // The PG14/PG15 container-recreate half of local db reset. The TS + // caller has already printed "Resetting local database…". + return reset.RecreateLocalDatabase(cmd.Context(), bootstrapVersion, fsys) + case "await-storage": + ready, err := reset.AwaitStorageReady(cmd.Context()) + if err != nil { + return err + } + if ready { + fmt.Println("ready") + } else { + fmt.Println("absent") + } + return nil + default: + return fmt.Errorf("unknown bootstrap mode: %s", bootstrapMode) + } + }, + } + dbRemoteCmd = &cobra.Command{ Hidden: true, Use: "remote", @@ -553,6 +611,12 @@ func init() { shadowFlags.StringSliceVarP(&shadowSchema, "schema", "s", []string{}, "Comma separated list of schema to include.") shadowFlags.StringVar(&shadowProjectRef, "project-ref", "", "Linked project ref, so the shadow merges the matching [remotes.] config override.") dbCmd.AddCommand(dbShadowCmd) + // Build hidden container-bootstrap seam command (native db start / db reset) + bootstrapFlags := dbBootstrapCmd.Flags() + bootstrapFlags.StringVar(&bootstrapMode, "mode", "start", "Bootstrap mode: start, recreate, or await-storage.") + bootstrapFlags.StringVar(&bootstrapFromBackup, "from-backup", "", "Path to a logical backup file (start mode).") + bootstrapFlags.StringVar(&bootstrapVersion, "version", "", "Reset up to the specified version (recreate mode).") + dbCmd.AddCommand(dbBootstrapCmd) // Build remote command remoteFlags := dbRemoteCmd.PersistentFlags() remoteFlags.StringSliceVarP(&schema, "schema", "s", []string{}, "Comma separated list of schema to include.") diff --git a/apps/cli-go/internal/db/reset/reset.go b/apps/cli-go/internal/db/reset/reset.go index 7d841f4ba3..765153d6d7 100644 --- a/apps/cli-go/internal/db/reset/reset.go +++ b/apps/cli-go/internal/db/reset/reset.go @@ -92,6 +92,38 @@ func toLogMessage(version string) string { return "..." } +// RecreateLocalDatabase is the container-lifecycle half of a local `db reset`, +// exposed for the native-TypeScript `db reset --local` seam (cmd db __db-bootstrap). +// It performs the PG14/PG15 branch — recreate the db container/volume, init schema, +// migrate + seed, and restart the satellite containers — WITHOUT the leading +// "Resetting local database…" line, which the TS caller prints itself. Mirrors +// resetDatabase (above) minus that message. +func RecreateLocalDatabase(ctx context.Context, version string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { + if utils.Config.Db.MajorVersion <= 14 { + return resetDatabase14(ctx, version, fsys, options...) + } + return resetDatabase15(ctx, version, fsys, options...) +} + +// AwaitStorageReady mirrors the storage-health gate that local `db reset` runs +// before seeding buckets (Run, above): if the storage container exists but is not +// healthy, wait up to 30s for it. It reports whether the storage container exists +// so the native-TypeScript caller knows whether to run the (already-ported) bucket +// seeding. Any inspect error is treated as "storage not running" → false, matching +// Go's `err == nil` gate, which silently skips buckets on any inspect failure. +func AwaitStorageReady(ctx context.Context) (bool, error) { + resp, err := utils.Docker.ContainerInspect(ctx, utils.StorageId) + if err != nil { + return false, nil + } + if resp.State.Health == nil || resp.State.Health.Status != types.Healthy { + if err := start.WaitForHealthyService(ctx, 30*time.Second, utils.StorageId); err != nil { + return false, err + } + } + return true, nil +} + func resetDatabase14(ctx context.Context, version string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { if err := recreateDatabase(ctx, options...); err != nil { return err diff --git a/apps/cli/docs/CLI-1325-STAGE3-HANDOFF.md b/apps/cli/docs/CLI-1325-STAGE3-HANDOFF.md index 2e5e26b56f..5e66277d65 100644 --- a/apps/cli/docs/CLI-1325-STAGE3-HANDOFF.md +++ b/apps/cli/docs/CLI-1325-STAGE3-HANDOFF.md @@ -28,11 +28,11 @@ ## 2. What is already done (Stages 1–2, on this branch) -| Command | Status | Where | -| --- | --- | --- | -| `db push` | **ported** (fully native) | `apps/cli/src/legacy/commands/db/push/` | +| Command | Status | Where | +| ---------- | ------------------------------------------------------------------------- | ---------------------------------------- | +| `db push` | **ported** (fully native) | `apps/cli/src/legacy/commands/db/push/` | | `db reset` | **partial** — remote path native; local + `--experimental` delegate to Go | `apps/cli/src/legacy/commands/db/reset/` | -| `db start` | **wrapped** (Go proxy, untouched) | `apps/cli/src/legacy/commands/db/start/` | +| `db start` | **wrapped** (Go proxy, untouched) | `apps/cli/src/legacy/commands/db/start/` | Commits (newest first): `docs(cli): document db reset…`, `feat(cli): implement native db reset remote path`, `docs(cli): document db push…`, `test(cli): expand db @@ -76,7 +76,7 @@ All under `apps/cli/src/legacy/`: lazy `legacyPlatformApiFactoryLayer` so `--local`/`--db-url` never resolve a token at layer-build time; single shared `legacyIdentityStitchLayer`. - Handler body wrapped in `.pipe(Effect.ensuring(linkedProjectCache.cache(ref)), - Effect.ensuring(telemetryState.flush))`. +Effect.ensuring(telemetryState.flush))`. - **Delegating to Go without double-counting telemetry**: call `proxy.exec(args, { env: { SUPABASE_TELEMETRY_DISABLED: "1" } })`. The TS instrumentation wrapper then fires `cli_command_executed` exactly once. This is @@ -99,24 +99,26 @@ Entry: `cmd/db.go:337` → `start.Run(ctx, fromBackup, fsys)`. Flag: `--from-bac 1. `flags.LoadConfig(fsys)`. 2. `AssertSupabaseDbIsRunning()`: if already running → `fmt.Fprintln(os.Stderr, - "Postgres database is already running.")` and **return nil** (exit 0). If the +"Postgres database is already running.")` and **return nil** (exit 0). If the error is anything other than `utils.ErrNotRunning`, return it. 3. `StartDatabase(ctx, fromBackup, fsys, os.Stderr)`; on error, `utils.DockerRemoveAll(...)` cleanup then return the error. `StartDatabase` (lines 133-190): builds container/host/network config (`NewContainerConfig`/`NewHostConfig`), handles `--from-backup` (restore entrypoint -+ bind mount `/etc/backup.sql:ro`), inspects the db volume to set -`utils.NoBackupVolume`, then: -- `NoBackupVolume` → `Starting database...`; else if `--from-backup` → + +- bind mount `/etc/backup.sql:ro`), inspects the db volume to set + `utils.NoBackupVolume`, then: + +* `NoBackupVolume` → `Starting database...`; else if `--from-backup` → `Starting database from backup...` (lines 168-174; both to the writer `w` = stderr). -- `WaitForHealthyService(ctx, Config.Db.HealthTimeout, utils.DbId)` — health check +* `WaitForHealthyService(ctx, Config.Db.HealthTimeout, utils.DbId)` — health check **skipped** when `--from-backup` is set (line 180). -- If `NoBackupVolume && no --from-backup` → `SetupLocalDatabase(ctx, "", fsys, w)` +* If `NoBackupVolume && no --from-backup` → `SetupLocalDatabase(ctx, "", fsys, w)` (line 185), which prints `Initialising schema...` (line 244) and applies initial schema + roles + migrations + seed. -- `initCurrentBranch(fsys)` (line 189) — writes the `_current_branch` file. +* `initCurrentBranch(fsys)` (line 189) — writes the `_current_branch` file. **Important parity facts**: `db start` does **NOT** print the full status table and does **NOT** fire `cli_stack_started` — those belong to the top-level `supabase @@ -130,13 +132,14 @@ delegating to Go (`reset.handler.ts:146`). Version/`--last` resolution (`reset.go:34-52`) is **already ported** and runs before the split — reuse it. Local flow (`reset.go:57-77`): + 1. `AssertSupabaseDbIsRunning()` — error if the db container isn't up. 2. `resetDatabase(ctx, version, fsys)` → `Resetting local database` (line 81), then branch on `Config.Db.MajorVersion`: - **≤ 14** → `resetDatabase14` (line 95): `recreateDatabase` (drop/recreate `postgres` + `_supabase` dbs), `initDatabase`, `RestartDatabase` (`Restarting containers...`), connect, `apply.MigrateAndSeed(ctx, version, - conn, fsys)`. +conn, fsys)`. - **≥ 15** → `resetDatabase15` (line 113): `Docker.ContainerRemove(DbId, Force)`, `Docker.VolumeRemove(DbId, force)`, `Recreating database...` (line 129), recreate container via `DockerStart(NewContainerConfig()/NewHostConfig())`, @@ -146,7 +149,7 @@ Local flow (`reset.go:57-77`): `supabase/buckets/` (reset.go:65-74). The **legacy `seed buckets` command is already ported** (`commands/seed/buckets/`) — reuse `legacySeedBuckets`/its core. 4. `branch := utils.GetGitBranch(fsys)`; `Finished supabase db reset on branch - .` to stderr (line 76; `supabase db reset` and `` are Aqua). +.` to stderr (line 76; `supabase db reset` and `` are Aqua). `restartServices` (reset.go:226-240) restarts `[StorageId, GotrueId, RealtimeId, PoolerId]`. `apply.MigrateAndSeed` is the same routine `db reset` remote uses — the @@ -162,8 +165,9 @@ Mirror `__shadow` (`apps/cli-go/cmd/db.go:219-268`). Add a hidden command that exposes the un-ported container primitives so the TS side can invoke them and then do the SQL orchestration itself (connect, migrate, seed, restart). Suggested sub-operations (drive via a `--mode` flag like `__shadow`): + - `start` → `start.StartDatabase(fromBackup)` (create + health + `SetupLocalDatabase` - + `initCurrentBranch`). + - `initCurrentBranch`). - `recreate` → `reset.resetDatabase15` container remove/volume-remove/recreate + `SetupLocalDatabase(version)` (the PG15 path), or `resetDatabase14` for PG≤14. - `restart-services` → `reset.restartServices`. diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index fb52ad1a30..712c5c6619 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -80,51 +80,51 @@ These commands exist in the TS CLI today but have no direct top-level equivalent ## Database -| Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes | -| --------------------------------- | --------- | -------------------------------------------------- | -------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `db diff` | `ported` | `legacy/commands/db/diff/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra diff via edge-runtime against a Go-seam-provisioned live shadow (`db __shadow`); `--use-pgadmin` / `--use-pg-schema` delegate to the Go binary. | -| `db dump` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `db lint` | `ported` | `legacy/commands/db/lint/` | `n/a` | `n/a` | Native TS port. Runs `plpgsql_check` in a rolled-back transaction via LegacyDbConnection; emits Go-parity pretty JSON. | -| `db pull` | `ported` | `legacy/commands/db/pull/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra migration + `--declarative` pg-delta export; reconciles `schema_migrations`. `--experimental` dump + initial-pull `pg_dump` (migra) delegate to the Go binary. | +| Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes | +| --------------------------------- | --------- | -------------------------------------------------- | -------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `db diff` | `ported` | `legacy/commands/db/diff/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra diff via edge-runtime against a Go-seam-provisioned live shadow (`db __shadow`); `--use-pgadmin` / `--use-pg-schema` delegate to the Go binary. | +| `db dump` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `db lint` | `ported` | `legacy/commands/db/lint/` | `n/a` | `n/a` | Native TS port. Runs `plpgsql_check` in a rolled-back transaction via LegacyDbConnection; emits Go-parity pretty JSON. | +| `db pull` | `ported` | `legacy/commands/db/pull/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra migration + `--declarative` pg-delta export; reconciles `schema_migrations`. `--experimental` dump + initial-pull `pg_dump` (migra) delegate to the Go binary. | | `db push` | `ported` | `legacy/commands/db/push/` | `n/a` | `n/a` | Native TS port. Connects local/linked/`--db-url`; pushes pending migrations, `--include-seed` seeds (`seed_files` hash tracking), `--include-roles`, `[db.vault]` secrets; `--dry-run`. `encrypted:` vault secrets + best-effort pg-delta catalog cache not ported (no output impact). | -| `db reset` | `partial` | `legacy/commands/db/reset/` | `n/a` | `n/a` | Remote path (`--linked` / remote `--db-url`) native: drop user schemas, vault upsert, MigrateAndSeed (partial migrations + seed), `--version`/`--last`. Local reset + `--experimental` schema-files path delegate to the Go binary (telemetry-disabled) pending the container-bootstrap seam (Stage 3). | -| `db start` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `inspect report` | `ported` | `legacy/commands/inspect/report/` | `n/a` | `n/a` | Native TS port. Runs every inspect query via server-side `COPY ... CSV`, writes 14 CSVs under `//`, then renders a Go-parity Glamour rules summary (bounded csvq-subset evaluator; custom `[experimental.inspect.rules]` supported). | -| `inspect db db-stats` | `ported` | `legacy/commands/inspect/db/db-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db replication-slots` | `ported` | `legacy/commands/inspect/db/replication-slots/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db locks` | `ported` | `legacy/commands/inspect/db/locks/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db blocking` | `ported` | `legacy/commands/inspect/db/blocking/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db outliers` | `ported` | `legacy/commands/inspect/db/outliers/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db calls` | `ported` | `legacy/commands/inspect/db/calls/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db index-stats` | `ported` | `legacy/commands/inspect/db/index-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db long-running-queries` | `ported` | `legacy/commands/inspect/db/long-running-queries/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db bloat` | `ported` | `legacy/commands/inspect/db/bloat/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db role-stats` | `ported` | `legacy/commands/inspect/db/role-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db vacuum-stats` | `ported` | `legacy/commands/inspect/db/vacuum-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db table-stats` | `ported` | `legacy/commands/inspect/db/table-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db traffic-profile` | `ported` | `legacy/commands/inspect/db/traffic-profile/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db cache-hit` | `ported` | `legacy/commands/inspect/db/cache-hit/` | `n/a` | `n/a` | Native TS port. Deprecated (use db-stats); routes to the active query. | -| `inspect db index-usage` | `ported` | `legacy/commands/inspect/db/index-usage/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db total-index-size` | `ported` | `legacy/commands/inspect/db/total-index-size/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db index-sizes` | `ported` | `legacy/commands/inspect/db/index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db table-sizes` | `ported` | `legacy/commands/inspect/db/table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db table-index-sizes` | `ported` | `legacy/commands/inspect/db/table-index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db total-table-sizes` | `ported` | `legacy/commands/inspect/db/total-table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db unused-indexes` | `ported` | `legacy/commands/inspect/db/unused-indexes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db table-record-counts` | `ported` | `legacy/commands/inspect/db/table-record-counts/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db seq-scans` | `ported` | `legacy/commands/inspect/db/seq-scans/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db role-configs` | `ported` | `legacy/commands/inspect/db/role-configs/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | -| `inspect db role-connections` | `ported` | `legacy/commands/inspect/db/role-connections/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | -| `migration down` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration fetch` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration list` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration new` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration repair` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration squash` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration up` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `seed buckets` | `ported` | `legacy/commands/seed/buckets/` | `n/a` | `n/a` | Native TS port. Local-only (Go's `seed` defines no `--project-ref`, so the ref is always empty): seeds `[storage.buckets]` + `[storage.vector]` against the local Storage service gateway; remote/analytics paths are unreachable and omitted. `--linked`/`--local` accepted for surface parity (both seed local). Vector graceful-skip WARNINGs ported. | -| `test db` | `ported` | `legacy/commands/test/db/` | `n/a` | `n/a` | Native TS port. `--db-url`/`--local`/`--linked` + variadic paths; runs `supabase/pg_prove:3.36` via `docker run`; pgTAP enable/disable via `@effect/sql-pg`. `--network-id` override and `[images]` config override not modeled (documented divergences). | -| `test new` | `ported` | `legacy/commands/test/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/tests/_test.sql` from the embedded pgtap template; `--template` (pgtap). | +| `db reset` | `partial` | `legacy/commands/db/reset/` | `n/a` | `n/a` | Remote path (`--linked` / remote `--db-url`) native: drop user schemas, vault upsert, MigrateAndSeed (partial migrations + seed), `--version`/`--last`. Local reset + `--experimental` schema-files path delegate to the Go binary (telemetry-disabled) pending the container-bootstrap seam (Stage 3). | +| `db start` | `ported` | `legacy/commands/db/start/` | `n/a` | `n/a` | Native TS port. Validates config, checks "already running" (prints Go's line), else delegates the container bootstrap (create + health + initial schema/roles/migrations/seed + `_current_branch`) to the hidden Go `db __db-bootstrap --mode start` seam. No status table / `cli_stack_started` (those are `supabase start`). `--from-backup` supported. | +| `inspect report` | `ported` | `legacy/commands/inspect/report/` | `n/a` | `n/a` | Native TS port. Runs every inspect query via server-side `COPY ... CSV`, writes 14 CSVs under `//`, then renders a Go-parity Glamour rules summary (bounded csvq-subset evaluator; custom `[experimental.inspect.rules]` supported). | +| `inspect db db-stats` | `ported` | `legacy/commands/inspect/db/db-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db replication-slots` | `ported` | `legacy/commands/inspect/db/replication-slots/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db locks` | `ported` | `legacy/commands/inspect/db/locks/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db blocking` | `ported` | `legacy/commands/inspect/db/blocking/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db outliers` | `ported` | `legacy/commands/inspect/db/outliers/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db calls` | `ported` | `legacy/commands/inspect/db/calls/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db index-stats` | `ported` | `legacy/commands/inspect/db/index-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db long-running-queries` | `ported` | `legacy/commands/inspect/db/long-running-queries/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db bloat` | `ported` | `legacy/commands/inspect/db/bloat/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db role-stats` | `ported` | `legacy/commands/inspect/db/role-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db vacuum-stats` | `ported` | `legacy/commands/inspect/db/vacuum-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db table-stats` | `ported` | `legacy/commands/inspect/db/table-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db traffic-profile` | `ported` | `legacy/commands/inspect/db/traffic-profile/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db cache-hit` | `ported` | `legacy/commands/inspect/db/cache-hit/` | `n/a` | `n/a` | Native TS port. Deprecated (use db-stats); routes to the active query. | +| `inspect db index-usage` | `ported` | `legacy/commands/inspect/db/index-usage/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db total-index-size` | `ported` | `legacy/commands/inspect/db/total-index-size/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db index-sizes` | `ported` | `legacy/commands/inspect/db/index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db table-sizes` | `ported` | `legacy/commands/inspect/db/table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db table-index-sizes` | `ported` | `legacy/commands/inspect/db/table-index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db total-table-sizes` | `ported` | `legacy/commands/inspect/db/total-table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db unused-indexes` | `ported` | `legacy/commands/inspect/db/unused-indexes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db table-record-counts` | `ported` | `legacy/commands/inspect/db/table-record-counts/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db seq-scans` | `ported` | `legacy/commands/inspect/db/seq-scans/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db role-configs` | `ported` | `legacy/commands/inspect/db/role-configs/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | +| `inspect db role-connections` | `ported` | `legacy/commands/inspect/db/role-connections/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | +| `migration down` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `migration fetch` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `migration list` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `migration new` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `migration repair` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `migration squash` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `migration up` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `seed buckets` | `ported` | `legacy/commands/seed/buckets/` | `n/a` | `n/a` | Native TS port. Local-only (Go's `seed` defines no `--project-ref`, so the ref is always empty): seeds `[storage.buckets]` + `[storage.vector]` against the local Storage service gateway; remote/analytics paths are unreachable and omitted. `--linked`/`--local` accepted for surface parity (both seed local). Vector graceful-skip WARNINGs ported. | +| `test db` | `ported` | `legacy/commands/test/db/` | `n/a` | `n/a` | Native TS port. `--db-url`/`--local`/`--linked` + variadic paths; runs `supabase/pg_prove:3.36` via `docker run`; pgTAP enable/disable via `@effect/sql-pg`. `--network-id` override and `[images]` config override not modeled (documented divergences). | +| `test new` | `ported` | `legacy/commands/test/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/tests/_test.sql` from the embedded pgtap template; `--template` (pgtap). | ## Code Generation @@ -302,9 +302,9 @@ Legend: | `db dump` | `ported` | [`../src/legacy/commands/db/dump/dump.command.ts`](../src/legacy/commands/db/dump/dump.command.ts) | | `db push` | `ported` | [`../src/legacy/commands/db/push/push.command.ts`](../src/legacy/commands/db/push/push.command.ts) | | `db pull` | `ported` | [`../src/legacy/commands/db/pull/pull.command.ts`](../src/legacy/commands/db/pull/pull.command.ts) — native pg-delta / migra; `--declarative` (deprecated alias `--use-pg-delta`) + `--diff-engine` (migra\|pg-delta); `--experimental` / initial `pg_dump` delegate to Go | -| `db reset` | `partial` | [`../src/legacy/commands/db/reset/reset.command.ts`](../src/legacy/commands/db/reset/reset.command.ts) — remote path native (drop + vault + MigrateAndSeed); local + `--experimental` delegate to the Go binary pending the container-bootstrap seam | +| `db reset` | `partial` | [`../src/legacy/commands/db/reset/reset.command.ts`](../src/legacy/commands/db/reset/reset.command.ts) — remote path native (drop + vault + MigrateAndSeed); local + `--experimental` delegate to the Go binary pending the container-bootstrap seam | | `db lint` | `ported` | [`../src/legacy/commands/db/lint/lint.command.ts`](../src/legacy/commands/db/lint/lint.command.ts) | -| `db start` | `wrapped` | [`../src/legacy/commands/db/start/start.command.ts`](../src/legacy/commands/db/start/start.command.ts) | +| `db start` | `ported` | [`../src/legacy/commands/db/start/start.command.ts`](../src/legacy/commands/db/start/start.command.ts) — native; container bootstrap via the hidden Go `db __db-bootstrap` seam | | `db query` | `ported` | [`../src/legacy/commands/db/query/query.command.ts`](../src/legacy/commands/db/query/query.command.ts) | | `db advisors` | `ported` | [`../src/legacy/commands/db/advisors/advisors.command.ts`](../src/legacy/commands/db/advisors/advisors.command.ts) | | `db test` | `wrapped` | [`../src/legacy/commands/db/test/test.command.ts`](../src/legacy/commands/db/test/test.command.ts) | diff --git a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md index cd2f58feec..5216459f76 100644 --- a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md @@ -6,48 +6,48 @@ linked/remote Postgres database. ## Files Read -| Path | Format | When | -| ---------------------------------------- | ---------- | -------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always (embedded defaults used when absent) | -| `~/.supabase//project-ref` | plain text | on the `--linked` path (and the default target), to resolve the ref | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and a linked temp-role is minted | -| `/supabase/migrations/` | directory | when `[db.migrations].enabled` (default true), to list local files | -| `/supabase/migrations/*.sql` | SQL | for each pending migration, when applied (and not `--dry-run`) | -| seed files from `[db.seed].sql_paths` | SQL | when `--include-seed` and `[db.seed].enabled` (paths under `supabase/`) | -| `/supabase/roles.sql` | SQL | when `--include-roles` (existence check + apply) | +| Path | Format | When | +| ------------------------------------- | ---------- | ----------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always (embedded defaults used when absent) | +| `~/.supabase//project-ref` | plain text | on the `--linked` path (and the default target), to resolve the ref | +| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and a linked temp-role is minted | +| `/supabase/migrations/` | directory | when `[db.migrations].enabled` (default true), to list local files | +| `/supabase/migrations/*.sql` | SQL | for each pending migration, when applied (and not `--dry-run`) | +| seed files from `[db.seed].sql_paths` | SQL | when `--include-seed` and `[db.seed].enabled` (paths under `supabase/`) | +| `/supabase/roles.sql` | SQL | when `--include-roles` (existence check + apply) | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| `~/.supabase//linked-project.json` | JSON | on the `--linked` path (post-run cache, Go's `ensureProjectGroupsCached`) | -| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | +| Path | Format | When | +| ------------------------------------------------ | ------ | ------------------------------------------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | on the `--linked` path (post-run cache, Go's `ensureProjectGroupsCached`) | +| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | No project files are written. All other effects are database mutations (below). ## Database Mutations -| Statement | When | -| --------- | ---- | -| `RESET ALL` + `BEGIN` … migration statements … `INSERT INTO supabase_migrations.schema_migrations(version, name, statements)` … `COMMIT` | per pending migration (after confirmation) | -| `CREATE SCHEMA/TABLE … supabase_migrations.schema_migrations`, `ALTER TABLE … ADD COLUMN …` | once before applying migrations (idempotent) | -| `RESET ALL` + `BEGIN` … roles.sql statements … `COMMIT` (no history row) | per `--include-roles` globals file (after confirmation) | -| `SELECT id, name FROM vault.secrets …`, `SELECT vault.update_secret(...)`, `SELECT vault.create_secret(...)` | when `[db.vault]` has syncable secrets and migrations are applied | -| `CREATE TABLE … supabase_migrations.seed_files`, seed statements, `INSERT … seed_files(path, hash) … ON CONFLICT …` | per pending seed file with `--include-seed` (after confirmation); a dirty seed only refreshes the hash | +| Statement | When | +| ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `RESET ALL` + `BEGIN` … migration statements … `INSERT INTO supabase_migrations.schema_migrations(version, name, statements)` … `COMMIT` | per pending migration (after confirmation) | +| `CREATE SCHEMA/TABLE … supabase_migrations.schema_migrations`, `ALTER TABLE … ADD COLUMN …` | once before applying migrations (idempotent) | +| `RESET ALL` + `BEGIN` … roles.sql statements … `COMMIT` (no history row) | per `--include-roles` globals file (after confirmation) | +| `SELECT id, name FROM vault.secrets …`, `SELECT vault.update_secret(...)`, `SELECT vault.create_secret(...)` | when `[db.vault]` has syncable secrets and migrations are applied | +| `CREATE TABLE … supabase_migrations.seed_files`, seed statements, `INSERT … seed_files(path, hash) … ON CONFLICT …` | per pending seed file with `--include-seed` (after confirmation); a dirty seed only refreshes the hash | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------- | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---- | ---- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | — | — | — | — | The native handler connects to Postgres directly. On the `--linked` path the db-config resolver may call the Management API to mint a temporary login role (inherited from the shared resolver). | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ------------------------------------------------------ | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no (`--password`/`-p` takes precedence) | -| `SUPABASE_YES` | auto-confirm prompts (Go's `viper YES`) | no (also `--yes`) | +| Variable | Purpose | Required? | +| ----------------------- | ------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no (`--password`/`-p` takes precedence) | +| `SUPABASE_YES` | auto-confirm prompts (Go's `viper YES`) | no (also `--yes`) | ## Exit Codes @@ -79,7 +79,13 @@ summary line, including ANSI color (Aqua command name, Bold file paths). stdout is payload-only. A single `result` object is emitted: ```json -{ "upToDate": false, "dryRun": false, "migrations": [".sql"], "seeds": ["supabase/seed.sql"], "roles": ["supabase/roles.sql"] } +{ + "upToDate": false, + "dryRun": false, + "migrations": [".sql"], + "seeds": ["supabase/seed.sql"], + "roles": ["supabase/roles.sql"] +} ``` ## Notes diff --git a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md index fe047346dd..af1207a652 100644 --- a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md @@ -10,47 +10,47 @@ is ported (CLI-1325 Stage 3). ## Files Read -| Path | Format | When | -| -------------------------------- | ---------- | --------------------------------------------------------------- | -| `/supabase/migrations/` | directory | to validate `--version` / resolve `--last`, and to load migrations | -| `/supabase/config.toml` | TOML | remote path (embedded defaults when absent) | -| `~/.supabase//project-ref` | plain text | `--linked`, to resolve the ref | -| `~/.supabase/access-token` | plain text | `--linked`, when `SUPABASE_ACCESS_TOKEN` unset and a temp role is minted | -| seed files from `[db.seed].sql_paths` | SQL | remote path, when `[db.seed].enabled` and not `--no-seed` | +| Path | Format | When | +| ------------------------------------- | ---------- | ------------------------------------------------------------------------ | +| `/supabase/migrations/` | directory | to validate `--version` / resolve `--last`, and to load migrations | +| `/supabase/config.toml` | TOML | remote path (embedded defaults when absent) | +| `~/.supabase//project-ref` | plain text | `--linked`, to resolve the ref | +| `~/.supabase/access-token` | plain text | `--linked`, when `SUPABASE_ACCESS_TOKEN` unset and a temp role is minted | +| seed files from `[db.seed].sql_paths` | SQL | remote path, when `[db.seed].enabled` and not `--no-seed` | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | -| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | +| Path | Format | When | +| ------------------------------------------------ | ------ | --------------------------------- | +| `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | +| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | The local / experimental paths additionally produce whatever the delegated Go binary writes (container volumes, `_current_branch`, etc.). ## Database Mutations (remote path) -| Statement | When | -| --------- | ---- | -| `drop.sql` `DO` block (drops user schemas/extensions/public objects, truncates auth/migrations) | always, first | -| `SELECT vault.update_secret(...)` / `vault.create_secret(...)` | when `[db.vault]` has syncable secrets | -| migration statements + `schema_migrations` history insert (per file, transactional) | when `[db.migrations].enabled`, for migrations `≤ --version` | -| seed statements + `seed_files` hash upsert | when `[db.seed].enabled` and not `--no-seed` | +| Statement | When | +| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| `drop.sql` `DO` block (drops user schemas/extensions/public objects, truncates auth/migrations) | always, first | +| `SELECT vault.update_secret(...)` / `vault.create_secret(...)` | when `[db.vault]` has syncable secrets | +| migration statements + `schema_migrations` history insert (per file, transactional) | when `[db.migrations].enabled`, for migrations `≤ --version` | +| seed statements + `seed_files` hash upsert | when `[db.seed].enabled` and not `--no-seed` | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------- | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---- | ---- | ------------ | ---------------------------------------------------------------------------------------------------------------------------- | | — | — | — | — | Connects to Postgres directly. The `--linked` db-config resolver may call the Management API to mint a temporary login role. | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | --------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no | -| `SUPABASE_YES` | auto-confirm the reset prompt | no (also `--yes`) | -| `SUPABASE_EXPERIMENTAL` | routes the experimental schema-files path to Go | no (also `--experimental`) | +| Variable | Purpose | Required? | +| ----------------------- | ----------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no | +| `SUPABASE_YES` | auto-confirm the reset prompt | no (also `--yes`) | +| `SUPABASE_EXPERIMENTAL` | routes the experimental schema-files path to Go | no (also `--experimental`) | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.errors.ts b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.errors.ts new file mode 100644 index 0000000000..f0aa2942f1 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.errors.ts @@ -0,0 +1,14 @@ +import { Data } from "effect"; + +/** + * Driving the bundled Go binary's hidden `db __db-bootstrap` seam failed — the + * container-lifecycle primitives that back native `db start` / `db reset --local` + * (create/recreate the local Postgres container, apply the initial schema, the + * storage health gate) are not yet ported to TypeScript. Wraps a failed inspect, + * a missing `supabase-go` binary, or a non-zero seam exit. The seam tees its own + * progress to stderr, so this message is the fallback shown when the subprocess + * dies without surfacing a more specific Go error. + */ +export class LegacyDbBootstrapError extends Data.TaggedError("LegacyDbBootstrapError")<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts new file mode 100644 index 0000000000..72f5f5d2e3 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts @@ -0,0 +1,180 @@ +import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; + +import { LegacyNetworkIdFlag, LegacyProfileFlag } from "../../../../shared/legacy/global-flags.ts"; +import { resolveBinary } from "../../../../shared/legacy/go-proxy.layer.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { spawnContainerCli } from "../../../shared/legacy-container-cli.ts"; +import { legacyReadDbToml } from "../../../shared/legacy-db-config.toml-read.ts"; +import { + legacyResolveLocalProjectId, + localDbContainerId, +} from "../../../shared/legacy-docker-ids.ts"; +import { LegacyDbBootstrapError } from "./legacy-db-bootstrap.errors.ts"; +import { LegacyDbBootstrapSeam } from "./legacy-db-bootstrap.seam.service.ts"; + +const seamFailure = (message: string) => new LegacyDbBootstrapError({ message }); + +const decodeChunks = (chunks: ReadonlyArray): string => { + const total = chunks.reduce((size, chunk) => size + chunk.length, 0); + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + return new TextDecoder().decode(bytes); +}; + +/** + * Real {@link LegacyDbBootstrapSeam}: drives the bundled `supabase-go`'s hidden + * `db __db-bootstrap --mode ` command. The binary is resolved exactly like + * `LegacyGoProxy` (`resolveBinary`); the child's telemetry is disabled and its + * progress teed to stderr, matching the `db __shadow` seam. `--network-id` and a + * flag-selected `--profile` are forwarded so the spawned containers land on the + * same network and the child re-runs Go's identical config resolution. + */ +export const legacyDbBootstrapSeamLayer = Layer.effect( + LegacyDbBootstrapSeam, + Effect.gen(function* () { + const cliConfig = yield* LegacyCliConfig; + const networkId = yield* LegacyNetworkIdFlag; + const profile = yield* LegacyProfileFlag; + const profileArgs = profile !== "supabase" ? ["--profile", profile] : []; + const networkArgs = Option.isSome(networkId) ? ["--network-id", networkId.value] : []; + const spawner = yield* ChildProcessSpawner; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resolved = resolveBinary(); + + /** + * Run `db __db-bootstrap` with the given mode args. `captureStdout` pipes + * stdout (for the `await-storage` marker); otherwise stdout is inherited. + * Returns the captured stdout (empty when inherited). + */ + const runBootstrap = (modeArgs: ReadonlyArray, captureStdout: boolean) => + Effect.scoped( + Effect.gen(function* () { + if (!("found" in resolved)) { + return yield* Effect.fail( + seamFailure( + "Could not find the supabase-go binary required to bootstrap the local database.", + ), + ); + } + const args = ["db", "__db-bootstrap", ...modeArgs, ...networkArgs, ...profileArgs]; + const command = ChildProcess.make(resolved.found, args, { + cwd: cliConfig.workdir, + stdin: "inherit", + stdout: captureStdout ? "pipe" : "inherit", + stderr: "inherit", + extendEnv: true, + // Disable the child's telemetry so the hidden seam never records its + // own `cli_command_executed` on top of the user's TS command, matching + // the `db __shadow` seam and the explicit LegacyGoProxy delegates. + env: { SUPABASE_TELEMETRY_DISABLED: "1" }, + detached: false, + }); + if (!captureStdout) { + const exitCode = yield* spawner + .exitCode(command) + .pipe(Effect.mapError(() => seamFailure("failed to run supabase-go."))); + if (exitCode !== 0) { + return yield* Effect.fail( + seamFailure(`failed to bootstrap the local database: exit ${exitCode}`), + ); + } + return ""; + } + const handle = yield* spawner + .spawn(command) + .pipe(Effect.mapError(() => seamFailure("failed to run supabase-go."))); + const chunks: Array = []; + yield* Stream.runForEach(handle.stdout, (chunk) => + Effect.sync(() => { + chunks.push(chunk); + }), + ).pipe(Effect.mapError(() => seamFailure("failed to bootstrap the local database."))); + const exitCode = yield* handle.exitCode.pipe( + Effect.mapError(() => seamFailure("failed to bootstrap the local database.")), + ); + if (exitCode !== 0) { + return yield* Effect.fail( + seamFailure(`failed to bootstrap the local database: exit ${exitCode}`), + ); + } + return decodeChunks(chunks); + }), + ); + + return LegacyDbBootstrapSeam.of({ + isDbRunning: () => + Effect.scoped( + Effect.gen(function* () { + // Resolve `utils.DbId` exactly as Go does (env → config.toml → workdir + // basename); the config.toml read is best-effort since the handler has + // already validated config. + const tomlProjectId = yield* legacyReadDbToml(fs, path, cliConfig.workdir).pipe( + Effect.map((toml) => toml.projectId), + Effect.orElseSucceed(() => Option.none()), + ); + const projectId = legacyResolveLocalProjectId( + Option.getOrUndefined(cliConfig.projectId), + Option.getOrUndefined(tomlProjectId), + cliConfig.workdir, + ); + const containerId = localDbContainerId(projectId); + // Go's AssertSupabaseDbIsRunning = ContainerInspect → NotFound ⇒ not + // running. Discard stdout (the inspect JSON) so the unconsumed pipe can + // never deadlock; only the exit code + stderr matter. + const child = yield* spawnContainerCli(spawner, ["container", "inspect", containerId], { + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + extendEnv: true, + }).pipe(Effect.mapError(() => seamFailure("failed to inspect service"))); + const stderrChunks: Array = []; + yield* Stream.runForEach(child.stderr, (chunk) => + Effect.sync(() => { + stderrChunks.push(chunk); + }), + ).pipe(Effect.mapError(() => seamFailure("failed to inspect service"))); + const inspectExit = yield* child.exitCode.pipe( + Effect.map(Number), + Effect.mapError(() => seamFailure("failed to inspect service")), + ); + if (inspectExit === 0) return true; // container exists ⇒ running + + const stderr = decodeChunks(stderrChunks).trim(); + // Only a missing container means "not running". Any other inspect + // failure (e.g. the Docker daemon is down) propagates, matching Go. + if (!stderr.includes("No such container")) { + return yield* Effect.fail( + seamFailure( + stderr.length > 0 + ? `failed to inspect service: ${stderr}` + : "failed to inspect service", + ), + ); + } + return false; + }), + ), + startDatabase: ({ fromBackup }) => + runBootstrap( + ["--mode", "start", ...(fromBackup !== undefined ? ["--from-backup", fromBackup] : [])], + false, + ).pipe(Effect.asVoid), + recreateDatabase: ({ version }) => + runBootstrap( + ["--mode", "recreate", ...(version !== "" ? ["--version", version] : [])], + false, + ).pipe(Effect.asVoid), + awaitStorageReady: () => + runBootstrap(["--mode", "await-storage"], true).pipe( + Effect.map((stdout) => stdout.trim() === "ready"), + ), + }); + }), +); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.service.ts b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.service.ts new file mode 100644 index 0000000000..2f15b278cf --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.service.ts @@ -0,0 +1,63 @@ +import { Context, type Effect } from "effect"; + +import type { LegacyDbBootstrapError } from "./legacy-db-bootstrap.errors.ts"; + +/** + * Seam over the bundled Go binary's hidden `db __db-bootstrap` command, exposing + * the container-bootstrap primitives that native `db start` / `db reset --local` + * still need but that are not ported to TypeScript: the local-stack "is running?" + * probe, the database container create/recreate flows, and the storage health gate + * before bucket seeding. The TS handlers orchestrate everything else (user-facing + * messages, version resolution, bucket seeding, the git-branch line, telemetry, + * and `--output-format` shaping); only the Docker lifecycle lives behind here. + * + * Mirrors {@link LegacyDeclarativeSeam} (`db __shadow`): each method shells out to + * the same resolved `supabase-go`, with the child's telemetry disabled so the + * hidden seam never double-counts the user's command, and its progress teed to + * stderr. + */ +interface LegacyDbBootstrapSeamShape { + /** + * Go's `utils.AssertSupabaseDbIsRunning` (`internal/utils/misc.go:144`): inspect + * the local Postgres container. `true` when it exists (the stack is up), `false` + * when Docker reports "No such container" (Go's `ErrNotRunning`). Any other + * inspect failure (e.g. the Docker daemon is unreachable) fails with + * {@link LegacyDbBootstrapError}, matching Go, which returns the wrapped inspect + * error rather than treating the database as stopped. + */ + readonly isDbRunning: () => Effect.Effect; + /** + * `db start`'s container bootstrap — `start.StartDatabase(fromBackup)` plus Go's + * `DockerRemoveAll` cleanup on failure (`internal/db/start/start.go:54-60`): + * create the Postgres container, wait for health, apply the initial schema + + * roles + migrations + seed on a fresh volume, and write `_current_branch`. + * Progress (`Starting database...`, `Initialising schema...`) is teed to stderr. + */ + readonly startDatabase: (opts: { + readonly fromBackup?: string; + }) => Effect.Effect; + /** + * The PG14/PG15 container-recreate half of local `db reset` + * (`reset.RecreateLocalDatabase`): recreate the db container/volume, init schema, + * migrate + seed up to `version`, and restart the satellite containers. The + * caller has already printed `Resetting local database…`; the seam tees the + * remaining progress (`Recreating database...`, `Restarting containers...`) to + * stderr. `version` is the resolved migration version ("" for all migrations). + */ + readonly recreateDatabase: (opts: { + readonly version: string; + }) => Effect.Effect; + /** + * The storage health gate local `db reset` runs before seeding buckets + * (`reset.AwaitStorageReady`): if the storage container exists but is unhealthy, + * wait up to 30s for it. Resolves `true` when the storage container exists (so + * the caller should run the ported bucket seeding) and `false` when it does not + * — matching Go, which silently skips buckets when storage is absent. + */ + readonly awaitStorageReady: () => Effect.Effect; +} + +export class LegacyDbBootstrapSeam extends Context.Service< + LegacyDbBootstrapSeam, + LegacyDbBootstrapSeamShape +>()("supabase/legacy/DbBootstrapSeam") {} diff --git a/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md index 0c980a749c..dcf8466552 100644 --- a/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md @@ -1,17 +1,35 @@ # `supabase db start` +Native TS port of `apps/cli-go/internal/db/start/start.go` `Run`. The handler +validates config, checks whether the local Postgres container is already running, +and otherwise delegates the container bootstrap to the bundled Go binary's hidden +`db __db-bootstrap --mode start` seam (the container-lifecycle primitives are not +ported). This is `db start`, **not** the top-level `supabase start`: no status +table, no `cli_stack_started` event, no `Finished` line. + ## Files Read -| Path | Format | When | -| -------------------------------- | ------ | ---------------------------------- | -| `/supabase/config.toml` | TOML | always, to resolve local DB config | -| `` (from `--from-backup`) | binary | when `--from-backup` flag is set | +| Path | Format | When | +| -------------------------------- | ------ | --------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always — parsed up front; a malformed config aborts before work | +| `` (from `--from-backup`) | binary | when `--from-backup` is set (read by the Go seam on start) | ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ---------------------------------------------- | ------ | --------------------------------------------------------------------------- | +| `/supabase/.branches/_current_branch` | text | by the Go seam (`initCurrentBranch`) when starting; writes `main` if absent | +| local Docker volume `supabase_db_` | — | by the Go seam — the Postgres data volume created on first start | +| `~/.supabase/telemetry.json` | JSON | always (telemetry flush, success and failure) | + +## Subprocesses + +| Command | When | Purpose | +| ---------------------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `docker container inspect supabase_db_` | always | `AssertSupabaseDbIsRunning` probe (Podman fallback) | +| `supabase-go db __db-bootstrap --mode start [--from-backup

]` | when the database is not running | create container + health check + initial schema/roles/migrations/seed + `_current_branch`; telemetry disabled (`SUPABASE_TELEMETRY_DISABLED=1`), progress teed to stderr | + +`--network-id` and a flag-selected `--profile` are forwarded to the seam. ## API Routes @@ -19,34 +37,45 @@ | ------ | ---- | ---- | ------------ | ---------------------- | | — | — | — | — | — | +(The Go seam may call Auth's JWKS endpoint while applying service migrations on a +fresh PG15 volume; that is internal to the seam, not the TS handler.) + ## Environment Variables -| Variable | Purpose | Required? | -| -------- | ------- | --------- | -| — | — | — | +| Variable | Purpose | Required? | +| ----------------------------- | ---------------------------------------------------- | ---------- | +| `SUPABASE_PROJECT_ID` | overrides the local container id (`utils.DbId`) | no | +| `SUPABASE_TELEMETRY_DISABLED` | set on the seam subprocess so it never double-counts | (internal) | ## Exit Codes -| Code | Condition | -| ---- | ------------------------------ | -| `0` | success | -| `1` | Docker not running | -| `1` | database container start error | +| Code | Condition | +| ---- | --------------------------------------------------------------------- | +| `0` | success — database started, or already running | +| `1` | malformed `supabase/config.toml` | +| `1` | Docker daemon unreachable / inspect failure | +| `1` | container bootstrap failed (the seam cleans up via `DockerRemoveAll`) | ## Output ### `--output-format text` (Go CLI compatible) -Prints progress to stderr as the local Postgres container starts. +- Already running → `Postgres database is already running.` on **stderr**, exit 0. +- Starting → the Go seam tees `Starting database...` / `Initialising schema...` to + **stderr**. No stdout output, no `Finished` line. ### `--output-format json` -Not applicable. +Emits a single result object to stdout: `{ status: "already-running" }` or +`{ status: "started" }`. Progress stays on stderr. ### `--output-format stream-json` -Not applicable. +Same result object as the terminal `result` event; progress on stderr. ## Notes -- `--from-backup` restores the database from a logical backup file on start. +- `--from-backup` restores the database from a logical backup file on start; the + health check is skipped for backups (a large restore can exceed the timeout). +- No `cli_stack_started` telemetry — that event belongs to `supabase start`, not + `db start`. The only event is the standard `cli_command_executed`. diff --git a/apps/cli/src/legacy/commands/db/start/start.command.ts b/apps/cli/src/legacy/commands/db/start/start.command.ts index cc4081ae84..c9457733ae 100644 --- a/apps/cli/src/legacy/commands/db/start/start.command.ts +++ b/apps/cli/src/legacy/commands/db/start/start.command.ts @@ -1,6 +1,10 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyDbStart } from "./start.handler.ts"; +import { legacyDbStartRuntimeLayer } from "./start.layers.ts"; const config = { fromBackup: Flag.string("from-backup").pipe( @@ -14,5 +18,15 @@ export type LegacyDbStartFlags = CliCommand.Command.Config.Infer; export const legacyDbStartCommand = Command.make("start", config).pipe( Command.withDescription("Starts local Postgres database."), Command.withShortDescription("Starts local Postgres database"), - Command.withHandler((flags) => legacyDbStart(flags)), + Command.withHandler((flags) => + legacyDbStart(flags).pipe( + withLegacyCommandInstrumentation({ + // `--from-backup` is not telemetry-safe in Go (no markFlagTelemetrySafe), + // so a set value reaches telemetry as ``. + flags: { "from-backup": flags.fromBackup }, + }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyDbStartRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/db/start/start.errors.ts b/apps/cli/src/legacy/commands/db/start/start.errors.ts new file mode 100644 index 0000000000..4a80ad4448 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/start/start.errors.ts @@ -0,0 +1,10 @@ +import { Data } from "effect"; + +/** + * `supabase/config.toml` failed to parse. Go loads the config first thing in + * `start.Run` (`flags.LoadConfig`, `internal/db/start/start.go:45`), so a + * malformed config aborts before the container is touched. + */ +export class LegacyDbStartConfigLoadError extends Data.TaggedError("LegacyDbStartConfigLoadError")<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/db/start/start.handler.ts b/apps/cli/src/legacy/commands/db/start/start.handler.ts index a1aa3e586a..f6a80c9244 100644 --- a/apps/cli/src/legacy/commands/db/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/db/start/start.handler.ts @@ -1,10 +1,72 @@ import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; + +import { Output } from "../../../../shared/output/output.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { loadProjectConfig } from "@supabase/config"; +import { LegacyDbBootstrapSeam } from "../shared/legacy-db-bootstrap.seam.service.ts"; import type { LegacyDbStartFlags } from "./start.command.ts"; +import { LegacyDbStartConfigLoadError } from "./start.errors.ts"; +/** + * `supabase db start` — start the local Postgres database. + * + * Strict 1:1 port of `apps/cli-go/internal/db/start/start.go` `Run`. Native TS + * orchestrates: it validates config, checks whether the database is already + * running (printing Go's "already running" line), and otherwise delegates the + * container bootstrap to the hidden Go `__db-bootstrap` seam (create container + + * health + initial schema + `_current_branch`), whose progress is teed to stderr. + * + * Parity notes: this is `db start`, NOT the top-level `supabase start`. It does + * NOT print a status table and does NOT fire `cli_stack_started` — those belong to + * `internal/start/start.go`. There is no `Finished` line. + */ export const legacyDbStart = Effect.fn("legacy.db.start")(function* (flags: LegacyDbStartFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["db", "start"]; - if (Option.isSome(flags.fromBackup)) args.push("--from-backup", flags.fromBackup.value); - yield* proxy.exec(args); + const output = yield* Output; + const cliConfig = yield* LegacyCliConfig; + const seam = yield* LegacyDbBootstrapSeam; + const telemetryState = yield* LegacyTelemetryState; + + const body = Effect.gen(function* () { + // Go's `flags.LoadConfig(fsys)` runs first; a malformed config aborts before + // any container work. A missing config is tolerated here (loadProjectConfig + // returns null) — the seam's Go LoadConfig then surfaces Go's authoritative + // missing-config error on the not-running path. + yield* loadProjectConfig(cliConfig.workdir).pipe( + Effect.catchTag( + "ProjectConfigParseError", + (cause) => + new LegacyDbStartConfigLoadError({ + message: `failed to parse supabase/config.toml: ${String(cause.cause)}`, + }), + ), + ); + + // Go's AssertSupabaseDbIsRunning: if the db container is already up, print to + // stderr and return nil (exit 0). + const running = yield* seam.isDbRunning(); + if (running) { + if (output.format === "text") { + yield* output.raw("Postgres database is already running.\n", "stderr"); + } else { + yield* output.success("Postgres database is already running.", { + status: "already-running", + }); + } + return; + } + + // Not running → bootstrap the container (StartDatabase + DockerRemoveAll on + // failure). The seam tees "Starting database...", "Initialising schema...", + // etc. to stderr. + yield* seam.startDatabase({ fromBackup: Option.getOrUndefined(flags.fromBackup) }); + + if (output.format !== "text") { + yield* output.success("Started local database.", { status: "started" }); + } + }); + + // db start is local-only — no project ref, so no linked-project cache write. + // Telemetry still flushes on success and failure (Go's PersistentPostRun). + yield* body.pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/db/start/start.integration.test.ts b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts new file mode 100644 index 0000000000..538bffb7a3 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts @@ -0,0 +1,190 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option } from "effect"; + +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { + mockLegacyCliConfig, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import type { OutputFormat } from "../../../../shared/output/types.ts"; +import { LegacyDbBootstrapError } from "../shared/legacy-db-bootstrap.errors.ts"; +import { LegacyDbBootstrapSeam } from "../shared/legacy-db-bootstrap.seam.service.ts"; +import { legacyDbStart } from "./start.handler.ts"; +import type { LegacyDbStartFlags } from "./start.command.ts"; + +const DEFAULT_FLAGS: LegacyDbStartFlags = { fromBackup: Option.none() }; + +/** + * Stateful mock of the container-bootstrap seam. `running` drives + * `AssertSupabaseDbIsRunning`; `runningFails` / `startFails` make the respective + * call fail (Docker daemon down / StartDatabase error). Records the args passed to + * `startDatabase`. + */ +function mockSeam(opts: { running?: boolean; runningFails?: boolean; startFails?: boolean } = {}) { + const startCalls: Array<{ fromBackup?: string }> = []; + const layer = Layer.succeed(LegacyDbBootstrapSeam, { + isDbRunning: () => + opts.runningFails === true + ? Effect.fail(new LegacyDbBootstrapError({ message: "failed to inspect service" })) + : Effect.succeed(opts.running ?? false), + startDatabase: (args: { fromBackup?: string }) => + opts.startFails === true + ? Effect.fail(new LegacyDbBootstrapError({ message: "failed to bootstrap" })) + : Effect.sync(() => { + startCalls.push(args); + }), + recreateDatabase: () => Effect.void, + awaitStorageReady: () => Effect.succeed(false), + }); + return { + layer, + get startCalls() { + return startCalls; + }, + }; +} + +function setup( + workdir: string, + opts: { + toml?: string; + format?: OutputFormat; + running?: boolean; + runningFails?: boolean; + startFails?: boolean; + }, +) { + if (opts.toml !== undefined) { + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), opts.toml); + } + const out = mockOutput({ format: opts.format ?? "text" }); + const seam = mockSeam(opts); + const telemetry = mockLegacyTelemetryStateTracked(); + const layer = Layer.mergeAll( + out.layer, + seam.layer, + mockLegacyCliConfig({ workdir }), + telemetry.layer, + BunServices.layer, + ); + return { layer, out, seam, telemetry }; +} + +describe("legacy db start", () => { + const tmp = useLegacyTempWorkdir("supabase-db-start-"); + + it.live("reports an already-running database without starting a container", () => { + const { layer, out, seam, telemetry } = setup(tmp.current, { + toml: 'project_id = "test"\n', + running: true, + }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(out.stderrText).toContain("Postgres database is already running."); + expect(seam.startCalls).toHaveLength(0); + expect(telemetry.flushed).toBe(true); + }); + }); + + it.live("starts the database when it is not running", () => { + const { layer, out, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + running: false, + }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(seam.startCalls).toEqual([{ fromBackup: undefined }]); + // db start prints no "Finished" line and no status table. + expect(out.stderrText).not.toContain("Finished"); + }); + }); + + it.live("forwards --from-backup to the bootstrap seam", () => { + const { layer, seam } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + yield* legacyDbStart({ fromBackup: Option.some("/tmp/dump.sql") }).pipe( + Effect.provide(layer), + ); + expect(seam.startCalls).toEqual([{ fromBackup: "/tmp/dump.sql" }]); + }); + }); + + it.live("proceeds with no config file (missing config is tolerated)", () => { + const { layer, seam } = setup(tmp.current, { running: false }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(seam.startCalls).toHaveLength(1); + }); + }); + + it.live("fails fast on a malformed config.toml", () => { + const { layer, seam, telemetry } = setup(tmp.current, { + toml: 'project_id = "unterminated\n', + }); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to parse supabase/config.toml"); + } + // No container work attempted; telemetry still flushes on failure. + expect(seam.startCalls).toHaveLength(0); + expect(telemetry.flushed).toBe(true); + }); + }); + + it.live("propagates a Docker inspect failure", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n', runningFails: true }); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to inspect service"); + } + }); + }); + + it.live("propagates a StartDatabase failure", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n', startFails: true }); + return Effect.gen(function* () { + const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to bootstrap"); + } + }); + }); + + it.live("emits a json result when the database is already running", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + running: true, + format: "json", + }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["status"]).toBe("already-running"); + }); + }); + + it.live("emits a json result after starting the database", () => { + const { layer, out, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + running: false, + format: "json", + }); + return Effect.gen(function* () { + yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(seam.startCalls).toHaveLength(1); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["status"]).toBe("started"); + }); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/start/start.layers.ts b/apps/cli/src/legacy/commands/db/start/start.layers.ts new file mode 100644 index 0000000000..71603292ee --- /dev/null +++ b/apps/cli/src/legacy/commands/db/start/start.layers.ts @@ -0,0 +1,27 @@ +import { Layer } from "effect"; + +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; +import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; +import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; +import { legacyDbBootstrapSeamLayer } from "../shared/legacy-db-bootstrap.seam.layer.ts"; + +/** + * Runtime layer for `supabase db start`. The command is local-only, so it needs + * far less than the remote-capable db commands: just the container-bootstrap seam + * (`db __db-bootstrap`), the CLI config (workdir + project id), and the telemetry + * flush. The seam's other dependencies (`LegacyNetworkIdFlag`, `LegacyProfileFlag`, + * `ChildProcessSpawner`, `FileSystem`, `Path`) are ambient from the root runtime, + * matching how `db diff` composes the `db __shadow` seam. `LegacyCliConfig` is + * provided to the seam explicitly (legacy CLAUDE.md rule 5). + */ +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + +const seam = legacyDbBootstrapSeamLayer.pipe(Layer.provide(cliConfig)); + +export const legacyDbStartRuntimeLayer = Layer.mergeAll( + seam, + cliConfig, + legacyTelemetryStateLayer, + commandRuntimeLayer(["db", "start"]), +); From 633a6477ee5658a370a3dbf3cad7a56394aeb0a3 Mon Sep 17 00:00:00 2001 From: avallete Date: Thu, 25 Jun 2026 15:52:26 +0200 Subject: [PATCH 12/29] feat(cli): implement native db reset --local path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the Go delegation for local `db reset` with a native handler: assert the db container is running, print "Resetting local database…", recreate the container + migrate + seed via the hidden Go `db __db-bootstrap --mode recreate` seam (forwarding `--version`/`--no-seed`), seed buckets through the storage health gate, and print "Finished supabase db reset on branch .". Extract the seed-buckets local path into `legacySeedBucketsRun` so reset reuses the exact bucket-seed logic Go invokes via `buckets.Run(ctx, "", false, fsys)`, with its machine-summary suppressed for the reset caller. Add a `--no-seed` flag to the recreate seam mode so it disables MigrateAndSeed's seed like `db reset`. Only the niche `--experimental` remote schema-files path still delegates to the Go binary. Reset handler integration coverage is 98.7% branch (one unreachable defensive guard, matching the push/reset precedent). Co-Authored-By: Claude Opus 4.8 --- apps/cli-go/cmd/db.go | 9 +- apps/cli/docs/go-cli-porting-status.md | 92 ++++---- .../legacy/commands/db/reset/SIDE_EFFECTS.md | 89 +++++--- .../legacy/commands/db/reset/reset.errors.ts | 10 + .../legacy/commands/db/reset/reset.handler.ts | 58 ++++- .../db/reset/reset.integration.test.ts | 203 ++++++++++++++++-- .../legacy/commands/db/reset/reset.layers.ts | 8 + .../shared/legacy-db-bootstrap.seam.layer.ts | 9 +- .../legacy-db-bootstrap.seam.service.ts | 5 +- .../commands/seed/buckets/buckets.handler.ts | 82 ++++--- 10 files changed, 432 insertions(+), 133 deletions(-) diff --git a/apps/cli-go/cmd/db.go b/apps/cli-go/cmd/db.go index 3f46802228..bcb56f0da4 100644 --- a/apps/cli-go/cmd/db.go +++ b/apps/cli-go/cmd/db.go @@ -271,6 +271,7 @@ var ( bootstrapMode string bootstrapFromBackup string bootstrapVersion string + bootstrapNoSeed bool // dbBootstrapCmd is a hidden seam used by the native-TypeScript `db start` and // `db reset --local` commands to drive the container-bootstrap primitives that @@ -306,7 +307,12 @@ var ( return nil case "recreate": // The PG14/PG15 container-recreate half of local db reset. The TS - // caller has already printed "Resetting local database…". + // caller has already printed "Resetting local database…". Mirror the + // `db reset` command's `--no-seed` handling (cmd/db.go dbResetCmd): + // disable the seed before MigrateAndSeed runs inside the recreate. + if bootstrapNoSeed { + utils.Config.Db.Seed.Enabled = false + } return reset.RecreateLocalDatabase(cmd.Context(), bootstrapVersion, fsys) case "await-storage": ready, err := reset.AwaitStorageReady(cmd.Context()) @@ -616,6 +622,7 @@ func init() { bootstrapFlags.StringVar(&bootstrapMode, "mode", "start", "Bootstrap mode: start, recreate, or await-storage.") bootstrapFlags.StringVar(&bootstrapFromBackup, "from-backup", "", "Path to a logical backup file (start mode).") bootstrapFlags.StringVar(&bootstrapVersion, "version", "", "Reset up to the specified version (recreate mode).") + bootstrapFlags.BoolVar(&bootstrapNoSeed, "no-seed", false, "Skip the seed script after recreate (recreate mode).") dbCmd.AddCommand(dbBootstrapCmd) // Build remote command remoteFlags := dbRemoteCmd.PersistentFlags() diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 712c5c6619..57fc620200 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -80,51 +80,51 @@ These commands exist in the TS CLI today but have no direct top-level equivalent ## Database -| Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes | -| --------------------------------- | --------- | -------------------------------------------------- | -------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `db diff` | `ported` | `legacy/commands/db/diff/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra diff via edge-runtime against a Go-seam-provisioned live shadow (`db __shadow`); `--use-pgadmin` / `--use-pg-schema` delegate to the Go binary. | -| `db dump` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `db lint` | `ported` | `legacy/commands/db/lint/` | `n/a` | `n/a` | Native TS port. Runs `plpgsql_check` in a rolled-back transaction via LegacyDbConnection; emits Go-parity pretty JSON. | -| `db pull` | `ported` | `legacy/commands/db/pull/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra migration + `--declarative` pg-delta export; reconciles `schema_migrations`. `--experimental` dump + initial-pull `pg_dump` (migra) delegate to the Go binary. | -| `db push` | `ported` | `legacy/commands/db/push/` | `n/a` | `n/a` | Native TS port. Connects local/linked/`--db-url`; pushes pending migrations, `--include-seed` seeds (`seed_files` hash tracking), `--include-roles`, `[db.vault]` secrets; `--dry-run`. `encrypted:` vault secrets + best-effort pg-delta catalog cache not ported (no output impact). | -| `db reset` | `partial` | `legacy/commands/db/reset/` | `n/a` | `n/a` | Remote path (`--linked` / remote `--db-url`) native: drop user schemas, vault upsert, MigrateAndSeed (partial migrations + seed), `--version`/`--last`. Local reset + `--experimental` schema-files path delegate to the Go binary (telemetry-disabled) pending the container-bootstrap seam (Stage 3). | -| `db start` | `ported` | `legacy/commands/db/start/` | `n/a` | `n/a` | Native TS port. Validates config, checks "already running" (prints Go's line), else delegates the container bootstrap (create + health + initial schema/roles/migrations/seed + `_current_branch`) to the hidden Go `db __db-bootstrap --mode start` seam. No status table / `cli_stack_started` (those are `supabase start`). `--from-backup` supported. | -| `inspect report` | `ported` | `legacy/commands/inspect/report/` | `n/a` | `n/a` | Native TS port. Runs every inspect query via server-side `COPY ... CSV`, writes 14 CSVs under `//`, then renders a Go-parity Glamour rules summary (bounded csvq-subset evaluator; custom `[experimental.inspect.rules]` supported). | -| `inspect db db-stats` | `ported` | `legacy/commands/inspect/db/db-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db replication-slots` | `ported` | `legacy/commands/inspect/db/replication-slots/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db locks` | `ported` | `legacy/commands/inspect/db/locks/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db blocking` | `ported` | `legacy/commands/inspect/db/blocking/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db outliers` | `ported` | `legacy/commands/inspect/db/outliers/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db calls` | `ported` | `legacy/commands/inspect/db/calls/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db index-stats` | `ported` | `legacy/commands/inspect/db/index-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db long-running-queries` | `ported` | `legacy/commands/inspect/db/long-running-queries/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db bloat` | `ported` | `legacy/commands/inspect/db/bloat/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db role-stats` | `ported` | `legacy/commands/inspect/db/role-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db vacuum-stats` | `ported` | `legacy/commands/inspect/db/vacuum-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db table-stats` | `ported` | `legacy/commands/inspect/db/table-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db traffic-profile` | `ported` | `legacy/commands/inspect/db/traffic-profile/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | -| `inspect db cache-hit` | `ported` | `legacy/commands/inspect/db/cache-hit/` | `n/a` | `n/a` | Native TS port. Deprecated (use db-stats); routes to the active query. | -| `inspect db index-usage` | `ported` | `legacy/commands/inspect/db/index-usage/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db total-index-size` | `ported` | `legacy/commands/inspect/db/total-index-size/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db index-sizes` | `ported` | `legacy/commands/inspect/db/index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db table-sizes` | `ported` | `legacy/commands/inspect/db/table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db table-index-sizes` | `ported` | `legacy/commands/inspect/db/table-index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db total-table-sizes` | `ported` | `legacy/commands/inspect/db/total-table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db unused-indexes` | `ported` | `legacy/commands/inspect/db/unused-indexes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db table-record-counts` | `ported` | `legacy/commands/inspect/db/table-record-counts/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | -| `inspect db seq-scans` | `ported` | `legacy/commands/inspect/db/seq-scans/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | -| `inspect db role-configs` | `ported` | `legacy/commands/inspect/db/role-configs/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | -| `inspect db role-connections` | `ported` | `legacy/commands/inspect/db/role-connections/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | -| `migration down` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration fetch` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration list` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration new` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration repair` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration squash` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration up` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `seed buckets` | `ported` | `legacy/commands/seed/buckets/` | `n/a` | `n/a` | Native TS port. Local-only (Go's `seed` defines no `--project-ref`, so the ref is always empty): seeds `[storage.buckets]` + `[storage.vector]` against the local Storage service gateway; remote/analytics paths are unreachable and omitted. `--linked`/`--local` accepted for surface parity (both seed local). Vector graceful-skip WARNINGs ported. | -| `test db` | `ported` | `legacy/commands/test/db/` | `n/a` | `n/a` | Native TS port. `--db-url`/`--local`/`--linked` + variadic paths; runs `supabase/pg_prove:3.36` via `docker run`; pgTAP enable/disable via `@effect/sql-pg`. `--network-id` override and `[images]` config override not modeled (documented divergences). | -| `test new` | `ported` | `legacy/commands/test/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/tests/_test.sql` from the embedded pgtap template; `--template` (pgtap). | +| Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes | +| --------------------------------- | --------- | -------------------------------------------------- | -------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `db diff` | `ported` | `legacy/commands/db/diff/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra diff via edge-runtime against a Go-seam-provisioned live shadow (`db __shadow`); `--use-pgadmin` / `--use-pg-schema` delegate to the Go binary. | +| `db dump` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `db lint` | `ported` | `legacy/commands/db/lint/` | `n/a` | `n/a` | Native TS port. Runs `plpgsql_check` in a rolled-back transaction via LegacyDbConnection; emits Go-parity pretty JSON. | +| `db pull` | `ported` | `legacy/commands/db/pull/` | `n/a` | `n/a` | Native TS port. Native pg-delta / migra migration + `--declarative` pg-delta export; reconciles `schema_migrations`. `--experimental` dump + initial-pull `pg_dump` (migra) delegate to the Go binary. | +| `db push` | `ported` | `legacy/commands/db/push/` | `n/a` | `n/a` | Native TS port. Connects local/linked/`--db-url`; pushes pending migrations, `--include-seed` seeds (`seed_files` hash tracking), `--include-roles`, `[db.vault]` secrets; `--dry-run`. `encrypted:` vault secrets + best-effort pg-delta catalog cache not ported (no output impact). | +| `db reset` | `ported` | `legacy/commands/db/reset/` | `n/a` | `n/a` | Remote path native (drop user schemas, vault upsert, MigrateAndSeed, `--version`/`--last`). Local path native: running check, recreate + migrate + seed via the hidden Go `db __db-bootstrap` seam, storage-gated bucket seeding (reuses `seed buckets`), git-branch `Finished…` line. Only the niche `--experimental` remote schema-files path still delegates to the Go binary (telemetry-disabled). | +| `db start` | `ported` | `legacy/commands/db/start/` | `n/a` | `n/a` | Native TS port. Validates config, checks "already running" (prints Go's line), else delegates the container bootstrap (create + health + initial schema/roles/migrations/seed + `_current_branch`) to the hidden Go `db __db-bootstrap --mode start` seam. No status table / `cli_stack_started` (those are `supabase start`). `--from-backup` supported. | +| `inspect report` | `ported` | `legacy/commands/inspect/report/` | `n/a` | `n/a` | Native TS port. Runs every inspect query via server-side `COPY ... CSV`, writes 14 CSVs under `//`, then renders a Go-parity Glamour rules summary (bounded csvq-subset evaluator; custom `[experimental.inspect.rules]` supported). | +| `inspect db db-stats` | `ported` | `legacy/commands/inspect/db/db-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db replication-slots` | `ported` | `legacy/commands/inspect/db/replication-slots/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db locks` | `ported` | `legacy/commands/inspect/db/locks/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db blocking` | `ported` | `legacy/commands/inspect/db/blocking/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db outliers` | `ported` | `legacy/commands/inspect/db/outliers/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db calls` | `ported` | `legacy/commands/inspect/db/calls/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db index-stats` | `ported` | `legacy/commands/inspect/db/index-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db long-running-queries` | `ported` | `legacy/commands/inspect/db/long-running-queries/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db bloat` | `ported` | `legacy/commands/inspect/db/bloat/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db role-stats` | `ported` | `legacy/commands/inspect/db/role-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db vacuum-stats` | `ported` | `legacy/commands/inspect/db/vacuum-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db table-stats` | `ported` | `legacy/commands/inspect/db/table-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db traffic-profile` | `ported` | `legacy/commands/inspect/db/traffic-profile/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db cache-hit` | `ported` | `legacy/commands/inspect/db/cache-hit/` | `n/a` | `n/a` | Native TS port. Deprecated (use db-stats); routes to the active query. | +| `inspect db index-usage` | `ported` | `legacy/commands/inspect/db/index-usage/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db total-index-size` | `ported` | `legacy/commands/inspect/db/total-index-size/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db index-sizes` | `ported` | `legacy/commands/inspect/db/index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db table-sizes` | `ported` | `legacy/commands/inspect/db/table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db table-index-sizes` | `ported` | `legacy/commands/inspect/db/table-index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db total-table-sizes` | `ported` | `legacy/commands/inspect/db/total-table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db unused-indexes` | `ported` | `legacy/commands/inspect/db/unused-indexes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db table-record-counts` | `ported` | `legacy/commands/inspect/db/table-record-counts/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db seq-scans` | `ported` | `legacy/commands/inspect/db/seq-scans/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db role-configs` | `ported` | `legacy/commands/inspect/db/role-configs/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | +| `inspect db role-connections` | `ported` | `legacy/commands/inspect/db/role-connections/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | +| `migration down` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `migration fetch` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `migration list` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `migration new` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `migration repair` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `migration squash` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `migration up` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `seed buckets` | `ported` | `legacy/commands/seed/buckets/` | `n/a` | `n/a` | Native TS port. Local-only (Go's `seed` defines no `--project-ref`, so the ref is always empty): seeds `[storage.buckets]` + `[storage.vector]` against the local Storage service gateway; remote/analytics paths are unreachable and omitted. `--linked`/`--local` accepted for surface parity (both seed local). Vector graceful-skip WARNINGs ported. | +| `test db` | `ported` | `legacy/commands/test/db/` | `n/a` | `n/a` | Native TS port. `--db-url`/`--local`/`--linked` + variadic paths; runs `supabase/pg_prove:3.36` via `docker run`; pgTAP enable/disable via `@effect/sql-pg`. `--network-id` override and `[images]` config override not modeled (documented divergences). | +| `test new` | `ported` | `legacy/commands/test/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/tests/_test.sql` from the embedded pgtap template; `--template` (pgtap). | ## Code Generation @@ -302,7 +302,7 @@ Legend: | `db dump` | `ported` | [`../src/legacy/commands/db/dump/dump.command.ts`](../src/legacy/commands/db/dump/dump.command.ts) | | `db push` | `ported` | [`../src/legacy/commands/db/push/push.command.ts`](../src/legacy/commands/db/push/push.command.ts) | | `db pull` | `ported` | [`../src/legacy/commands/db/pull/pull.command.ts`](../src/legacy/commands/db/pull/pull.command.ts) — native pg-delta / migra; `--declarative` (deprecated alias `--use-pg-delta`) + `--diff-engine` (migra\|pg-delta); `--experimental` / initial `pg_dump` delegate to Go | -| `db reset` | `partial` | [`../src/legacy/commands/db/reset/reset.command.ts`](../src/legacy/commands/db/reset/reset.command.ts) — remote path native (drop + vault + MigrateAndSeed); local + `--experimental` delegate to the Go binary pending the container-bootstrap seam | +| `db reset` | `ported` | [`../src/legacy/commands/db/reset/reset.command.ts`](../src/legacy/commands/db/reset/reset.command.ts) — remote + local native; local container recreate via the hidden Go `db __db-bootstrap` seam; only `--experimental` remote delegates to the Go binary | | `db lint` | `ported` | [`../src/legacy/commands/db/lint/lint.command.ts`](../src/legacy/commands/db/lint/lint.command.ts) | | `db start` | `ported` | [`../src/legacy/commands/db/start/start.command.ts`](../src/legacy/commands/db/start/start.command.ts) — native; container bootstrap via the hidden Go `db __db-bootstrap` seam | | `db query` | `ported` | [`../src/legacy/commands/db/query/query.command.ts`](../src/legacy/commands/db/query/query.command.ts) | diff --git a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md index af1207a652..3a2f99d145 100644 --- a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md @@ -4,19 +4,22 @@ Native TypeScript port of `apps/cli-go/internal/db/reset/reset.go`. Reinitialise database from local migrations (plus seed). The **remote** path (`--linked`, or a remote `--db-url`) is native: drop all user schemas, upsert vault secrets, then re-apply migrations and seed. The **local** path (`--local`/default, or a `--db-url` -pointing at the local stack) and the niche `--experimental` schema-files path -delegate to the bundled Go binary — an interim until the container-bootstrap seam -is ported (CLI-1325 Stage 3). +pointing at the local stack) is also native: TS orchestrates the running check, +messages, bucket seeding, and git-branch line, while the container-recreate +primitives run behind the hidden Go `db __db-bootstrap` seam. Only the niche +**`--experimental`** remote schema-files path still delegates to the Go binary. ## Files Read | Path | Format | When | | ------------------------------------- | ---------- | ------------------------------------------------------------------------ | | `/supabase/migrations/` | directory | to validate `--version` / resolve `--last`, and to load migrations | -| `/supabase/config.toml` | TOML | remote path (embedded defaults when absent) | +| `/supabase/config.toml` | TOML | remote path + local bucket seeding (embedded defaults when absent) | +| `/.git/HEAD` (walked upward) | plain text | local path, for the `Finished … on branch .` line | | `~/.supabase//project-ref` | plain text | `--linked`, to resolve the ref | | `~/.supabase/access-token` | plain text | `--linked`, when `SUPABASE_ACCESS_TOKEN` unset and a temp role is minted | | seed files from `[db.seed].sql_paths` | SQL | remote path, when `[db.seed].enabled` and not `--no-seed` | +| `/supabase/buckets/` | files | local path, when storage is up and `[storage.buckets]` configure objects | ## Files Written @@ -25,10 +28,25 @@ is ported (CLI-1325 Stage 3). | `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | | `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | -The local / experimental paths additionally produce whatever the delegated Go -binary writes (container volumes, `_current_branch`, etc.). +On the local path the Go seam additionally recreates the `supabase_db_` +container/volume and applies the initial schema (`SetupLocalDatabase`); the +`--experimental` remote path produces whatever the delegated Go binary writes. -## Database Mutations (remote path) +## Subprocesses + +| Command | When | Purpose | +| --------------------------------------------------------------------------- | ----------------------------------- | ----------------------------------------------------------------------- | +| `docker container inspect supabase_db_` | local path | `AssertSupabaseDbIsRunning` probe (Podman fallback) | +| `supabase-go db __db-bootstrap --mode recreate [--version ] [--no-seed]` | local path | recreate container + init schema + migrate + seed + restart services | +| `supabase-go db __db-bootstrap --mode await-storage` | local path | storage health gate before bucket seeding (`ready` / `absent`) | +| `supabase-go db reset --linked\|--db-url … [--no-seed]` | `--experimental` remote, no version | the un-ported experimental schema-files apply path (telemetry disabled) | + +The seam subprocesses run with `SUPABASE_TELEMETRY_DISABLED=1`, stderr inherited; +`--network-id` / a flag-selected `--profile` are forwarded. + +## Database Mutations + +### Remote path (native, in TS) | Statement | When | | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | @@ -37,11 +55,19 @@ binary writes (container volumes, `_current_branch`, etc.). | migration statements + `schema_migrations` history insert (per file, transactional) | when `[db.migrations].enabled`, for migrations `≤ --version` | | seed statements + `seed_files` hash upsert | when `[db.seed].enabled` and not `--no-seed` | +### Local path (inside the Go seam) + +The recreate seam drops & recreates the `postgres`/`_supabase` databases (PG≤14) or +removes & recreates the db container/volume (PG15), applies the initial schema + +roles, then runs `MigrateAndSeed` (migrations `≤ --version`, seed unless `--no-seed`) +and restarts the storage/auth/realtime/pooler containers. Bucket objects are then +seeded over the Storage gateway (reusing the `seed buckets` local path). + ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------------------------------------------------------------------------------------------------------------- | -| — | — | — | — | Connects to Postgres directly. The `--linked` db-config resolver may call the Management API to mint a temporary login role. | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---- | ---- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| — | — | — | — | Connects to Postgres directly. The `--linked` resolver may call the Management API to mint a temporary login role; local bucket seeding calls the Storage gateway. | ## Environment Variables @@ -51,6 +77,7 @@ binary writes (container volumes, `_current_branch`, etc.). | `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no | | `SUPABASE_YES` | auto-confirm the reset prompt | no (also `--yes`) | | `SUPABASE_EXPERIMENTAL` | routes the experimental schema-files path to Go | no (also `--experimental`) | +| `SUPABASE_PROJECT_ID` | overrides the local container id (`utils.DbId`) | no | ## Exit Codes @@ -61,40 +88,50 @@ binary writes (container volumes, `_current_branch`, etc.). | `1` | `--version` + `--last` together (`[last version]`) | | `1` | `--version` not an integer (`invalid version number`) | | `1` | `--version` has no matching migration file | +| `1` | local: database not running (`supabase start is not running.`) | | `1` | user declined the reset confirmation (`context canceled`) | | `1` | `config.toml` parse failure | | `1` | drop / migrate / seed / vault apply failure, or connection error | +| `1` | local: container recreate / storage health-gate failure (seam) | ## Output The remote path prints `Resetting remote database…` to **stderr**, then the -drop/migrate/seed progress (`Applying migration …`, `Seeding data from …`). Unlike -`db push`, Go connects with `io.Discard`, so there is **no** `Connecting to … -database…` line and **no** `Finished …` line. +drop/migrate/seed progress (`Applying migration …`, `Seeding data from …`). Go +connects with `io.Discard`, so there is **no** `Connecting to … database…` line and +**no** `Finished …` line on the remote path. + +The local path prints `Resetting local database…` to **stderr**, then the seam's +`Recreating database...` / `Restarting containers...` progress, and finally +`Finished supabase db reset on branch .` (`supabase db reset` and `` +in Aqua). ### `--output-format text` (Go CLI compatible) -Byte-matches Go's stderr progress for the remote path. The local / experimental -paths pass the delegated Go binary's output through unchanged. +Byte-matches Go's stderr progress for both the remote and local paths. The +`--experimental` remote path passes the delegated Go binary's output through +unchanged. ### `--output-format json` / `stream-json` -stdout is payload-only; on a confirmed remote reset a `result` object is emitted: +stdout is payload-only; a `result` object is emitted: ```json -{ "target": "remote", "version": "" } +{ "target": "remote" | "local", "version": "" } ``` -In machine modes the confirmation prompt is non-interactive and takes its default -(`false`), so a remote reset is declined unless `--yes` is set. +In machine modes the remote confirmation prompt is non-interactive and takes its +default (`false`), so a remote reset is declined unless `--yes` is set. The local +path has no confirmation prompt. ## Notes - **Target/local split** follows Go's `IsLocalDatabase(resolved config)`, not the - flag name: a `--db-url` pointing at the local stack is treated as a local reset - and delegated. -- `--no-seed` forces seeding off (Go sets `Config.Db.Seed.Enabled = false`). -- `--last n` reverts the most recent `n` migrations; if `n ≥ total`, the reset - target version becomes `-` (revert everything). -- **Known interim**: local `db reset` and `--experimental` remote resets run via the - Go binary; the best-effort pg-delta catalog cache is not ported (no output impact). + flag name: a `--db-url` pointing at the local stack is treated as a local reset. +- `--no-seed` forces seeding off (Go sets `Config.Db.Seed.Enabled = false`); on the + local path it is forwarded to the recreate seam so `MigrateAndSeed` skips the seed. +- `--last n` reverts the most recent `n` migrations; if `n ≥ total`, the reset target + version becomes `-` (revert everything). +- **Known interim**: only `--experimental` remote resets run via the Go binary; the + best-effort pg-delta catalog cache (inside the seam) is not surfaced (no output + impact). `encrypted:` vault secrets are skipped on the remote path. diff --git a/apps/cli/src/legacy/commands/db/reset/reset.errors.ts b/apps/cli/src/legacy/commands/db/reset/reset.errors.ts index 6e8a64ee98..18b5d865b9 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.errors.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.errors.ts @@ -58,3 +58,13 @@ export class LegacyDbResetConfigLoadError extends Data.TaggedError("LegacyDbRese export class LegacyDbResetApplyError extends Data.TaggedError("LegacyDbResetApplyError")<{ readonly message: string; }> {} + +/** + * The local database container is not running. Byte-matches Go's + * `utils.ErrNotRunning` (`internal/utils/misc.go:116`), `"supabase start + * is not running."`, returned by `AssertSupabaseDbIsRunning` before the local + * reset (`internal/db/reset/reset.go:57`). + */ +export class LegacyDbResetNotRunningError extends Data.TaggedError("LegacyDbResetNotRunningError")<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts index 592f122024..bd49928d1b 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts @@ -6,6 +6,7 @@ import { import { Effect, FileSystem, Option, Path, Schema } from "effect"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { detectGitBranch } from "../../../../shared/git/git-branch.ts"; import { LegacyDnsResolverFlag, LegacyExperimentalFlag, @@ -13,6 +14,7 @@ import { import { legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; import { Output } from "../../../../shared/output/output.service.ts"; +import { legacyAqua } from "../../../shared/legacy-colors.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; @@ -22,9 +24,11 @@ import { resolveLegacyDbTargetFlags } from "../../../shared/legacy-db-target-fla import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { legacyDropUserSchemas } from "../shared/legacy-drop-schemas.ts"; +import { LegacyDbBootstrapSeam } from "../shared/legacy-db-bootstrap.seam.service.ts"; import { legacyListLocalMigrations } from "../shared/legacy-pgdelta.cache.ts"; import { legacyGetPendingSeeds, legacySeedData } from "../shared/legacy-seed-ops.ts"; import { legacyReadVaultDocument, legacyUpsertVaultSecrets } from "../shared/legacy-vault.ts"; +import { legacySeedBucketsRun } from "../../seed/buckets/buckets.handler.ts"; import type { LegacyDbResetFlags } from "./reset.command.ts"; import { LegacyDbResetApplyError, @@ -32,6 +36,7 @@ import { LegacyDbResetConfigLoadError, LegacyDbResetInvalidVersionError, LegacyDbResetMigrationFileError, + LegacyDbResetNotRunningError, LegacyDbResetTargetFlagsError, LegacyDbResetVersionFlagsError, } from "./reset.errors.ts"; @@ -47,15 +52,19 @@ const applyError = (message: string) => new LegacyDbResetApplyError({ message }) const toLogMessage = (version: string): string => version.length > 0 ? ` to version: ${version}` : "..."; -/** Rebuilds the `db reset` argv for the Go-delegated (local / experimental) paths. */ +/** + * Rebuilds the `db reset` argv for the remaining Go-delegated path: a remote + * `--experimental` reset with no resolved version. Only the flags reachable on + * that path are forwarded — `--local` always takes the native path, and a set + * `--version`/`--last` resolves a non-empty version which disables the experimental + * delegation (a degenerate `--last 0` resolves to "" and is behaviourally identical + * whether or not it is forwarded, so it is omitted). + */ const buildResetArgs = (flags: LegacyDbResetFlags): Array => { const args = ["db", "reset"]; if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); if (flags.noSeed) args.push("--no-seed"); - if (Option.isSome(flags.version)) args.push("--version", flags.version.value); - if (Option.isSome(flags.last)) args.push("--last", String(flags.last.value)); return args; }; @@ -72,6 +81,7 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega const resolver = yield* LegacyDbConfigResolver; const dbConn = yield* LegacyDbConnection; const proxy = yield* LegacyGoProxy; + const seam = yield* LegacyDbBootstrapSeam; const cliConfig = yield* LegacyCliConfig; const telemetryState = yield* LegacyTelemetryState; const linkedProjectCache = yield* LegacyLinkedProjectCache; @@ -141,10 +151,44 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega const connType = target.connType ?? "local"; const cfg = yield* resolver.resolve({ dbUrl: flags.dbUrl, connType, dnsResolver }); - // Local target → container reset, not yet ported. Delegate to the Go binary - // (telemetry disabled so the TS instrumentation wrapper counts the run once). + // Local target → native local reset. The container-recreate primitives live + // behind the hidden Go `db __db-bootstrap` seam; TS orchestrates the rest + // (running check, messages, bucket seeding, git-branch line, output shaping). + // Mirrors `internal/db/reset/reset.go:57-77`. if (cfg.isLocal) { - yield* proxy.exec(buildResetArgs(flags), { env: { SUPABASE_TELEMETRY_DISABLED: "1" } }); + // AssertSupabaseDbIsRunning — error if the local db container is down. + const running = yield* seam.isDbRunning(); + if (!running) { + return yield* Effect.fail( + new LegacyDbResetNotRunningError({ + message: `${legacyAqua("supabase start")} is not running.`, + }), + ); + } + // resetDatabase: "Resetting local database…" then recreate + migrate + seed. + yield* output.raw(`Resetting local database${toLogMessage(resolvedVersion)}\n`, "stderr"); + yield* seam.recreateDatabase({ version: resolvedVersion, noSeed: flags.noSeed }); + + // Seed objects from supabase/buckets when storage is up (Go gates buckets on + // an existing, healthy storage container). Reuses the ported seed-buckets + // local path; its summary is suppressed (reset emits its own result). + const storageReady = yield* seam.awaitStorageReady(); + if (storageReady) { + yield* legacySeedBucketsRun({ projectRef: "", emitSummary: false }); + } + + // "Finished supabase db reset on branch ." (both Aqua). + const branch = Option.getOrElse(yield* detectGitBranch(workdir), () => "main"); + yield* output.raw( + `Finished ${legacyAqua("supabase db reset")} on branch ${legacyAqua(branch)}.\n`, + "stderr", + ); + if (output.format !== "text") { + yield* output.success("Reset local database.", { + target: "local", + version: resolvedVersion, + }); + } return; } diff --git a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts index 93b297f3ac..ec4030bf0b 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts @@ -4,15 +4,20 @@ import { dirname, join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Layer, Option } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; -import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts"; import { LEGACY_VALID_REF, mockLegacyCliConfig, mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApiService, mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyPlatformApiFactory } from "../../../auth/legacy-platform-api-factory.service.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyDnsResolverFlag, @@ -31,6 +36,7 @@ import { LegacyDbConnection, type LegacyPgConnInput, } from "../../../shared/legacy-db-connection.service.ts"; +import { LegacyDbBootstrapSeam } from "../shared/legacy-db-bootstrap.seam.service.ts"; import { legacyDbReset } from "./reset.handler.ts"; import type { LegacyDbResetFlags } from "./reset.command.ts"; @@ -113,6 +119,48 @@ function mockConnection(opts: { remoteSeeds?: Readonly> } }; } +/** + * Stateful mock of the container-bootstrap seam. `running` drives + * `AssertSupabaseDbIsRunning`; `storageReady` drives the bucket-seed gate. Records + * the recreate args so tests can assert version / `--no-seed` propagation. + */ +function mockBootstrapSeam(opts: { running?: boolean; storageReady?: boolean }) { + const recreateCalls: Array<{ version: string; noSeed: boolean }> = []; + let storageChecked = false; + const layer = Layer.succeed(LegacyDbBootstrapSeam, { + isDbRunning: () => Effect.succeed(opts.running ?? true), + startDatabase: () => Effect.void, + recreateDatabase: (args: { version: string; noSeed: boolean }) => + Effect.sync(() => { + recreateCalls.push(args); + }), + awaitStorageReady: () => + Effect.sync(() => { + storageChecked = true; + return opts.storageReady ?? false; + }), + }); + return { + layer, + get recreateCalls() { + return recreateCalls; + }, + get storageChecked() { + return storageChecked; + }, + }; +} + +// Dummy HTTP client; the local-reset bucket-seed core only reaches it when storage +// is ready AND buckets are configured (no reset test configures buckets, so the +// gateway is never actually called). Present to satisfy the handler's R. +const mockStorageHttp = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response("{}", { status: 404 }))), + ), +); + function mockProxy() { const calls: Array<{ args: ReadonlyArray; env?: Record }> = []; const layer = Layer.succeed(LegacyGoProxy, { @@ -144,6 +192,8 @@ function setup( remoteSeeds?: Readonly>; yes?: boolean; omitRef?: boolean; + running?: boolean; + storageReady?: boolean; }, ) { if (opts.toml !== undefined) { @@ -159,13 +209,18 @@ function setup( const out = mockOutput({ format: opts.format ?? "text", promptConfirmResponses: opts.confirm }); const conn = mockConnection(opts); const proxy = mockProxy(); + const seam = mockBootstrapSeam({ running: opts.running, storageReady: opts.storageReady }); const telemetry = mockLegacyTelemetryStateTracked(); const linkedCache = mockLegacyLinkedProjectCacheTracked(); + // The local-reset bucket-seed core statically requires the (lazy) Management-API + // factory; never invoked on `--local` (projectRef === ""). + const platformApi = mockLegacyPlatformApiService({}); const layer = Layer.mergeAll( out.layer, conn.layer, proxy.layer, + seam.layer, mockResolver({ isLocal: opts.isLocal ?? false, ref: opts.ref ?? LEGACY_VALID_REF, @@ -173,6 +228,11 @@ function setup( }), mockLegacyCliConfig({ workdir }), BunServices.layer, + mockRuntimeInfo(), + mockStorageHttp, + Layer.succeed(LegacyPlatformApiFactory, { + make: LegacyPlatformApi.pipe(Effect.provide(platformApi.layer)), + }), Layer.succeed(CliArgs, { args: opts.args ?? ["db", "reset", "--linked"] }), Layer.succeed(LegacyYesFlag, opts.yes ?? false), Layer.succeed(LegacyDnsResolverFlag, "native"), @@ -180,7 +240,7 @@ function setup( telemetry.layer, linkedCache.layer, ); - return { layer, out, conn, proxy, telemetry, linkedCache }; + return { layer, out, conn, proxy, seam, telemetry, linkedCache }; } const migrationFile = (version: string, body = "create table t ();") => ({ @@ -190,19 +250,100 @@ const migrationFile = (version: string, body = "create table t ();") => ({ describe("legacy db reset", () => { const tmp = useLegacyTempWorkdir("supabase-db-reset-"); - it.live("delegates a local reset to the Go binary with telemetry disabled", () => { - const { layer, proxy, conn } = setup(tmp.current, { + it.live("resets the local database via the bootstrap seam", () => { + const { layer, out, seam, proxy } = setup(tmp.current, { toml: 'project_id = "test"\n', args: ["db", "reset"], isLocal: true, + running: true, }); return Effect.gen(function* () { yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(proxy.calls).toHaveLength(1); - expect(proxy.calls[0]!.args).toEqual(["db", "reset"]); - expect(proxy.calls[0]!.env).toEqual({ SUPABASE_TELEMETRY_DISABLED: "1" }); - // No native DB work on the delegated path. - expect(conn.execs).toHaveLength(0); + // Native path — no Go delegation. + expect(proxy.calls).toHaveLength(0); + expect(out.stderrText).toContain("Resetting local database..."); + expect(seam.recreateCalls).toEqual([{ version: "", noSeed: false }]); + // Storage gate checked; with no buckets configured nothing is seeded. + expect(seam.storageChecked).toBe(true); + expect(out.stderrText).toContain("Finished "); + expect(out.stderrText).toContain("on branch "); + }); + }); + + it.live("fails a local reset when the database is not running", () => { + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset"], + isLocal: true, + running: false, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("is not running."); + expect(seam.recreateCalls).toHaveLength(0); + }); + }); + + it.live("seeds buckets after a local reset when storage is ready", () => { + const { layer, seam } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset"], + isLocal: true, + running: true, + storageReady: true, + }); + return Effect.gen(function* () { + // No buckets configured → the seed-buckets core short-circuits, but the + // storage gate is still consulted (Go inspects storage before buckets.Run). + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(seam.storageChecked).toBe(true); + expect(seam.recreateCalls).toHaveLength(1); + }); + }); + + it.live("uses the detected git branch in the Finished line", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: { ".git/HEAD": "ref: refs/heads/feature-x\n" }, + args: ["db", "reset"], + isLocal: true, + running: true, + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + // The branch name is wrapped in ANSI (legacyAqua), so assert on the token. + expect(out.stderrText).toContain("on branch "); + expect(out.stderrText).toContain("feature-x"); + }); + }); + + it.live("fails a remote reset on a malformed config.toml", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "unterminated\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( + Effect.provide(layer), + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to parse supabase/config.toml"); + } + }); + }); + + it.live("emits a json result for a local reset", () => { + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + args: ["db", "reset"], + isLocal: true, + running: true, + format: "json", + }); + return Effect.gen(function* () { + yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data?.["target"]).toBe("local"); }); }); @@ -380,34 +521,60 @@ describe("legacy db reset", () => { return Effect.gen(function* () { yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); expect(proxy.calls).toHaveLength(1); + expect(proxy.calls[0]!.args).toEqual(["db", "reset", "--linked"]); expect(proxy.calls[0]!.env).toEqual({ SUPABASE_TELEMETRY_DISABLED: "1" }); }); }); - it.live("forwards all flags to the Go binary on the delegated local path", () => { + it.live("forwards --db-url and --no-seed on an experimental remote db-url reset", () => { const { layer, proxy } = setup(tmp.current, { + toml: 'project_id = "test"\n', + experimental: true, + args: ["db", "reset", "--db-url", "postgresql://db.example.com:5432/postgres"], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + dbUrl: Option.some("postgresql://db.example.com:5432/postgres"), + noSeed: true, + }).pipe(Effect.provide(layer)); + expect(proxy.calls[0]!.args).toEqual([ + "db", + "reset", + "--db-url", + "postgresql://db.example.com:5432/postgres", + "--no-seed", + ]); + }); + }); + + it.live("passes --no-seed and the resolved --last version to the recreate seam", () => { + const { layer, seam } = setup(tmp.current, { toml: 'project_id = "test"\n', files: { ...migrationFile("20240101000000"), ...migrationFile("20240202000000") }, args: ["db", "reset", "--local"], isLocal: true, + running: true, }); return Effect.gen(function* () { + // last=1 with 2 local migrations → recreate up to version 20240101000000. yield* legacyDbReset({ ...DEFAULT_FLAGS, local: true, noSeed: true, last: Option.some(1), }).pipe(Effect.provide(layer)); - expect(proxy.calls[0]!.args).toEqual(["db", "reset", "--local", "--no-seed", "--last", "1"]); + expect(seam.recreateCalls).toEqual([{ version: "20240101000000", noSeed: true }]); }); }); - it.live("forwards --db-url and --version when delegating a local db-url reset", () => { - const { layer, proxy } = setup(tmp.current, { + it.live("recreates to a specific --version on a local db-url reset", () => { + const { layer, out, seam } = setup(tmp.current, { toml: 'project_id = "test"\n', files: migrationFile("20240101000000"), args: ["db", "reset", "--db-url", "postgresql://localhost:54322/postgres"], isLocal: true, + running: true, }); return Effect.gen(function* () { yield* legacyDbReset({ @@ -415,14 +582,8 @@ describe("legacy db reset", () => { dbUrl: Option.some("postgresql://localhost:54322/postgres"), version: Option.some("20240101000000"), }).pipe(Effect.provide(layer)); - expect(proxy.calls[0]!.args).toEqual([ - "db", - "reset", - "--db-url", - "postgresql://localhost:54322/postgres", - "--version", - "20240101000000", - ]); + expect(out.stderrText).toContain("Resetting local database to version: 20240101000000"); + expect(seam.recreateCalls).toEqual([{ version: "20240101000000", noSeed: false }]); }); }); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.layers.ts b/apps/cli/src/legacy/commands/db/reset/reset.layers.ts index e88a280f32..ef37ddbcc7 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.layers.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.layers.ts @@ -12,6 +12,7 @@ import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.laye import { legacyIdentityStitchLayer } from "../../../shared/legacy-identity-stitch.ts"; import { legacyLinkedProjectCacheLayer } from "../../../telemetry/legacy-linked-project-cache.layer.ts"; import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; +import { legacyDbBootstrapSeamLayer } from "../shared/legacy-db-bootstrap.seam.layer.ts"; /** * Runtime layer for `supabase db reset`. Same composition as `db push` / `db lint`: @@ -60,8 +61,15 @@ export const legacyDbResetRuntimeLayer = Layer.mergeAll( httpClient, credentials, projectRef, + // Exposed (not just provided to `projectRef`) because the local reset path reuses + // the seed-buckets core, whose `legacyResolveStorageCredentials` requires the + // (lazy) Management-API factory for the linked branch — never hit on `--local`, + // but a static service requirement of the shared core. + platformApiFactory, linkedProjectCache, legacyIdentityStitchLayer, legacyTelemetryStateLayer, + // Container-recreate / storage-health primitives for the native local reset. + legacyDbBootstrapSeamLayer.pipe(Layer.provide(cliConfig)), commandRuntimeLayer(["db", "reset"]), ); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts index 72f5f5d2e3..30c47ed2c4 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts @@ -166,9 +166,14 @@ export const legacyDbBootstrapSeamLayer = Layer.effect( ["--mode", "start", ...(fromBackup !== undefined ? ["--from-backup", fromBackup] : [])], false, ).pipe(Effect.asVoid), - recreateDatabase: ({ version }) => + recreateDatabase: ({ version, noSeed }) => runBootstrap( - ["--mode", "recreate", ...(version !== "" ? ["--version", version] : [])], + [ + "--mode", + "recreate", + ...(version !== "" ? ["--version", version] : []), + ...(noSeed ? ["--no-seed"] : []), + ], false, ).pipe(Effect.asVoid), awaitStorageReady: () => diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.service.ts b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.service.ts index 2f15b278cf..36124e0c52 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.service.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.service.ts @@ -42,10 +42,13 @@ interface LegacyDbBootstrapSeamShape { * migrate + seed up to `version`, and restart the satellite containers. The * caller has already printed `Resetting local database…`; the seam tees the * remaining progress (`Recreating database...`, `Restarting containers...`) to - * stderr. `version` is the resolved migration version ("" for all migrations). + * stderr. `version` is the resolved migration version ("" for all migrations); + * `noSeed` disables the seed inside the recreate's MigrateAndSeed, mirroring the + * `db reset --no-seed` handling (`cmd/db.go` `dbResetCmd`). */ readonly recreateDatabase: (opts: { readonly version: string; + readonly noSeed: boolean; }) => Effect.Effect; /** * The storage health gate local `db reset` runs before seeding buckets diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts index 9ddb8fdb86..9d4306fded 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts @@ -119,40 +119,33 @@ const legacyDecodeDefaultProjectConfig = Schema.decodeUnknownSync(ProjectConfigS * passed, the remote Storage gateway is used with the project's service-role key; * otherwise the local stack is used. */ -export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( - // Target is selected from the changed-flag set (Go's flag.Changed), not the - // parsed value, so the flags arg itself is unused here. - _flags: LegacyBucketsFlags, -) { +/** + * Core of `seed buckets`: load config (merging `[remotes.]` for a non-empty + * `projectRef`), validate bucket config, then upsert/seed buckets + objects against + * the Storage service gateway. Extracted from the command handler so `db reset + * --local` can reuse the exact local-seed path Go invokes via + * `buckets.Run(ctx, "", false, fsys)` (`internal/db/reset/reset.go:71`). + * + * `emitSummary` controls whether the machine-readable summary is written to stdout: + * the `seed buckets` command emits it; `db reset` does NOT (it emits its own + * result), matching Go where reset's `buckets.Run` prints nothing to stdout. + * + * The caller owns project-ref resolution, the linked-project cache write, and the + * telemetry flush (Go's PersistentPostRun) — this core does none of those. + */ +export const legacySeedBucketsRun = Effect.fnUntraced(function* (opts: { + readonly projectRef: string; + readonly emitSummary: boolean; +}) { const output = yield* Output; const cliConfig = yield* LegacyCliConfig; - const telemetryState = yield* LegacyTelemetryState; - const linkedProjectCache = yield* LegacyLinkedProjectCache; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const cliArgs = yield* CliArgs; // `--yes` OR `SUPABASE_YES` (Go's viper AutomaticEnv, root.go:318-320). const yes = yield* legacyResolveYes; + const { projectRef, emitSummary } = opts; - // Set once --linked resolves a ref; drives the post-run linked-project cache - // write + org/project group identify, mirroring Go's `ensureProjectGroupsCached` - // (`cmd/root.go`, gated on a non-empty `flags.ProjectRef`). Empty on the local - // path, so the cache is never written there. - let linkedRef = ""; - - yield* Effect.gen(function* () { - // 1. Resolve the project ref for --linked BEFORE loading config, so that - // the matching `[remotes.]` override (whose `project_id == ref`) is - // merged over the base config by `loadProjectConfig`. Go selects the target - // from `flag.Changed`, not the flag value: `--linked` is the linked path - // whenever it's *set* (even `--linked=false`). - const setFlags = legacySeedChangedTargetFlags(cliArgs.args); - const projectRefResolver = yield* LegacyProjectRefResolver; - const projectRef = setFlags.includes("linked") - ? yield* projectRefResolver.loadProjectRef(Option.none()) - : ""; - linkedRef = projectRef; - + { // 2. Load config.toml, passing projectRef so `[remotes.*]` overrides are // merged for --linked. A parse failure aborts before any network call. const loadOptions: LoadProjectConfigOptions | undefined = @@ -210,7 +203,7 @@ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( // 3d. Short-circuit: nothing to seed (ref present → never short-circuits). if (projectRef === "" && bucketNames.length === 0 && !hasVectorBuckets) { - if (output.format !== "text") { + if (emitSummary && output.format !== "text") { yield* output.success("", { ...emptySummary() }); } return; @@ -259,7 +252,7 @@ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( yield* uploadObjects(fs, path, output, gateway, cliConfig.workdir, bucketsConfig, summary); // 9. Machine-readable summary (Go has none; text mode emits nothing extra). - if (output.format !== "text") { + if (emitSummary && output.format !== "text") { yield* output.success("", { ...summary }); } }); @@ -270,6 +263,37 @@ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( legacyStorageGatewayFetch(credentials.localKongCa), ), ); + } +}); + +export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( + // Target is selected from the changed-flag set (Go's flag.Changed), not the + // parsed value, so the flags arg itself is unused here. + _flags: LegacyBucketsFlags, +) { + const telemetryState = yield* LegacyTelemetryState; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const cliArgs = yield* CliArgs; + + // Set once --linked resolves a ref; drives the post-run linked-project cache + // write + org/project group identify, mirroring Go's `ensureProjectGroupsCached` + // (`cmd/root.go`, gated on a non-empty `flags.ProjectRef`). Empty on the local + // path, so the cache is never written there. + let linkedRef = ""; + + yield* Effect.gen(function* () { + // 1. Resolve the project ref for --linked BEFORE loading config, so that + // the matching `[remotes.]` override (whose `project_id == ref`) is + // merged over the base config by `loadProjectConfig`. Go selects the target + // from `flag.Changed`, not the flag value: `--linked` is the linked path + // whenever it's *set* (even `--linked=false`). + const setFlags = legacySeedChangedTargetFlags(cliArgs.args); + const projectRefResolver = yield* LegacyProjectRefResolver; + const projectRef = setFlags.includes("linked") + ? yield* projectRefResolver.loadProjectRef(Option.none()) + : ""; + linkedRef = projectRef; + yield* legacySeedBucketsRun({ projectRef, emitSummary: true }); }).pipe( // Go's root `Execute` caches the linked project + fires org/project group // identify whenever `flags.ProjectRef` is set — only on the --linked path. From 5968f8346b7ffabe7e7d0cba7f05fd3826425431 Mon Sep 17 00:00:00 2001 From: avallete Date: Thu, 25 Jun 2026 16:01:14 +0200 Subject: [PATCH 13/29] test(cli): add db start / db reset legacy e2e smokes Real-subprocess golden-path e2e for the native db start / db reset commands, matching the Docker-free convention of the sibling legacy e2e tests (db diff, seed buckets): db reset's pre-split flag validations (mutually-exclusive targets, invalid --version, --version+--last), and db start's config-parse failure that aborts before the running check. Full container behavior is covered by the integration suites with the bootstrap seam mocked. Co-Authored-By: Claude Opus 4.8 --- .../commands/db/reset/reset.e2e.test.ts | 62 +++++++++++++++++++ .../commands/db/start/start.e2e.test.ts | 42 +++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 apps/cli/src/legacy/commands/db/reset/reset.e2e.test.ts create mode 100644 apps/cli/src/legacy/commands/db/start/start.e2e.test.ts diff --git a/apps/cli/src/legacy/commands/db/reset/reset.e2e.test.ts b/apps/cli/src/legacy/commands/db/reset/reset.e2e.test.ts new file mode 100644 index 0000000000..3095c918eb --- /dev/null +++ b/apps/cli/src/legacy/commands/db/reset/reset.e2e.test.ts @@ -0,0 +1,62 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; + +import { runSupabase } from "../../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; + +/** + * Golden-path e2e: exercises the real compiled-binary boundary for the + * Docker-free paths of `db reset` — the flag validations that run BEFORE the + * local/remote split (so no container, config override, or seam subprocess is + * touched). The full local/remote reset behavior is covered by the integration + * suite with the bootstrap seam and DB connection mocked; booting a real stack is + * deliberately out of scope here (matching the sibling `db diff` / `seed buckets` + * legacy e2e tests). + */ +describe("supabase db reset (legacy)", () => { + let projectDir: string; + + beforeAll(() => { + projectDir = mkdtempSync(join(tmpdir(), "supabase-db-reset-e2e-")); + mkdirSync(join(projectDir, "supabase"), { recursive: true }); + writeFileSync(join(projectDir, "supabase", "config.toml"), 'project_id = "test"\n'); + }); + + afterAll(() => { + rmSync(projectDir, { recursive: true, force: true }); + }); + + test("rejects mutually exclusive target flags", { timeout: E2E_TIMEOUT_MS }, async () => { + const { exitCode, stdout, stderr } = await runSupabase(["db", "reset", "--linked", "--local"], { + entrypoint: "legacy", + cwd: projectDir, + }); + expect(exitCode).toBe(1); + expect(`${stdout}${stderr}`).toContain( + "if any flags in the group [db-url linked local] are set none of the others can be", + ); + }); + + test("rejects a non-integer --version", { timeout: E2E_TIMEOUT_MS }, async () => { + const { exitCode, stdout, stderr } = await runSupabase( + ["db", "reset", "--version", "not-a-number"], + { entrypoint: "legacy", cwd: projectDir }, + ); + expect(exitCode).toBe(1); + expect(`${stdout}${stderr}`).toContain("invalid version number"); + }); + + test("rejects --version together with --last", { timeout: E2E_TIMEOUT_MS }, async () => { + const { exitCode, stdout, stderr } = await runSupabase( + ["db", "reset", "--linked", "--version", "20240101000000", "--last", "1"], + { entrypoint: "legacy", cwd: projectDir }, + ); + expect(exitCode).toBe(1); + expect(`${stdout}${stderr}`).toContain( + "if any flags in the group [last version] are set none of the others can be", + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/start/start.e2e.test.ts b/apps/cli/src/legacy/commands/db/start/start.e2e.test.ts new file mode 100644 index 0000000000..ec031f3e1d --- /dev/null +++ b/apps/cli/src/legacy/commands/db/start/start.e2e.test.ts @@ -0,0 +1,42 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; + +import { runSupabase } from "../../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; + +/** + * Golden-path e2e: exercises the real compiled-binary boundary for the one + * environment-independent path of `db start` — a malformed `config.toml`, which + * the handler validates BEFORE the "already running?" probe, so it fails fast + * without touching Docker. The container-bootstrap behavior (the running check, + * the `__db-bootstrap` seam, the "already running" line) is covered by the + * integration suite with the seam mocked; booting a real Postgres container is + * deliberately out of scope here (matching the sibling `db diff` / `seed buckets` + * legacy e2e tests, none of which boot a live stack). + */ +describe("supabase db start (legacy)", () => { + let projectDir: string; + + beforeAll(() => { + projectDir = mkdtempSync(join(tmpdir(), "supabase-db-start-e2e-")); + mkdirSync(join(projectDir, "supabase"), { recursive: true }); + // Invalid TOML — aborts config loading before any container work. + writeFileSync(join(projectDir, "supabase", "config.toml"), 'project_id = "unterminated\n'); + }); + + afterAll(() => { + rmSync(projectDir, { recursive: true, force: true }); + }); + + test("fails fast on a malformed config.toml", { timeout: E2E_TIMEOUT_MS }, async () => { + const { exitCode, stdout, stderr } = await runSupabase(["db", "start"], { + entrypoint: "legacy", + cwd: projectDir, + }); + expect(exitCode).toBe(1); + expect(`${stdout}${stderr}`).toContain("failed to parse supabase/config.toml"); + }); +}); From 0897515f712bc54bbd27c7f50928c1767c99ad13 Mon Sep 17 00:00:00 2001 From: avallete Date: Thu, 25 Jun 2026 16:01:38 +0200 Subject: [PATCH 14/29] docs(cli): remove completed CLI-1325 Stage 3 handoff The Stage 3 work (native db start, native db reset --local, the hidden Go db __db-bootstrap seam) is implemented, tested, and documented in the command SIDE_EFFECTS.md files and go-cli-porting-status.md. The handoff note described this as remaining/unvalidated work and is now obsolete. Co-Authored-By: Claude Opus 4.8 --- apps/cli/docs/CLI-1325-STAGE3-HANDOFF.md | 290 ----------------------- 1 file changed, 290 deletions(-) delete mode 100644 apps/cli/docs/CLI-1325-STAGE3-HANDOFF.md diff --git a/apps/cli/docs/CLI-1325-STAGE3-HANDOFF.md b/apps/cli/docs/CLI-1325-STAGE3-HANDOFF.md deleted file mode 100644 index 5e66277d65..0000000000 --- a/apps/cli/docs/CLI-1325-STAGE3-HANDOFF.md +++ /dev/null @@ -1,290 +0,0 @@ -# CLI-1325 — Stage 3 Handoff (`db start` + `db reset --local`) - -> **Purpose**: hand off the remaining work on CLI-1325 ("Port `supabase db reset` / -> `supabase db push` / `supabase db start`") to a local agent that has Docker and -> can build the Go binary. Stages 1–2 are done and on this branch -> (`claude/gifted-knuth-mslxnh`). Stage 3 is the container-bootstrap work that -> could not be validated in the cloud environment. - ---- - -## 1. Context - -- **Repo**: `supabase/cli`. Branch: `claude/gifted-knuth-mslxnh`. -- **What this is**: a strict **1:1 port** of the Go CLI into the TypeScript - **legacy shell** at `apps/cli/src/legacy/`. The authoritative reference is the - vendored Go source at `apps/cli-go/`. Match Go's stdout/stderr text, flags, - filesystem effects, API routes, and exit codes **exactly**. -- **Read first**: `apps/cli/CLAUDE.md` (the legacy-port playbook — naming, telemetry - parity, `--output-format`, SIDE_EFFECTS, testing rules) and the repo-root - `CLAUDE.md`. -- **Chosen architecture for Stage 3** (decided with the issue owner): a **hidden Go - seam**. Native TS orchestrates; container provisioning that isn't ported stays in - Go behind a hidden `__db-bootstrap` command, mirroring the existing `__shadow` - seam at `apps/cli-go/cmd/db.go:219`. The TS side shells out to it via - `LegacyGoProxy`. - ---- - -## 2. What is already done (Stages 1–2, on this branch) - -| Command | Status | Where | -| ---------- | ------------------------------------------------------------------------- | ---------------------------------------- | -| `db push` | **ported** (fully native) | `apps/cli/src/legacy/commands/db/push/` | -| `db reset` | **partial** — remote path native; local + `--experimental` delegate to Go | `apps/cli/src/legacy/commands/db/reset/` | -| `db start` | **wrapped** (Go proxy, untouched) | `apps/cli/src/legacy/commands/db/start/` | - -Commits (newest first): `docs(cli): document db reset…`, `feat(cli): implement -native db reset remote path`, `docs(cli): document db push…`, `test(cli): expand db -push coverage…`, `test(cli): integration tests for native db push`, `feat(cli): -implement native db push handler`, `feat(cli): add vault upsert…`, `feat(cli): add -seed-file ops…`, `feat(cli): add pending-migration reconciliation…`. - -### Reusable shared helpers already built (use these — do not re-implement) - -All under `apps/cli/src/legacy/`: - -- `commands/db/shared/legacy-migration-pending.ts` — `legacyFindPendingMigrations`, - `legacyIncludeAllPending`, `legacySuggestRevertHistory`, `legacySuggestIgnoreFlag`. -- `commands/db/shared/legacy-seed-ops.ts` — `legacyGetPendingSeeds`, - `legacySeedData`, `LegacySeedFile`, `legacyMatchPattern` (Go `fs.Glob`/`path.Match` - port). Seed paths resolve under `supabase/` and dedupe like Go's `config.Glob.Files`. -- `commands/db/shared/legacy-vault.ts` — `legacyUpsertVaultSecrets`, - `legacyReadVaultDocument`, `legacySyncableVaultSecrets`. **Gap**: `encrypted:` - vault secrets are skipped (ECIES/dotenvx decryption not ported). -- `commands/db/shared/legacy-drop-schemas.ts` — `legacyDropUserSchemas` (embedded - `drop.sql` `DO` block). -- `shared/legacy-migration-apply.ts` — `legacyApplyMigrations` (emits - `Applying migration ...`), `legacySeedGlobals`, `legacyApplyMigrationFile`. -- `commands/db/shared/legacy-pgdelta.cache.ts` — `legacyListLocalMigrations` - (Go-faithful local migration listing, with the deprecated-init skip). - -### Existing Docker / container infra in the legacy shell (for Stage 3) - -- `shared/legacy-docker-run.service.ts` + `.layer.ts` — `LegacyDockerRun` - (`run`/`runCapture`/`runStream`). -- `shared/legacy-container-cli.ts` — `LegacyContainerCli`. -- `shared/legacy-docker-registry.ts` — `LegacyDockerRegistry`. -- `shared/legacy/go-proxy.service.ts` — `LegacyGoProxy` (`exec`, `execCapture`), - **ambient** (provided at root in `legacy/cli/root.ts`). - -### Patterns established in Stages 1–2 (copy these) - -- Command file wires `withLegacyCommandInstrumentation({ flags, safeFlags? })` + - `withJsonErrorHandling` + `Command.provide()`. -- Runtime layers mirror `commands/db/push/push.layers.ts` (and `lint.layers.ts`): - lazy `legacyPlatformApiFactoryLayer` so `--local`/`--db-url` never resolve a token - at layer-build time; single shared `legacyIdentityStitchLayer`. -- Handler body wrapped in `.pipe(Effect.ensuring(linkedProjectCache.cache(ref)), -Effect.ensuring(telemetryState.flush))`. -- **Delegating to Go without double-counting telemetry**: call - `proxy.exec(args, { env: { SUPABASE_TELEMETRY_DISABLED: "1" } })`. The TS - instrumentation wrapper then fires `cli_command_executed` exactly once. This is - how `db reset`'s local/experimental paths already work - (`reset.handler.ts:147,154`). - ---- - -## 3. Stage 3 scope - -Make `db start` native, and replace `db reset --local`'s Go delegation -(`reset.handler.ts:146-149`) with a native local reset. Both lean on a new hidden -Go seam for the parts that aren't ported (container create/recreate, init schema, -service restarts). - -### 3a. Go behavior to match — `db start` (`apps/cli-go/internal/db/start/start.go`) - -Entry: `cmd/db.go:337` → `start.Run(ctx, fromBackup, fsys)`. Flag: `--from-backup` -(string, `cmd/db.go:590`). `Run` (lines 44-61): - -1. `flags.LoadConfig(fsys)`. -2. `AssertSupabaseDbIsRunning()`: if already running → `fmt.Fprintln(os.Stderr, -"Postgres database is already running.")` and **return nil** (exit 0). If the - error is anything other than `utils.ErrNotRunning`, return it. -3. `StartDatabase(ctx, fromBackup, fsys, os.Stderr)`; on error, - `utils.DockerRemoveAll(...)` cleanup then return the error. - -`StartDatabase` (lines 133-190): builds container/host/network config -(`NewContainerConfig`/`NewHostConfig`), handles `--from-backup` (restore entrypoint - -- bind mount `/etc/backup.sql:ro`), inspects the db volume to set - `utils.NoBackupVolume`, then: - -* `NoBackupVolume` → `Starting database...`; else if `--from-backup` → - `Starting database from backup...` (lines 168-174; both to the writer `w` = - stderr). -* `WaitForHealthyService(ctx, Config.Db.HealthTimeout, utils.DbId)` — health check - **skipped** when `--from-backup` is set (line 180). -* If `NoBackupVolume && no --from-backup` → `SetupLocalDatabase(ctx, "", fsys, w)` - (line 185), which prints `Initialising schema...` (line 244) and applies initial - schema + roles + migrations + seed. -* `initCurrentBranch(fsys)` (line 189) — writes the `_current_branch` file. - -**Important parity facts**: `db start` does **NOT** print the full status table and -does **NOT** fire `cli_stack_started` — those belong to the top-level `supabase -start` (`internal/start/start.go`), not `db start`. No `Finished` line. - -### 3b. Go behavior to match — `db reset` local (`apps/cli-go/internal/db/reset/reset.go`) - -The local path is reached when `utils.IsLocalDatabase(config)` is true (Go -`reset.go:53`). In the TS handler this is the `cfg.isLocal` branch currently -delegating to Go (`reset.handler.ts:146`). Version/`--last` resolution -(`reset.go:34-52`) is **already ported** and runs before the split — reuse it. - -Local flow (`reset.go:57-77`): - -1. `AssertSupabaseDbIsRunning()` — error if the db container isn't up. -2. `resetDatabase(ctx, version, fsys)` → `Resetting local database` - (line 81), then branch on `Config.Db.MajorVersion`: - - **≤ 14** → `resetDatabase14` (line 95): `recreateDatabase` (drop/recreate - `postgres` + `_supabase` dbs), `initDatabase`, `RestartDatabase` - (`Restarting containers...`), connect, `apply.MigrateAndSeed(ctx, version, -conn, fsys)`. - - **≥ 15** → `resetDatabase15` (line 113): `Docker.ContainerRemove(DbId, Force)`, - `Docker.VolumeRemove(DbId, force)`, `Recreating database...` (line 129), - recreate container via `DockerStart(NewContainerConfig()/NewHostConfig())`, - wait healthy, `start.SetupLocalDatabase(ctx, version, fsys)`, - `Restarting containers...` (line 139), `restartServices(ctx)` (line 140). -3. If the storage container is healthy → `buckets.Run(ctx, "", false, fsys)` to seed - `supabase/buckets/` (reset.go:65-74). The **legacy `seed buckets` command is - already ported** (`commands/seed/buckets/`) — reuse `legacySeedBuckets`/its core. -4. `branch := utils.GetGitBranch(fsys)`; `Finished supabase db reset on branch -.` to stderr (line 76; `supabase db reset` and `` are Aqua). - -`restartServices` (reset.go:226-240) restarts `[StorageId, GotrueId, RealtimeId, -PoolerId]`. `apply.MigrateAndSeed` is the same routine `db reset` remote uses — the -TS equivalent (drop is NOT done locally; instead the db is recreated) is -`legacyApplyMigrations` (partial migrations) + `legacyGetPendingSeeds` + -`legacySeedData`, already wired in `reset.handler.ts` for the remote path. Factor -that "migrate + seed against a connected session" block into a shared helper so both -the remote and local paths call it. - -### 3c. The hidden `__db-bootstrap` Go seam - -Mirror `__shadow` (`apps/cli-go/cmd/db.go:219-268`). Add a hidden command that -exposes the un-ported container primitives so the TS side can invoke them and then -do the SQL orchestration itself (connect, migrate, seed, restart). Suggested -sub-operations (drive via a `--mode` flag like `__shadow`): - -- `start` → `start.StartDatabase(fromBackup)` (create + health + `SetupLocalDatabase` - - `initCurrentBranch`). -- `recreate` → `reset.resetDatabase15` container remove/volume-remove/recreate + - `SetupLocalDatabase(version)` (the PG15 path), or `resetDatabase14` for PG≤14. -- `restart-services` → `reset.restartServices`. -- An "is running" probe so the TS side can replicate `AssertSupabaseDbIsRunning` - without porting Docker inspect (or use `LegacyDockerRun`/`LegacyContainerCli`). - -Decide how much SQL stays native vs behind the seam. The thinnest correct split: -the seam does **only** container lifecycle + `SetupLocalDatabase` (initial schema / -roles / first migrate+seed), and the TS side does the "already running?" check, -user-facing messages, the bucket seeding (reuse `legacySeedBuckets`), the -git-branch `Finished …` line, and `--output-format` shaping. Keep the seam's stdout -machine-parseable (newline-separated, no secrets) like `__shadow`. - -When you add the seam: update the three `__shadow`-style steps in `cmd/db.go`, -rebuild the bundled Go binary (`apps/cli/scripts` / `pnpm --filter @supabase/cli -build:go-sidecar` — check `apps/cli/package.json` scripts), and confirm -`LegacyGoProxy` can reach it. - ---- - -## 4. Deliverables checklist (Stage 3) - -- [ ] `db start` native handler + `start.layers.ts` + `start.errors.ts`; replace the - proxy in `commands/db/start/`. Match every string in §3a. No `cli_stack_started`. -- [ ] `db reset` local path native in `reset.handler.ts` (replace the `cfg.isLocal` - Go delegation); reuse the shared migrate+seed block and `legacySeedBuckets`. -- [ ] Hidden `__db-bootstrap` Go command in `apps/cli-go/cmd/db.go`; rebuild the - bundled binary. -- [ ] Shared helper for "migrate + seed against a connected session" (extract from - the remote reset path so local + remote share it). -- [ ] `commands/db/start/SIDE_EFFECTS.md` (rewrite from proxy stub) and update - `commands/db/reset/SIDE_EFFECTS.md` for the now-native local path. -- [ ] Integration tests (handler, ~100% branch — see precedent: one unreachable - defensive guard is acceptable, as in push/reset). Mock `LegacyDockerRun` / - the seam / `LegacyDbConnection`. -- [ ] **E2E** (`*.e2e.test.ts`) golden paths via `tests/helpers/cli.ts` `runSupabase` - against a **real local stack** — this is the part requiring Docker. Cover - `db start` (fresh + already-running) and `db reset` local. -- [ ] Flip `db start` → `ported` and `db reset` → `ported` in - `apps/cli/docs/go-cli-porting-status.md` (two tables: the leaf table ~line 89-91 - and the status table ~line 303-307). -- [ ] Telemetry: confirm no custom events for `db start`/`db reset` (none in Go); - keep `withLegacyCommandInstrumentation`. - ---- - -## 5. How to build / test / verify in this repo - -`nx` is **not** on PATH; invoke tools directly from `apps/cli/`: - -```sh -# from repo root, once: -pnpm install # (and `pnpm repos:install` if .repos/effect is missing) - -# from apps/cli/ : -# typecheck (tsgo, the repo's TS checker): -./node_modules/.bin/tsgo --noEmit -p tsconfig.json - -# tests MUST run under the Bun runtime (they import @effect/platform-bun): -bun --bun ./node_modules/vitest/vitest.mjs run --project unit -bun --bun ./node_modules/vitest/vitest.mjs run --project integration -# coverage (istanbul) for a handler — aim ~100% branch: -bun --bun ./node_modules/vitest/vitest.mjs run --project integration \ - --coverage --coverage.reporter=json --coverage.reportsDirectory=/tmp/cov \ - --coverage.include='src/legacy/commands/db/start/start.handler.ts' - -# e2e (needs Docker; do not run the full suite — target the file): -bun --bun ./node_modules/vitest/vitest.mjs run --project e2e .e2e.test.ts - -# lint / format / unused-exports: -./node_modules/.bin/oxfmt # writes; add --check to verify -./node_modules/.bin/oxlint -./node_modules/.bin/knip # de-export internal-only helpers it flags -``` - -The canonical full gate (CLAUDE.md): `bun run test` + `bun run --parallel "*:check"` -(these go through `nx`; if `nx` is unavailable, run the direct commands above). - ---- - -## 6. Gotchas / parity rules learned in Stages 1–2 - -- **Bun runtime for tests**: plain `pnpm vitest` fails (`Cannot find package 'bun'`); - always `bun --bun ./node_modules/vitest/vitest.mjs`. -- **Telemetry double-count**: any Go delegation must pass - `env: { SUPABASE_TELEMETRY_DISABLED: "1" }` so the child doesn't also emit - `cli_command_executed`. -- **stdout vs stderr**: progress/diagnostics → stderr; the only stdout text Go emits - for these commands is suppressed in `json`/`stream-json` mode (emit a structured - `output.success(...)` instead). See CLI-1546 notes in `apps/cli/CLAUDE.md`. -- **Colors**: match Go's `utils.Aqua` (cyan) / `utils.Bold` via `legacy-colors.ts` - (`legacyAqua`, `legacyBold`). Tests assert with `toContain` to tolerate ANSI. -- **`db start` connects via `io.Discard` in `db reset` remote** — note `db start` - itself writes progress to stderr; double-check each `Fprintln(w, …)` target. -- **Coverage**: handler integration tests target 100% branch; a single genuinely - unreachable defensive guard is acceptable (push and reset each have one). - Relocate defensive parsing into unit-tested pure helpers where practical (see how - `legacyReadVaultDocument` was moved into `legacy-vault.ts`). -- **Config access**: use `loadProjectConfig(workdir, { projectRef? })` from - `@supabase/config`; `config.db.{migrations.enabled, seed.enabled, seed.sql_paths}`; - raw `[db.vault]` via the returned `document`. `MajorVersion` is at - `config.db.major_version` — needed for the PG14 vs PG15 reset branch. -- **`--no-seed`**: forces seed disabled (Go sets `Config.Db.Seed.Enabled = false`). - ---- - -## 7. Key Go reference files (read these on the local machine) - -- `apps/cli-go/cmd/db.go` — flag defs + `__shadow` seam to mirror (lines ~219-268, - 337-343, 567-591). -- `apps/cli-go/internal/db/start/start.go` — `Run`, `StartDatabase`, - `SetupLocalDatabase`, `WaitForHealthyService`, `initCurrentBranch`, - `NewContainerConfig`/`NewHostConfig`. -- `apps/cli-go/internal/db/reset/reset.go` — local `resetDatabase14`/`15`, - `recreateDatabase`, `initDatabase`, `RestartDatabase`, `restartServices`, - `toLogMessage`. -- `apps/cli-go/internal/migration/apply/apply.go` — `MigrateAndSeed` (already - mirrored in the TS remote reset path). -- `apps/cli-go/internal/seed/buckets/buckets.go` — bucket seeding invoked by local - reset (TS port exists at `commands/seed/buckets/`). From e5920058cb9e972ffd435deea129f1018be5f2d2 Mon Sep 17 00:00:00 2001 From: avallete Date: Thu, 25 Jun 2026 18:04:40 +0200 Subject: [PATCH 15/29] test(cli): add gated real-Docker live e2e for db start / db reset --local MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the one boundary the integration suites mock — the db __db-bootstrap Go seam driving real Docker. Boots an actual local Postgres container and exercises db start (fresh), db start (already-running, exit 0), and db reset --local (recreate + branch line), tearing the stack down in afterAll. Gated behind SUPABASE_E2E_DOCKER=1 (skipped by default) so it never runs in the normal feedback loop / default e2e suite. Validated locally against Docker 29.4.0 (3/3 pass, ~60s with images cached). Co-Authored-By: Claude Opus 4.8 --- .../db/db-local-stack.live.e2e.test.ts | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 apps/cli/src/legacy/commands/db/db-local-stack.live.e2e.test.ts diff --git a/apps/cli/src/legacy/commands/db/db-local-stack.live.e2e.test.ts b/apps/cli/src/legacy/commands/db/db-local-stack.live.e2e.test.ts new file mode 100644 index 0000000000..bce6c9a48b --- /dev/null +++ b/apps/cli/src/legacy/commands/db/db-local-stack.live.e2e.test.ts @@ -0,0 +1,103 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; + +import { makeTempHome, runSupabase } from "../../../../tests/helpers/cli.ts"; + +/** + * Real-stack live e2e for the native `db start` / `db reset --local` ports — the + * one boundary the integration suites mock (the `db __db-bootstrap` Go seam + + * real Docker). It boots an actual local Postgres container, so it is **opt-in**: + * gated behind `SUPABASE_E2E_DOCKER=1` and skipped by default (it must NOT run in + * the normal feedback loop / CI e2e default — booting + pulling images is slow). + * + * Run locally with a working Docker daemon: + * pnpm build:go-sidecar && pnpm build:legacy && pnpm build:shim + * SUPABASE_E2E_DOCKER=1 bun --bun ./node_modules/vitest/vitest.mjs run \ + * --project e2e src/legacy/commands/db/db-local-stack.live.e2e.test.ts + * + * Tests share one stack and run in declaration order (the e2e project is + * sequential); `afterAll` tears the stack down even on failure. + */ +const dockerEnabled = process.env["SUPABASE_E2E_DOCKER"] === "1"; + +// First `db start` pulls the Postgres + service images; subsequent ops are fast. +const START_TIMEOUT_MS = 600_000; +const RESET_TIMEOUT_MS = 180_000; + +describe.skipIf(!dockerEnabled)("db start / db reset --local (live, real Docker)", () => { + let projectDir: string; + let home: ReturnType; + + beforeAll(async () => { + home = makeTempHome(); + projectDir = mkdtempSync(join(tmpdir(), "supabase-db-local-stack-e2e-")); + // `init` writes a full default config.toml (db image, ports, services). + const init = await runSupabase(["init"], { + entrypoint: "legacy", + cwd: projectDir, + home: home.dir, + exitTimeoutMs: 60_000, + }); + expect(init.exitCode, init.stderr).toBe(0); + }, 120_000); + + afterAll(async () => { + // Tear the stack down (legacy proxies `stop` to the Go binary) even if a + // test failed, then drop the temp project. HOME is cleaned by the harness. + if (projectDir !== undefined) { + await runSupabase(["stop", "--no-backup"], { + entrypoint: "legacy", + cwd: projectDir, + home: home.dir, + exitTimeoutMs: 120_000, + }).catch(() => undefined); + rmSync(projectDir, { recursive: true, force: true }); + } + }, 180_000); + + test("db start boots the local Postgres container", { timeout: START_TIMEOUT_MS }, async () => { + const { exitCode, stdout, stderr } = await runSupabase(["db", "start"], { + entrypoint: "legacy", + cwd: projectDir, + home: home.dir, + exitTimeoutMs: START_TIMEOUT_MS, + }); + expect(exitCode, stderr).toBe(0); + // The Go seam tees bootstrap progress to stderr (mode-independent). + expect(`${stdout}${stderr}`).toContain("Starting database"); + }); + + test( + "db start is a no-op (exit 0) when already running", + { timeout: RESET_TIMEOUT_MS }, + async () => { + const { exitCode, stdout, stderr } = await runSupabase(["db", "start"], { + entrypoint: "legacy", + cwd: projectDir, + home: home.dir, + exitTimeoutMs: RESET_TIMEOUT_MS, + }); + expect(exitCode).toBe(0); + // text mode → stderr line; agent mode → stdout JSON status. Match either. + expect(`${stdout}${stderr}`).toMatch(/already[\s-]running/i); + }, + ); + + test( + "db reset --local recreates the database and prints the branch line", + { timeout: RESET_TIMEOUT_MS }, + async () => { + const { exitCode, stderr } = await runSupabase(["db", "reset", "--local"], { + entrypoint: "legacy", + cwd: projectDir, + home: home.dir, + exitTimeoutMs: RESET_TIMEOUT_MS, + }); + expect(exitCode, stderr).toBe(0); + // "Finished supabase db reset on branch ." goes to stderr (ANSI-wrapped). + expect(stderr).toContain("on branch "); + }, + ); +}); From 0437f441b284e2bef55ac12ffe497d5dd87a5611 Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 26 Jun 2026 15:47:07 +0200 Subject: [PATCH 16/29] test(cli-e2e): add live db start / db reset --local real-stack coverage Adds a live-suite test in apps/cli-e2e that boots a real local Postgres container via the compiled CLI and drives db start (fresh), db start (already-running, idempotent), and db reset --local (recreate + branch line), stopping the stack in finally. Runs through the cli-e2e harness against the real Docker socket the live setup wires up, exercising the hidden db __db-bootstrap Go seam end-to-end across the go + ts-legacy targets (skipped for ts-next, which has no db group). Inert on replay/PR runs (testLive skips unless CLI_E2E_MODE=live); runs under pnpm --filter @supabase/cli-e2e test:e2e:live. Co-Authored-By: Claude Opus 4.8 --- .../live/db-local-stack.live.e2e.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 apps/cli-e2e/src/tests/live/db-local-stack.live.e2e.test.ts diff --git a/apps/cli-e2e/src/tests/live/db-local-stack.live.e2e.test.ts b/apps/cli-e2e/src/tests/live/db-local-stack.live.e2e.test.ts new file mode 100644 index 0000000000..aeccb7db56 --- /dev/null +++ b/apps/cli-e2e/src/tests/live/db-local-stack.live.e2e.test.ts @@ -0,0 +1,43 @@ +import { describe, expect } from "vitest"; +import { TARGET } from "../env.ts"; +import { testLive } from "./live-context.ts"; + +// Real-stack live coverage for the native `db start` / `db reset --local` ports. +// These are local-Docker commands (they boot/recreate the local Postgres +// container), so they exercise the hidden `db __db-bootstrap` Go seam end-to-end +// against the real Docker socket the live harness wires up — the one boundary the +// in-process integration suites mock. +// +// `db start` / `db reset` live only in the `go` reference and the `ts-legacy` +// port (the `next` shell has no `db` group), so skip the `ts-next` target. +// +// The whole start → already-running → reset cycle runs in one test so it shares a +// single booted stack, and `finally` stops it (legacy proxies `stop` to Go) so the +// run never leaves containers behind. Each test gets a fresh init-generated +// workspace, so the project id (and container names) never collide across targets. +describe.skipIf(TARGET === "ts-next")("db local stack (live, real Docker)", () => { + testLive( + "db start boots, is idempotent, and db reset --local recreates", + { timeout: 600_000 }, + async ({ run }) => { + try { + const start = await run(["db", "start"]); + expect(start.exitCode, start.stderr).toBe(0); + // Go tees bootstrap progress to stderr (mode-independent). + expect(`${start.stdout}${start.stderr}`).toMatch(/Starting database|Initialising schema/i); + + // Second start is a no-op: the db is already running, exit 0. + const again = await run(["db", "start"]); + expect(again.exitCode, again.stderr).toBe(0); + expect(`${again.stdout}${again.stderr}`).toMatch(/already[\s-]running/i); + + // Local reset recreates the container and prints the git-branch line. + const reset = await run(["db", "reset", "--local"]); + expect(reset.exitCode, reset.stderr).toBe(0); + expect(reset.stderr).toContain("on branch "); + } finally { + await run(["stop", "--no-backup"]).catch(() => undefined); + } + }, + ); +}); From 73252bd3a5862c709b07cab28b238fe07de69959 Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 26 Jun 2026 16:20:54 +0200 Subject: [PATCH 17/29] test(cli-e2e): cover db reset remote leg; drop env-gated local live test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the remote (`--db-url` over the staging session pooler) leg of `db reset` to the live suite: drop user schemas → re-apply a local migration → seed against a real Postgres, verified via `migration list`. `db start` has no remote leg. Fold the local-leg coverage (db start / db reset --local, real Docker seam) and the new remote leg into one `db-reset-start.live.e2e.test.ts`, both gated to skip the `ts-next` target (no `db` group there). Remove the `SUPABASE_E2E_DOCKER`-gated apps/cli live test: `*.live` tests belong in the cli-e2e live suite, which already runs only in Docker-available environments (gated by `CLI_E2E_MODE=live`); a separate env flag was the wrong pattern and duplicated this coverage. Co-Authored-By: Claude Opus 4.8 --- .../live/db-local-stack.live.e2e.test.ts | 43 -------- .../live/db-reset-start.live.e2e.test.ts | 81 ++++++++++++++ .../db/db-local-stack.live.e2e.test.ts | 103 ------------------ 3 files changed, 81 insertions(+), 146 deletions(-) delete mode 100644 apps/cli-e2e/src/tests/live/db-local-stack.live.e2e.test.ts create mode 100644 apps/cli-e2e/src/tests/live/db-reset-start.live.e2e.test.ts delete mode 100644 apps/cli/src/legacy/commands/db/db-local-stack.live.e2e.test.ts diff --git a/apps/cli-e2e/src/tests/live/db-local-stack.live.e2e.test.ts b/apps/cli-e2e/src/tests/live/db-local-stack.live.e2e.test.ts deleted file mode 100644 index aeccb7db56..0000000000 --- a/apps/cli-e2e/src/tests/live/db-local-stack.live.e2e.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect } from "vitest"; -import { TARGET } from "../env.ts"; -import { testLive } from "./live-context.ts"; - -// Real-stack live coverage for the native `db start` / `db reset --local` ports. -// These are local-Docker commands (they boot/recreate the local Postgres -// container), so they exercise the hidden `db __db-bootstrap` Go seam end-to-end -// against the real Docker socket the live harness wires up — the one boundary the -// in-process integration suites mock. -// -// `db start` / `db reset` live only in the `go` reference and the `ts-legacy` -// port (the `next` shell has no `db` group), so skip the `ts-next` target. -// -// The whole start → already-running → reset cycle runs in one test so it shares a -// single booted stack, and `finally` stops it (legacy proxies `stop` to Go) so the -// run never leaves containers behind. Each test gets a fresh init-generated -// workspace, so the project id (and container names) never collide across targets. -describe.skipIf(TARGET === "ts-next")("db local stack (live, real Docker)", () => { - testLive( - "db start boots, is idempotent, and db reset --local recreates", - { timeout: 600_000 }, - async ({ run }) => { - try { - const start = await run(["db", "start"]); - expect(start.exitCode, start.stderr).toBe(0); - // Go tees bootstrap progress to stderr (mode-independent). - expect(`${start.stdout}${start.stderr}`).toMatch(/Starting database|Initialising schema/i); - - // Second start is a no-op: the db is already running, exit 0. - const again = await run(["db", "start"]); - expect(again.exitCode, again.stderr).toBe(0); - expect(`${again.stdout}${again.stderr}`).toMatch(/already[\s-]running/i); - - // Local reset recreates the container and prints the git-branch line. - const reset = await run(["db", "reset", "--local"]); - expect(reset.exitCode, reset.stderr).toBe(0); - expect(reset.stderr).toContain("on branch "); - } finally { - await run(["stop", "--no-backup"]).catch(() => undefined); - } - }, - ); -}); diff --git a/apps/cli-e2e/src/tests/live/db-reset-start.live.e2e.test.ts b/apps/cli-e2e/src/tests/live/db-reset-start.live.e2e.test.ts new file mode 100644 index 0000000000..9c2ec6f8e7 --- /dev/null +++ b/apps/cli-e2e/src/tests/live/db-reset-start.live.e2e.test.ts @@ -0,0 +1,81 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect } from "vitest"; +import { TARGET } from "../env.ts"; +import { testLive } from "./live-context.ts"; + +// Real-backend live coverage for the native `db start` / `db reset` ports. +// +// `db start` / `db reset` live only in the `go` reference and the `ts-legacy` +// port (the `next` shell has no `db` group), so skip the `ts-next` target. +// +// The live suite runs serially (`fileParallelism: false`, `maxWorkers: 1`), so the +// destructive remote reset below is safe against the throwaway per-run project. + +// --- Local leg: db start + db reset --local against the real Docker socket ----- +// Exercises the hidden `db __db-bootstrap` Go seam end-to-end — the boundary the +// in-process integration suites mock. The start → already-running → reset cycle +// runs in one test so it shares a single booted stack, and `finally` stops it +// (legacy proxies `stop` to Go) so the run never leaves containers behind. +describe.skipIf(TARGET === "ts-next")("db start / db reset --local (live, local Docker)", () => { + testLive( + "db start boots, is idempotent, and db reset --local recreates", + { timeout: 600_000 }, + async ({ run }) => { + try { + const start = await run(["db", "start"]); + expect(start.exitCode, start.stderr).toBe(0); + // Go tees bootstrap progress to stderr (mode-independent). + expect(`${start.stdout}${start.stderr}`).toMatch(/Starting database|Initialising schema/i); + + // Second start is a no-op: the db is already running, exit 0. + const again = await run(["db", "start"]); + expect(again.exitCode, again.stderr).toBe(0); + expect(`${again.stdout}${again.stderr}`).toMatch(/already[\s-]running/i); + + // Local reset recreates the container and prints the git-branch line. + const reset = await run(["db", "reset", "--local"]); + expect(reset.exitCode, reset.stderr).toBe(0); + expect(reset.stderr).toContain("on branch "); + } finally { + await run(["stop", "--no-backup"]).catch(() => undefined); + } + }, + ); +}); + +// --- Remote leg: db reset against the staging project over the session pooler --- +// Exercises the native remote reset path (drop user schemas → apply local +// migrations → seed) against a real Postgres, no Docker. `--yes` auto-accepts the +// confirmation prompt (the non-interactive default is decline). Mutates the +// throwaway project's schema — deleted on teardown. The IPv4 session pooler +// `dbUrl` is used because the direct host is IPv6-only and unreachable from +// IPv4-only CI runners. +describe.skipIf(TARGET === "ts-next")("db reset (live, remote session pooler)", () => { + testLive( + "resets the remote schema and re-applies a local migration", + { timeout: 600_000 }, + async ({ run, dbUrl, workspace }) => { + const migrations = join(workspace.path, "supabase", "migrations"); + mkdirSync(migrations, { recursive: true }); + writeFileSync( + join(migrations, "20240101000000_e2e_reset.sql"), + "create table if not exists e2e_reset (id int);\n", + ); + + const reset = await run(["db", "reset", "--db-url", dbUrl, "--yes"]); + expect(reset.exitCode, reset.stderr).toBe(0); + expect(reset.stderr).toContain("Resetting remote database"); + // A real connection failure must never be mistaken for a benign outcome. + expect(`${reset.stdout}${reset.stderr}`, "db reset hit a connection error").not.toMatch( + /dial|no route|connection refused|could not connect|server closed the connection|i\/o timeout/i, + ); + + // The migration history shows the re-applied version → proves the drop + + // migrate ran against the remote database. + const listed = await run(["migration", "list", "--db-url", dbUrl]); + expect(listed.exitCode, listed.stderr).toBe(0); + expect(listed.stdout).toContain("20240101000000"); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/db/db-local-stack.live.e2e.test.ts b/apps/cli/src/legacy/commands/db/db-local-stack.live.e2e.test.ts deleted file mode 100644 index bce6c9a48b..0000000000 --- a/apps/cli/src/legacy/commands/db/db-local-stack.live.e2e.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterAll, beforeAll, describe, expect, test } from "vitest"; - -import { makeTempHome, runSupabase } from "../../../../tests/helpers/cli.ts"; - -/** - * Real-stack live e2e for the native `db start` / `db reset --local` ports — the - * one boundary the integration suites mock (the `db __db-bootstrap` Go seam + - * real Docker). It boots an actual local Postgres container, so it is **opt-in**: - * gated behind `SUPABASE_E2E_DOCKER=1` and skipped by default (it must NOT run in - * the normal feedback loop / CI e2e default — booting + pulling images is slow). - * - * Run locally with a working Docker daemon: - * pnpm build:go-sidecar && pnpm build:legacy && pnpm build:shim - * SUPABASE_E2E_DOCKER=1 bun --bun ./node_modules/vitest/vitest.mjs run \ - * --project e2e src/legacy/commands/db/db-local-stack.live.e2e.test.ts - * - * Tests share one stack and run in declaration order (the e2e project is - * sequential); `afterAll` tears the stack down even on failure. - */ -const dockerEnabled = process.env["SUPABASE_E2E_DOCKER"] === "1"; - -// First `db start` pulls the Postgres + service images; subsequent ops are fast. -const START_TIMEOUT_MS = 600_000; -const RESET_TIMEOUT_MS = 180_000; - -describe.skipIf(!dockerEnabled)("db start / db reset --local (live, real Docker)", () => { - let projectDir: string; - let home: ReturnType; - - beforeAll(async () => { - home = makeTempHome(); - projectDir = mkdtempSync(join(tmpdir(), "supabase-db-local-stack-e2e-")); - // `init` writes a full default config.toml (db image, ports, services). - const init = await runSupabase(["init"], { - entrypoint: "legacy", - cwd: projectDir, - home: home.dir, - exitTimeoutMs: 60_000, - }); - expect(init.exitCode, init.stderr).toBe(0); - }, 120_000); - - afterAll(async () => { - // Tear the stack down (legacy proxies `stop` to the Go binary) even if a - // test failed, then drop the temp project. HOME is cleaned by the harness. - if (projectDir !== undefined) { - await runSupabase(["stop", "--no-backup"], { - entrypoint: "legacy", - cwd: projectDir, - home: home.dir, - exitTimeoutMs: 120_000, - }).catch(() => undefined); - rmSync(projectDir, { recursive: true, force: true }); - } - }, 180_000); - - test("db start boots the local Postgres container", { timeout: START_TIMEOUT_MS }, async () => { - const { exitCode, stdout, stderr } = await runSupabase(["db", "start"], { - entrypoint: "legacy", - cwd: projectDir, - home: home.dir, - exitTimeoutMs: START_TIMEOUT_MS, - }); - expect(exitCode, stderr).toBe(0); - // The Go seam tees bootstrap progress to stderr (mode-independent). - expect(`${stdout}${stderr}`).toContain("Starting database"); - }); - - test( - "db start is a no-op (exit 0) when already running", - { timeout: RESET_TIMEOUT_MS }, - async () => { - const { exitCode, stdout, stderr } = await runSupabase(["db", "start"], { - entrypoint: "legacy", - cwd: projectDir, - home: home.dir, - exitTimeoutMs: RESET_TIMEOUT_MS, - }); - expect(exitCode).toBe(0); - // text mode → stderr line; agent mode → stdout JSON status. Match either. - expect(`${stdout}${stderr}`).toMatch(/already[\s-]running/i); - }, - ); - - test( - "db reset --local recreates the database and prints the branch line", - { timeout: RESET_TIMEOUT_MS }, - async () => { - const { exitCode, stderr } = await runSupabase(["db", "reset", "--local"], { - entrypoint: "legacy", - cwd: projectDir, - home: home.dir, - exitTimeoutMs: RESET_TIMEOUT_MS, - }); - expect(exitCode, stderr).toBe(0); - // "Finished supabase db reset on branch ." goes to stderr (ANSI-wrapped). - expect(stderr).toContain("on branch "); - }, - ); -}); From d5ff3572e0f32f3dc27c333faaf0c64d018277af Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 26 Jun 2026 17:59:54 +0200 Subject: [PATCH 18/29] chore(cli-go): sync generated API types with latest spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `go generate` (oapi-codegen against the live Management API spec) adds the new `storage.purge_cache` entitlement and the `Features.PurgeCache` field on the storage config request/response. Regenerate `pkg/api/types.gen.go` and add the matching `PurgeCache` field to the hand-built `UpdateStorageConfigBody.Features` literal in `pkg/config/storage.go` so the package still compiles. Fixes the Codegen CI check (`go generate` left the committed `pkg` dirty). Pure spec sync — `PurgeCache` is nil + omitempty, so serialized request bodies are unchanged. Co-Authored-By: Claude Opus 4.8 --- apps/cli-go/pkg/api/types.gen.go | 7 +++++++ apps/cli-go/pkg/config/storage.go | 3 +++ 2 files changed, 10 insertions(+) diff --git a/apps/cli-go/pkg/api/types.gen.go b/apps/cli-go/pkg/api/types.gen.go index 16267c8dc4..96bf6343f7 100644 --- a/apps/cli-go/pkg/api/types.gen.go +++ b/apps/cli-go/pkg/api/types.gen.go @@ -1485,6 +1485,7 @@ const ( V1ListEntitlementsResponseEntitlementsFeatureKeyStorageImageTransformations V1ListEntitlementsResponseEntitlementsFeatureKey = "storage.image_transformations" V1ListEntitlementsResponseEntitlementsFeatureKeyStorageMaxFileSize V1ListEntitlementsResponseEntitlementsFeatureKey = "storage.max_file_size" V1ListEntitlementsResponseEntitlementsFeatureKeyStorageMaxFileSizeConfigurable V1ListEntitlementsResponseEntitlementsFeatureKey = "storage.max_file_size.configurable" + V1ListEntitlementsResponseEntitlementsFeatureKeyStoragePurgeCache V1ListEntitlementsResponseEntitlementsFeatureKey = "storage.purge_cache" V1ListEntitlementsResponseEntitlementsFeatureKeyStorageVectorBuckets V1ListEntitlementsResponseEntitlementsFeatureKey = "storage.vector_buckets" V1ListEntitlementsResponseEntitlementsFeatureKeyVanitySubdomain V1ListEntitlementsResponseEntitlementsFeatureKey = "vanity_subdomain" ) @@ -3961,6 +3962,9 @@ type StorageConfigResponse struct { ImageTransformation struct { Enabled bool `json:"enabled"` } `json:"imageTransformation"` + PurgeCache struct { + Enabled bool `json:"enabled"` + } `json:"purgeCache"` S3Protocol struct { Enabled bool `json:"enabled"` } `json:"s3Protocol"` @@ -4563,6 +4567,9 @@ type UpdateStorageConfigBody struct { ImageTransformation *struct { Enabled bool `json:"enabled"` } `json:"imageTransformation,omitempty"` + PurgeCache *struct { + Enabled bool `json:"enabled"` + } `json:"purgeCache,omitempty"` S3Protocol *struct { Enabled bool `json:"enabled"` } `json:"s3Protocol,omitempty"` diff --git a/apps/cli-go/pkg/config/storage.go b/apps/cli-go/pkg/config/storage.go index 740c525f43..14bf5ff082 100644 --- a/apps/cli-go/pkg/config/storage.go +++ b/apps/cli-go/pkg/config/storage.go @@ -73,6 +73,9 @@ func (s *storage) ToUpdateStorageConfigBody() v1API.UpdateStorageConfigBody { ImageTransformation *struct { Enabled bool `json:"enabled"` } `json:"imageTransformation,omitempty"` + PurgeCache *struct { + Enabled bool `json:"enabled"` + } `json:"purgeCache,omitempty"` S3Protocol *struct { Enabled bool `json:"enabled"` } `json:"s3Protocol,omitempty"` From 597371b48e54a50589f49ed8d80997f41f9fec5c Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 26 Jun 2026 18:28:22 +0200 Subject: [PATCH 19/29] fix(cli): address db reset/start review feedback - Honor SUPABASE_EXPERIMENTAL (not just --experimental) on db reset via legacyResolveExperimental, and forward --experimental into the db __db-bootstrap seam so the local recreate's MigrateAndSeed takes Go's experimental schema-file path on a versionless reset/start. - Preserve absolute seed paths in legacyGetPendingSeeds/globOne/legacySeedData (Go only prefixes relative patterns with the supabase dir). - Reject a negative --last (Go declares it UintVar; cobra rejects at parse). - Create the supabase_migrations schema before seed_files, matching Go's CreateSeedTable, so seed-only runs don't fail on a missing schema. - Keep bucket seeding non-interactive during db reset (Go's buckets.Run(ctx, "", false, fsys)) via a new interactive flag threaded through legacySeedBucketsRun and legacyPromptYesNo. - Cache the linked project before the delegated --experimental reset (Go's PersistentPostRun runs even though the delegated child has telemetry disabled). - Record --sql-paths in reset telemetry (flags map + value-consuming set). Co-Authored-By: Claude Opus 4.8 --- .../legacy/commands/db/reset/reset.command.ts | 1 + .../legacy/commands/db/reset/reset.errors.ts | 9 +++++ .../legacy/commands/db/reset/reset.handler.ts | 35 +++++++++++++----- .../db/reset/reset.integration.test.ts | 36 +++++++++++++++++++ .../shared/legacy-db-bootstrap.seam.layer.ts | 20 +++++++++-- .../commands/db/shared/legacy-seed-ops.ts | 30 +++++++++++----- .../commands/seed/buckets/buckets.handler.ts | 28 ++++++++++++--- .../legacy/shared/legacy-db-target-flags.ts | 1 + .../src/legacy/shared/legacy-prompt-yes-no.ts | 8 +++++ 9 files changed, 146 insertions(+), 22 deletions(-) diff --git a/apps/cli/src/legacy/commands/db/reset/reset.command.ts b/apps/cli/src/legacy/commands/db/reset/reset.command.ts index 47d150e8aa..664d740ffd 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.command.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.command.ts @@ -54,6 +54,7 @@ export const legacyDbResetCommand = Command.make("reset", config).pipe( linked: flags.linked, local: flags.local, "no-seed": flags.noSeed, + "sql-paths": flags.sqlPaths, version: flags.version, last: flags.last, }, diff --git a/apps/cli/src/legacy/commands/db/reset/reset.errors.ts b/apps/cli/src/legacy/commands/db/reset/reset.errors.ts index de52a123a7..bb9ec23330 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.errors.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.errors.ts @@ -69,6 +69,15 @@ export class LegacyDbResetNotRunningError extends Data.TaggedError("LegacyDbRese readonly message: string; }> {} +/** + * `--last` was given a negative value. Go declares `--last` as an unsigned flag + * (`UintVar`, `cmd/db.go`), so cobra rejects a negative at parse time. Byte-matches + * cobra's parse error for `strconv.ParseUint`. + */ +export class LegacyDbResetLastFlagError extends Data.TaggedError("LegacyDbResetLastFlagError")<{ + readonly message: string; +}> {} + /** * Invalid `--sql-paths` usage. Byte-matches Go's `validateDbResetSeedFlags` * (`cmd/db.go`): `"--no-seed cannot be used with --sql-paths"` and diff --git a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts index 844bcdff6c..623137235f 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts @@ -7,11 +7,11 @@ import { Effect, FileSystem, Option, Path, Schema } from "effect"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { detectGitBranch } from "../../../../shared/git/git-branch.ts"; +import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; import { - LegacyDnsResolverFlag, - LegacyExperimentalFlag, + legacyResolveExperimental, + legacyResolveYes, } from "../../../../shared/legacy/global-flags.ts"; -import { legacyResolveYes } from "../../../../shared/legacy/global-flags.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { legacyAqua, legacyYellow } from "../../../shared/legacy-colors.ts"; @@ -35,6 +35,7 @@ import { LegacyDbResetCancelledError, LegacyDbResetConfigLoadError, LegacyDbResetInvalidVersionError, + LegacyDbResetLastFlagError, LegacyDbResetMigrationFileError, LegacyDbResetNotRunningError, LegacyDbResetSeedFlagsError, @@ -91,7 +92,9 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega const path = yield* Path.Path; const cliArgs = yield* CliArgs; const dnsResolver = yield* LegacyDnsResolverFlag; - const experimental = yield* LegacyExperimentalFlag; + // Env-aware (honors `SUPABASE_EXPERIMENTAL`, not just `--experimental`), matching + // Go's `viper.GetBool("EXPERIMENTAL")`. + const experimental = yield* legacyResolveExperimental; const yes = yield* legacyResolveYes; const workdir = cliConfig.workdir; @@ -108,6 +111,16 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega }), ); } + // Go declares `--last` as `UintVar`, so cobra rejects a negative at parse time + // (`Flag.integer` here accepts it). Reject it the same way rather than silently + // treating it as "no --last" and resetting the full history. + if (Option.isSome(flags.last) && flags.last.value < 0) { + return yield* Effect.fail( + new LegacyDbResetLastFlagError({ + message: `invalid argument "${flags.last.value}" for "--last" flag: strconv.ParseUint: parsing "${flags.last.value}": invalid syntax`, + }), + ); + } // cobra MarkFlagsMutuallyExclusive("version", "last") — alphabetical group. if (Option.isSome(flags.version) && Option.isSome(flags.last)) { return yield* Effect.fail( @@ -207,7 +220,9 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega // local path; its summary is suppressed (reset emits its own result). const storageReady = yield* seam.awaitStorageReady(); if (storageReady) { - yield* legacySeedBucketsRun({ projectRef: "", emitSummary: false }); + // Go's `buckets.Run(ctx, "", false, fsys)` — non-interactive: overwrite/prune + // confirmations take their defaults instead of blocking on input. + yield* legacySeedBucketsRun({ projectRef: "", emitSummary: false, interactive: false }); } // "Finished supabase db reset on branch ." (both Aqua). @@ -225,6 +240,13 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega return; } + // Resolve the linked ref before any return so the post-run cache (Go's + // `PersistentPostRun` `ensureProjectGroupsCached`) is written even on the + // delegated `--experimental` path below — the Go child runs with telemetry + // disabled and skips that cache, so the TS finalizer must own it. + const linkedRef = Option.getOrUndefined(cfg.ref ?? Option.none()); + if (connType === "linked" && linkedRef !== undefined) linkedRefForCache = linkedRef; + // Remote path. The niche `--experimental` schema-files apply path // (`apply.MigrateAndSeed`) is not ported; delegate it too. if (experimental && resolvedVersion === "") { @@ -232,9 +254,6 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega return; } - const linkedRef = Option.getOrUndefined(cfg.ref ?? Option.none()); - if (connType === "linked" && linkedRef !== undefined) linkedRefForCache = linkedRef; - const loadOptions: LoadProjectConfigOptions | undefined = connType === "linked" && linkedRef !== undefined ? { projectRef: linkedRef } : undefined; const loaded = yield* loadProjectConfig(workdir, loadOptions).pipe( diff --git a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts index 475910bf5f..f54708e5c4 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts @@ -714,6 +714,42 @@ describe("legacy db reset", () => { }); }); + it.live("rejects a negative --last value", () => { + const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); + return Effect.gen(function* () { + const exit = yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + last: Option.some(-1), + }).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const cause = JSON.stringify(exit.cause); + expect(cause).toContain("invalid argument"); + expect(cause).toContain("strconv.ParseUint"); + } + }); + }); + + it.live("seeds an absolute --sql-paths file on a remote reset", () => { + const absSeed = join(tmp.current, "external-seed.sql"); + writeFileSync(absSeed, "insert into t values (3);"); + const { layer, out } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile("20240101000000"), + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ + ...DEFAULT_FLAGS, + linked: true, + sqlPaths: [absSeed], + }).pipe(Effect.provide(layer)); + // Absolute paths are preserved (not prefixed with supabase/) and seeded. + expect(out.stderrText).toContain(`Seeding data from ${absSeed}...`); + }); + }); + it.live("warns and seeds from --sql-paths overriding config on a remote reset", () => { const { layer, out } = setup(tmp.current, { // Seed disabled in config — --sql-paths must force-enable it. diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts index 9b74d46eda..f5785e5be4 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.seam.layer.ts @@ -2,7 +2,11 @@ import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; -import { LegacyNetworkIdFlag, LegacyProfileFlag } from "../../../../shared/legacy/global-flags.ts"; +import { + LegacyNetworkIdFlag, + LegacyProfileFlag, + legacyResolveExperimental, +} from "../../../../shared/legacy/global-flags.ts"; import { resolveBinary } from "../../../../shared/legacy/go-proxy.layer.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { spawnContainerCli } from "../../../shared/legacy-container-cli.ts"; @@ -43,6 +47,11 @@ export const legacyDbBootstrapSeamLayer = Layer.effect( const profile = yield* LegacyProfileFlag; const profileArgs = profile !== "supabase" ? ["--profile", profile] : []; const networkArgs = Option.isSome(networkId) ? ["--network-id", networkId.value] : []; + // Forward `--experimental` (env-aware) so the seam's `SetupLocalDatabase` / + // `apply.MigrateAndSeed` takes Go's experimental schema-file path on a + // versionless reset/start, matching `viper.GetBool("EXPERIMENTAL")`. + const experimental = yield* legacyResolveExperimental; + const experimentalArgs = experimental ? ["--experimental"] : []; const spawner = yield* ChildProcessSpawner; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -63,7 +72,14 @@ export const legacyDbBootstrapSeamLayer = Layer.effect( ), ); } - const args = ["db", "__db-bootstrap", ...modeArgs, ...networkArgs, ...profileArgs]; + const args = [ + "db", + "__db-bootstrap", + ...modeArgs, + ...networkArgs, + ...profileArgs, + ...experimentalArgs, + ]; const command = ChildProcess.make(resolved.found, args, { cwd: cliConfig.workdir, stdin: "inherit", diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts b/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts index e540e07213..be3382c24d 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-seed-ops.ts @@ -9,6 +9,7 @@ import { legacySplitAndTrim } from "../../../shared/legacy-sql-split.ts"; /** * Seed-history DDL/DML, verbatim from Go's `pkg/migration/history.go`. */ +const CREATE_VERSION_SCHEMA = "CREATE SCHEMA IF NOT EXISTS supabase_migrations"; const CREATE_SEED_TABLE = "CREATE TABLE IF NOT EXISTS supabase_migrations.seed_files (path text NOT NULL PRIMARY KEY, hash text NOT NULL)"; const UPSERT_SEED_FILE = @@ -122,8 +123,11 @@ const legacyGlobSeedFiles = Effect.fnUntraced(function* ( const errors: Array = []; for (const rawPattern of patterns) { - // Go joins each configured pattern under SupabaseDirPath before globbing. - const pattern = toSlash(path.join("supabase", rawPattern)); + // Go joins each *relative* pattern under SupabaseDirPath before globbing but + // preserves absolute paths as-is (config.go / resolveSeedSqlPaths). + const pattern = path.isAbsolute(rawPattern) + ? toSlash(rawPattern) + : toSlash(path.join("supabase", rawPattern)); const matches = yield* globOne(fs, path, workdir, pattern); if (matches.length === 0) { errors.push(`no files matched pattern: ${pattern}`); @@ -160,11 +164,12 @@ const globOne = ( pattern: string, ): Effect.Effect, never> => Effect.gen(function* () { + // Absolute patterns resolve against the filesystem root (Go preserves absolute + // seed paths); relative ones are rooted at the workdir. + const resolve = (p: string): string => (path.isAbsolute(p) ? p : path.join(workdir, p)); // No metacharacters: a direct existence check (Go's `fs.Glob` fast path). if (!META_CHARS.test(pattern)) { - const exists = yield* fs - .exists(path.join(workdir, pattern)) - .pipe(Effect.orElseSucceed(() => false)); + const exists = yield* fs.exists(resolve(pattern)).pipe(Effect.orElseSucceed(() => false)); return exists ? [pattern] : []; } const { dir, file } = splitPath(pattern); @@ -173,7 +178,7 @@ const globOne = ( dir === "" || !META_CHARS.test(dir) ? [dir] : yield* globOne(fs, path, workdir, dir); const result: Array = []; for (const d of dirs) { - const absDir = d === "" ? workdir : path.join(workdir, d); + const absDir = d === "" ? workdir : resolve(d); const names = yield* fs .readDirectory(absDir) .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); @@ -229,7 +234,9 @@ export const legacyGetPendingSeeds = Effect.fnUntraced(function* ( const applied = yield* readRemoteSeeds(session); const pending: Array = []; for (const file of files) { - const content = yield* fs.readFileString(path.join(workdir, file)); + const content = yield* fs.readFileString( + path.isAbsolute(file) ? file : path.join(workdir, file), + ); const hash = createHash("sha256").update(content).digest("hex"); const appliedHash = applied.get(file); if (appliedHash !== undefined) { @@ -260,6 +267,9 @@ export const legacySeedData = ( Effect.gen(function* () { const output = yield* Output; if (seeds.length === 0) return; + // Go's `CreateSeedTable` (history.go:54-60) creates the schema first, so a + // seed-only run (no prior migrations) doesn't fail on a missing schema. + yield* session.exec(CREATE_VERSION_SCHEMA); yield* session.exec(CREATE_SEED_TABLE); for (const seed of seeds) { yield* output.raw( @@ -270,7 +280,11 @@ export const legacySeedData = ( ); const statements = seed.dirty ? [] - : legacySplitAndTrim(yield* fs.readFileString(path.join(workdir, seed.path))); + : legacySplitAndTrim( + yield* fs.readFileString( + path.isAbsolute(seed.path) ? seed.path : path.join(workdir, seed.path), + ), + ); yield* session.exec("BEGIN"); const body = Effect.gen(function* () { for (const statement of statements) yield* session.exec(statement); diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts index 9d4306fded..14ea87aa7f 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts @@ -136,6 +136,13 @@ const legacyDecodeDefaultProjectConfig = Schema.decodeUnknownSync(ProjectConfigS export const legacySeedBucketsRun = Effect.fnUntraced(function* (opts: { readonly projectRef: string; readonly emitSummary: boolean; + /** + * Whether overwrite/prune confirmations may prompt. `db reset` reuses this core + * with `interactive: false` so it never blocks on input — Go forces it via + * `buckets.Run(ctx, "", false, fsys)` (`internal/db/reset/reset.go:71`). + * Defaults to `true` for the `seed buckets` command. + */ + readonly interactive?: boolean; }) { const output = yield* Output; const cliConfig = yield* LegacyCliConfig; @@ -144,6 +151,7 @@ export const legacySeedBucketsRun = Effect.fnUntraced(function* (opts: { // `--yes` OR `SUPABASE_YES` (Go's viper AutomaticEnv, root.go:318-320). const yes = yield* legacyResolveYes; const { projectRef, emitSummary } = opts; + const interactive = opts.interactive ?? true; { // 2. Load config.toml, passing projectRef so `[remotes.*]` overrides are @@ -226,7 +234,7 @@ export const legacySeedBucketsRun = Effect.fnUntraced(function* (opts: { const summary = emptySummary(); // 5. Upsert configured buckets. - yield* upsertBuckets(output, yes, gateway, bucketPropsByName, summary); + yield* upsertBuckets(output, yes, interactive, gateway, bucketPropsByName, summary); // 6. Upsert analytics buckets (remote --linked only). if (config.storage.analytics.enabled && projectRef !== "") { @@ -234,6 +242,7 @@ export const legacySeedBucketsRun = Effect.fnUntraced(function* (opts: { yield* upsertAnalyticsBuckets( output, yes, + interactive, gateway, Object.keys(config.storage.analytics.buckets), summary, @@ -243,9 +252,14 @@ export const legacySeedBucketsRun = Effect.fnUntraced(function* (opts: { // 7. Upsert vector buckets (local), with graceful skip on unavailability. if (vectorEnabled && hasVectorBuckets) { yield* output.raw("Updating vector buckets...\n", "stderr"); - yield* upsertVectorBuckets(output, yes, gateway, vectorBucketNames, summary).pipe( - Effect.catch((error) => handleVectorError(output, error, summary)), - ); + yield* upsertVectorBuckets( + output, + yes, + interactive, + gateway, + vectorBucketNames, + summary, + ).pipe(Effect.catch((error) => handleVectorError(output, error, summary))); } // 8. Upload objects for each bucket with a configured objects_path. @@ -348,6 +362,7 @@ const computeBucketProps = ( const upsertBuckets = Effect.fnUntraced(function* ( output: typeof Output.Service, yes: boolean, + interactive: boolean, gateway: LegacyStorageGateway, propsByName: ReadonlyMap, summary: SeedSummary, @@ -363,6 +378,7 @@ const upsertBuckets = Effect.fnUntraced(function* ( yes, `Bucket ${legacyBold(bucketId)} already exists. Do you want to overwrite its properties?`, true, + interactive, ); if (!overwrite) { summary.buckets_skipped.push(bucketId); @@ -383,6 +399,7 @@ const upsertBuckets = Effect.fnUntraced(function* ( const upsertVectorBuckets = Effect.fnUntraced(function* ( output: typeof Output.Service, yes: boolean, + interactive: boolean, gateway: LegacyStorageGateway, configuredNames: ReadonlyArray, summary: SeedSummary, @@ -408,6 +425,7 @@ const upsertVectorBuckets = Effect.fnUntraced(function* ( yes, `Bucket ${legacyBold(name)} not found in ${legacyBold(CONFIG_PATH)}. Do you want to prune it?`, false, + interactive, ); if (!prune) { continue; @@ -422,6 +440,7 @@ const upsertVectorBuckets = Effect.fnUntraced(function* ( const upsertAnalyticsBuckets = Effect.fnUntraced(function* ( output: typeof Output.Service, yes: boolean, + interactive: boolean, gateway: LegacyStorageGateway, configuredNames: ReadonlyArray, summary: SeedSummary, @@ -447,6 +466,7 @@ const upsertAnalyticsBuckets = Effect.fnUntraced(function* ( yes, `Bucket ${legacyBold(name)} not found in ${legacyBold(CONFIG_PATH)}. Do you want to prune it?`, false, + interactive, ); if (!prune) { continue; diff --git a/apps/cli/src/legacy/shared/legacy-db-target-flags.ts b/apps/cli/src/legacy/shared/legacy-db-target-flags.ts index 6bffda2a45..bcb44e0a3a 100644 --- a/apps/cli/src/legacy/shared/legacy-db-target-flags.ts +++ b/apps/cli/src/legacy/shared/legacy-db-target-flags.ts @@ -48,6 +48,7 @@ export interface LegacyDbTargetSelection { export const VALUE_CONSUMING_LONG_FLAGS = new Set([ // db-family command flags "db-url", + "sql-paths", "schema", "level", "fail-on", diff --git a/apps/cli/src/legacy/shared/legacy-prompt-yes-no.ts b/apps/cli/src/legacy/shared/legacy-prompt-yes-no.ts index 13ad746906..dda6e9fc6a 100644 --- a/apps/cli/src/legacy/shared/legacy-prompt-yes-no.ts +++ b/apps/cli/src/legacy/shared/legacy-prompt-yes-no.ts @@ -8,6 +8,9 @@ import { Output } from "../../shared/output/output.service.ts"; * `storage rm`: * - when `yes` is set, echoes `