From de4cfd200714f3ebf2442f243e6ad9561c113360 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 11 Aug 2026 10:58:26 +0100 Subject: [PATCH 1/3] fix(cli): port migration squash to native TS (CLI-1969) --- apps/cli/docs/go-cli-porting-status.md | 34 +- .../legacy/commands/db/diff/diff.handler.ts | 6 +- .../legacy/commands/db/dump/dump.handler.ts | 6 +- .../legacy/commands/db/pull/pull.handler.ts | 15 +- .../db/shared/legacy-shadow-source.ts | 113 +- .../migration/migration.integration.test.ts | 48 +- .../commands/migration/migration.layers.ts | 19 + .../commands/migration/squash/SIDE_EFFECTS.md | 159 +- .../migration/squash/squash.command.ts | 27 +- .../commands/migration/squash/squash.diff.ts | 68 + .../migration/squash/squash.diff.unit.test.ts | 120 ++ .../commands/migration/squash/squash.dump.ts | 97 ++ .../migration/squash/squash.e2e.test.ts | 77 + .../migration/squash/squash.errors.ts | 71 + .../migration/squash/squash.handler.ts | 600 ++++++- .../squash/squash.integration.test.ts | 1435 +++++++++++++++++ .../shared/db-bootstrap/shadow-database.ts | 128 +- .../legacy/shared/legacy-migration-history.ts | 4 + .../db => }/shared/legacy-pg-dump.env.ts | 8 +- .../shared/legacy-pg-dump.env.unit.test.ts | 4 +- .../db => }/shared/legacy-pg-dump.run.ts | 20 +- .../db => }/shared/legacy-pg-dump.scripts.ts | 0 .../src/legacy/shared/legacy-pgdelta.cache.ts | 6 +- apps/cli/tests/helpers/legacy-mocks.ts | 37 +- 24 files changed, 2875 insertions(+), 227 deletions(-) create mode 100644 apps/cli/src/legacy/commands/migration/squash/squash.diff.ts create mode 100644 apps/cli/src/legacy/commands/migration/squash/squash.diff.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/migration/squash/squash.dump.ts create mode 100644 apps/cli/src/legacy/commands/migration/squash/squash.e2e.test.ts create mode 100644 apps/cli/src/legacy/commands/migration/squash/squash.errors.ts create mode 100644 apps/cli/src/legacy/commands/migration/squash/squash.integration.test.ts rename apps/cli/src/legacy/{commands/db => }/shared/legacy-pg-dump.env.ts (96%) rename apps/cli/src/legacy/{commands/db => }/shared/legacy-pg-dump.env.unit.test.ts (97%) rename apps/cli/src/legacy/{commands/db => }/shared/legacy-pg-dump.run.ts (67%) rename apps/cli/src/legacy/{commands/db => }/shared/legacy-pg-dump.scripts.ts (100%) diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 6dd1135b63..2a4c88f02a 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -17,23 +17,23 @@ Percentages and counts below are based on final leaf commands only. Command grou ## Summary -| Metric | Count | Percent | -| ------------------------- | ------: | ------: | -| Fully ported commands | 11 / 94 | 11.7% | -| Partially ported commands | 61 / 94 | 64.9% | +| Metric | Count | Percent | +| ------------------------- | -------: | ------: | +| Fully ported commands | 51 / 118 | 43.2% | +| Partially ported commands | 59 / 118 | 50.0% | ## Family Summary -| Family | Final commands | `ported` | `partial` | `missing` | Represented in TS | -| ------------------------- | -------------: | --------: | --------: | ---------: | ----------------: | -| Quick Start | 1 | 0 (0%) | 0 (0%) | 1 (100%) | 0 (0%) | -| Project / Stack Lifecycle | 9 | 2 (22.2%) | 7 (77.8%) | 0 (0%) | 9 (100%) | -| Database | 19 | 5 (26.3%) | 0 (0%) | 14 (73.7%) | 5 (26.3%) | -| Code Generation | 3 | 0 (0%) | 0 (0%) | 3 (100%) | 0 (0%) | -| Functions | 6 | 0 (0%) | 6 (100%) | 0 (0%) | 6 (100%) | -| Storage | 4 | 0 (0%) | 0 (0%) | 4 (100%) | 0 (0%) | -| Management APIs | 47 | 0 (0%) | 47 (100%) | 0 (0%) | 47 (100%) | -| Additional Commands | 5 | 4 (80%) | 1 (20%) | 0 (0%) | 5 (100.0%) | +| Family | Final commands | `ported` | `partial` | `missing` | Represented in TS | +| ------------------------- | -------------: | --------: | --------: | --------: | ----------------: | +| Quick Start | 1 | 0 (0%) | 0 (0%) | 1 (100%) | 0 (0%) | +| Project / Stack Lifecycle | 9 | 4 (44.4%) | 5 (55.6%) | 0 (0%) | 9 (100%) | +| Database | 43 | 43 (100%) | 0 (0%) | 0 (0%) | 43 (100%) | +| Code Generation | 3 | 0 (0%) | 0 (0%) | 3 (100%) | 0 (0%) | +| Functions | 6 | 0 (0%) | 6 (100%) | 0 (0%) | 6 (100%) | +| Storage | 4 | 0 (0%) | 0 (0%) | 4 (100%) | 0 (0%) | +| Management APIs | 47 | 0 (0%) | 47 (100%) | 0 (0%) | 47 (100%) | +| Additional Commands | 5 | 4 (80%) | 1 (20%) | 0 (0%) | 5 (100.0%) | ## Global Flags Overview @@ -120,7 +120,7 @@ These commands exist in the TS CLI today but have no direct top-level equivalent | `migration list` | `ported` | `legacy/commands/migration/list/` | `n/a` | `n/a` | Native TS port. Merges remote `schema_migrations` with local files into a Glamour ASCII table (Local / Remote / Time-UTC columns); defaults to `--linked`. | | `migration new` | `ported` | `legacy/commands/migration/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/migrations/_.sql` (mode 0644) from piped stdin; no DB/API. | | `migration repair` | `ported` | `legacy/commands/migration/repair/` | `n/a` | `n/a` | Native TS port. Transactional create-table + TRUNCATE/UPSERT/DELETE; applied mode reads local files; repair-all prompt; defaults to `--linked`. | -| `migration squash` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `migration squash` | `ported` | `legacy/commands/migration/squash/` | `n/a` | `n/a` | Native TS port. Native shadow DB (CLI-1956) + three one-shot `pg_dump` containers; squashes local migrations into the target file (full dump + auth/storage line diff), then suggests `migration repair` locally or prompts to baseline the remote history; defaults to `--local`. | | `migration up` | `ported` | `legacy/commands/migration/up/` | `n/a` | `n/a` | Native TS port. Computes pending migrations, upserts `[db.vault]`, applies each transactionally (pipeline-incompatible statements run standalone — closed Go PR supabase/cli#5156, ported into `apps/cli-go`, CLI-1989 ruling); `--include-all` for out-of-order; defaults to `--local`. Does not seed (matches Go). | | `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/shared/legacy-test-db.*` (command definitions in `legacy/commands/test/db/` and its hidden `db test` alias, `legacy/commands/db/test/`) | `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 is honored. `[images]` config override not modeled (documented divergence). | @@ -218,7 +218,7 @@ These route-first equivalents are intentionally lower-level than the old Go comm ## Legacy Shell Command Status Per-command status for the legacy shell (`src/legacy/`), which mirrors the old Go CLI 1:1. -The `migration` command group also accepts Go's top-level `migrations` alias and forwards singular `migration` argv to Go. +The `migration` command group also accepts Go's top-level `migrations` alias (`Command.withAlias`); every subcommand in the family is native TS as of CLI-1969 (`squash` was the last proxy delegate), so the alias no longer forwards any argv to the bundled Go binary. Legend: @@ -298,7 +298,7 @@ Legend: | `migration list` | `ported` | [`../src/legacy/commands/migration/list/list.command.ts`](../src/legacy/commands/migration/list/list.command.ts) — native; merged Local/Remote/Time-UTC Glamour table | | `migration new` | `ported` | [`../src/legacy/commands/migration/new/new.command.ts`](../src/legacy/commands/migration/new/new.command.ts) — native; writes `supabase/migrations/_.sql` from piped stdin | | `migration repair` | `ported` | [`../src/legacy/commands/migration/repair/repair.command.ts`](../src/legacy/commands/migration/repair/repair.command.ts) — native; transactional TRUNCATE/UPSERT/DELETE, repair-all prompt | -| `migration squash` | `wrapped` | [`../src/legacy/commands/migration/squash/squash.command.ts`](../src/legacy/commands/migration/squash/squash.command.ts) | +| `migration squash` | `ported` | [`../src/legacy/commands/migration/squash/squash.command.ts`](../src/legacy/commands/migration/squash/squash.command.ts) — native; shadow-DB squash + auth/storage line diff, optional remote baseline | | `migration up` | `ported` | [`../src/legacy/commands/migration/up/up.command.ts`](../src/legacy/commands/migration/up/up.command.ts) — native; pending compute + vault upsert + per-file apply | | `migration down` | `ported` | [`../src/legacy/commands/migration/down/down.command.ts`](../src/legacy/commands/migration/down/down.command.ts) — native; drop + vault + migrate&seed to target version | | `migration fetch` | `ported` | [`../src/legacy/commands/migration/fetch/fetch.command.ts`](../src/legacy/commands/migration/fetch/fetch.command.ts) — native; writes history rows to `supabase/migrations/` | diff --git a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts index 7e652e0b6f..bb8c2f688a 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -25,6 +25,7 @@ import { legacyBuildLocalDbContainerInputs } from "../../../shared/db-bootstrap/ import { legacyCreateShadowDatabase, legacyRemoveShadowDatabase, + legacyShadowRunInputFromLocalContainerInputs, } from "../../../shared/db-bootstrap/shadow-database.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; @@ -47,10 +48,7 @@ import { legacyIsPgDeltaDebugEnabled, legacyResolvePgDeltaProjectId, } from "../../../shared/legacy-pgdelta.ts"; -import { - legacyPrepareShadowSource, - legacyShadowRunInputFromLocalContainerInputs, -} from "../shared/legacy-shadow-source.ts"; +import { legacyPrepareShadowSource } from "../shared/legacy-shadow-source.ts"; import type { LegacyDbDiffFlags } from "./diff.command.ts"; import { legacyClassifyExplicitRef, legacyUnknownTargetMessage } from "./diff.explicit.ts"; import { diff --git a/apps/cli/src/legacy/commands/db/dump/dump.handler.ts b/apps/cli/src/legacy/commands/db/dump/dump.handler.ts index d7021d20ca..dc25726ff9 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.handler.ts +++ b/apps/cli/src/legacy/commands/db/dump/dump.handler.ts @@ -32,14 +32,14 @@ import { legacyBuildRoleDumpEnv, legacyBuildSchemaDumpEnv, legacyExpandScript, -} from "../shared/legacy-pg-dump.env.ts"; -import { legacyStreamPgDump } from "../shared/legacy-pg-dump.run.ts"; +} from "../../../shared/legacy-pg-dump.env.ts"; +import { legacyStreamPgDump } from "../../../shared/legacy-pg-dump.run.ts"; import { legacyRunWithPoolerFallback } from "../shared/legacy-pooler-fallback.ts"; import { legacyDumpDataScript, legacyDumpRoleScript, legacyDumpSchemaScript, -} from "../shared/legacy-pg-dump.scripts.ts"; +} from "../../../shared/legacy-pg-dump.scripts.ts"; /** * Mutually-exclusive flag groups, in cobra's check order (it sorts the joined diff --git a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts index a0fd92efaa..7a1fd05405 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -44,6 +44,7 @@ import { legacyCreateShadowDatabase, legacyPrepareRawShadow, legacyRemoveShadowDatabase, + legacyShadowRunInputFromLocalContainerInputs, } from "../../../shared/db-bootstrap/shadow-database.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; @@ -59,14 +60,17 @@ import { } from "../../../shared/legacy-diff-engine.ts"; import { legacyDiffMigra } from "../shared/legacy-migra.ts"; import { legacyWritePgDeltaMigrations } from "../shared/legacy-pgdelta-migrations.write.ts"; -import { type LegacyDumpOptions, legacyBuildSchemaDumpEnv } from "../shared/legacy-pg-dump.env.ts"; -import { legacyStreamPgDump } from "../shared/legacy-pg-dump.run.ts"; +import { + type LegacyDumpOptions, + legacyBuildSchemaDumpEnv, +} from "../../../shared/legacy-pg-dump.env.ts"; +import { legacyStreamPgDump } from "../../../shared/legacy-pg-dump.run.ts"; import { legacyEmitPoolerFallbackWarning, legacyIsDirectLinkedHost, legacyRunWithPoolerFallback, } from "../shared/legacy-pooler-fallback.ts"; -import { legacyDumpSchemaScript } from "../shared/legacy-pg-dump.scripts.ts"; +import { legacyDumpSchemaScript } from "../../../shared/legacy-pg-dump.scripts.ts"; import { legacyFormatMigrationTimestamp, legacyGetMigrationPath, @@ -81,10 +85,7 @@ import { legacyResolvePgDeltaProjectId, } from "../../../shared/legacy-pgdelta.ts"; import { legacySaveEmptyPgDeltaPullDebug } from "./pull.debug.ts"; -import { - legacyPrepareShadowSource, - legacyShadowRunInputFromLocalContainerInputs, -} from "../shared/legacy-shadow-source.ts"; +import { legacyPrepareShadowSource } from "../shared/legacy-shadow-source.ts"; import type { LegacyDbPullFlags } from "./pull.command.ts"; import { LegacyDbPullDumpError, diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.ts b/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.ts index 957b8793ba..2726492757 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.ts @@ -14,7 +14,7 @@ * is nothing to port. */ -import { Effect, Option, Result, type FileSystem, type Path } from "effect"; +import { Effect, Result, type FileSystem, type Path } from "effect"; import type { GlobalFlag } from "effect/unstable/cli"; import type * as HttpClient from "effect/unstable/http/HttpClient"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; @@ -33,7 +33,6 @@ import { legacyResolveSeedSqlPath, type LegacyPgDeltaTomlConfig, } from "../../../shared/legacy-db-config.toml-read.ts"; -import type { LegacyDbConfigLoadError } from "../../../shared/legacy-db-config.errors.ts"; import { legacyResolveUnderWorkdir, legacyGlobPattern, @@ -47,14 +46,11 @@ import { legacyWaitForHealthyServices } from "../../../shared/db-bootstrap/healt import { legacySeedGlobals } from "../../../shared/legacy-migration-apply.ts"; import { LEGACY_BAD_PATTERN_MESSAGE, legacyPathMatch } from "../../../shared/legacy-path-match.ts"; import { legacyToPostgresURL } from "../../../shared/legacy-postgres-url.ts"; -import type { LegacyLocalDbContainerInputs } from "../../../shared/db-bootstrap/local-container-inputs.ts"; -import type { LegacyVaultSecret } from "../../../shared/legacy-vault.ts"; import { legacyMigrateShadowDatabase, LegacyShadowDbError, - type LegacyShadowConnectionInput, type LegacyShadowDatabaseHandle, - type LegacyShadowDbSetupInput, + type LegacyShadowSetupInput, type LegacyShadowSourceResult, } from "../../../shared/db-bootstrap/shadow-database.ts"; import type { LegacyStartSetupLocalDatabaseError } from "../../../shared/db-bootstrap/db-setup.ts"; @@ -69,107 +65,12 @@ type Spawner = ChildProcessSpawner["Service"]; export type { LegacyShadowSourceResult }; -/** - * Adapts {@link LegacyLocalDbContainerInputs} (`local-container-inputs.ts`, the SAME - * config/image/JWKS resolution prelude `db start`/`db reset` share) plus the caller's own - * already-loaded `config.toml` slice into {@link LegacyShadowConnectionInput} - * (`shadow-database.ts`) — every field {@link legacyPrepareShadowSource}/ - * `legacyPrepareRawShadow` (`shadow-database.ts`) need EXCEPT the diff/pull-specific ones - * (`targetLocal`/`usePgDelta`/`schemaPaths`/`pgDelta`/`ctx`/`setup`, left to each call site). - * Hoisted here so `db diff`/`db pull` don't each declare an identical ~20-field object literal. - * - * On `db diff --linked`/`db pull` (linked), the caller passes its own resolved ref straight - * through to {@link legacyBuildLocalDbContainerInputs} (its own `projectRef` parameter — see - * that function's doc comment), which threads it into `legacyLoadLocalProjectContext` -> - * `loadProjectConfig({ projectRef })`. So the shadow's OWN container config (image, JWT - * secret, root key, `db.settings`, service enabled-for-setup flags, sourced from - * `localInputs.context.config`/`postgresSpecBase`) reflects the matching `[remotes.]` - * override, same as `toml` (the caller's own `legacyReadDbToml(..., linkedRef)` result, - * which feeds `pgDelta`/vault/`apiAutoExposeNewTables` below) — matching Go's own uniform - * remote-merge on the linked path (`LoadConfig` seeds `flags.ProjectRef` before every field - * read). The two config reads still go through independent remote-merge implementations - * (`@supabase/config`'s `applyRemoteOverride` for `localInputs.context.config`; - * `legacy-db-config.toml-read.ts`'s own TOML-based merge for `toml`) rather than a single - * shared decode — unifying those is a larger, out-of-scope refactor, not a per-command gap. - */ -export function legacyShadowRunInputFromLocalContainerInputs( - localInputs: LegacyLocalDbContainerInputs, - resolvedImage: string, - toml: { - readonly shadowPort: number; - readonly password: string; - readonly baseline: { readonly apiAutoExposeNewTables: Option.Option }; - readonly vault: ReadonlyArray; - }, - fs: FileSystem.FileSystem, - path: Path.Path, -): Omit< - LegacyPrepareShadowSourceInput, - "targetLocal" | "usePgDelta" | "schemaPaths" | "pgDelta" | "ctx" -> { - const { postgresSpecBase } = localInputs; - return { - db: { - major_version: postgresSpecBase.db.major_version, - settings: postgresSpecBase.db.settings, - }, - experimental: postgresSpecBase.experimental, - jwtSecret: postgresSpecBase.jwtSecret, - jwtExpiry: postgresSpecBase.jwtExpiry, - networkId: localInputs.networkId, - image: resolvedImage, - configImage: postgresSpecBase.configImage, - rootKey: postgresSpecBase.rootKey, - shadowPort: toml.shadowPort, - projectId: localInputs.context.projectId, - isBitbucketPipeline: localInputs.containerOpts.isBitbucketPipeline, - workdir: localInputs.containerOpts.workdir, - extraHosts: localInputs.containerOpts.extraHosts, - fs, - path, - hostname: localInputs.context.hostname, - password: toml.password, - healthTimeoutSeconds: localInputs.dbHealthTimeoutSeconds, - setup: { - majorVersion: localInputs.setup.majorVersion, - config: localInputs.setup.config, - // NOT `localInputs.setup.dbUrl` — that carries the REGULAR local container's own - // hardcoded-"postgres" password (`legacy-local-config-values.ts`'s `DEFAULT_DB_PASSWORD`), - // for a DIFFERENT container. The shadow's own one-shot setup jobs - // (`legacyBuildShadowSetupDatabaseInput`) only ever consume this `dbUrl` to extract a - // password (`legacyStartInternalDbPassword`) for the SHADOW they actually run against, so - // it must carry the SAME resolved `toml.password` the shadow container itself is - // initialized with (see `legacyBuildShadowPostgresContainerSpec`) — otherwise a non-default - // `[db] password` authenticates against the wrong secret and every setup job fails. - dbUrl: legacyToPostgresURL({ - host: localInputs.context.hostname, - port: toml.shadowPort, - user: "postgres", - password: toml.password, - database: "postgres", - }), - jwtSecret: localInputs.setup.jwtSecret, - jwks: localInputs.setup.jwks, - apiUrl: localInputs.setup.apiUrl, - authExternalUrl: localInputs.setup.authExternalUrl, - siteUrl: localInputs.setup.siteUrl, - anonKey: localInputs.setup.anonKey, - serviceRoleKey: localInputs.setup.serviceRoleKey, - storageTargetMigration: localInputs.setup.storageTargetMigration, - realtimeEnabledForSetup: localInputs.setup.realtimeEnabledForSetup, - storageEnabledForSetup: localInputs.setup.storageEnabledForSetup, - authEnabledForSetup: localInputs.setup.authEnabledForSetup, - serviceVersionOverrides: localInputs.setup.serviceVersionOverrides, - projectEnvValues: localInputs.setup.projectEnvValues, - debug: localInputs.setup.debug, - apiAutoExposeNewTables: toml.baseline.apiAutoExposeNewTables, - vault: toml.vault, - }, - }; -} +// `legacyShadowRunInputFromLocalContainerInputs` used to be re-exported here (promoted to +// `shared/db-bootstrap/shadow-database.ts` when `migration squash` became this builder's +// THIRD consumer, CLI-1969). All three call sites (`diff.handler.ts`, `pull.handler.ts`, +// `legacy-pgdelta.cache.ts`) now import it from there directly — no re-export shim needed. -export interface LegacyPrepareShadowSourceInput extends LegacyShadowConnectionInput { - readonly setup: LegacyShadowDbSetupInput; +export interface LegacyPrepareShadowSourceInput extends LegacyShadowSetupInput { /** Go's `utils.IsLocalDatabase(config)` — the only target-derived input the shadow prep needs. */ readonly targetLocal: boolean; /** Selects the declarative-apply engine for the local-declared branch, matching `DiffDatabase`. */ diff --git a/apps/cli/src/legacy/commands/migration/migration.integration.test.ts b/apps/cli/src/legacy/commands/migration/migration.integration.test.ts index 886224c0c2..dc67ea14fa 100644 --- a/apps/cli/src/legacy/commands/migration/migration.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/migration.integration.test.ts @@ -1,43 +1,41 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { Effect, Exit } from "effect"; import { CliOutput, Command } from "effect/unstable/cli"; import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts"; -import { LegacyGoProxy } from "../../../shared/legacy/go-proxy.service.ts"; +import { LEGACY_GLOBAL_FLAGS } from "../../../shared/legacy/global-flags.ts"; import { legacyMigrationCommand } from "./migration.command.ts"; -function mockLegacyGoProxy() { - const calls: Array> = []; - const layer = Layer.succeed(LegacyGoProxy, { - exec: (args) => - Effect.sync(() => { - calls.push([...args]); - }), - execCapture: () => Effect.succeed(""), - }); - - return { layer, calls }; -} - +// `withGlobalFlags` must come AFTER `withSubcommands` — see +// `start.string-slice-flags.integration.test.ts`'s identical comment. const legacyTestRoot = Command.make("supabase").pipe( Command.withSubcommands([legacyMigrationCommand]), + Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), ); describe("legacy migration command integration", () => { it.live("accepts the Go-compatible plural migrations alias", () => { - // Routes through `squash`, which stays a deliberate Go-proxy delegate (a - // native pg-delta squash would diverge from Go's pg_dump output — see the - // porting-status doc), so this also asserts the proxy path still works while - // the other six subcommands are now native. - const proxy = mockLegacyGoProxy(); + // After CLI-1969, `squash` is native and no `migration` subcommand is proxied + // any more — so the plural alias is now proven at the PARSER instead: a + // `migrations squash --nope` must fail with squash's own unknown-flag error, + // which never builds the command's `Command.provide` runtime layer. const run = Effect.gen(function* () { - yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })(["migrations", "squash"]); - - expect(proxy.calls).toEqual([["migration", "squash"]]); - }).pipe(Effect.provide(Layer.mergeAll(proxy.layer, CliOutput.layer(textCliOutputFormatter())))); + const exit = yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ + "migrations", + "squash", + "--nope", + ]).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const causeJson = JSON.stringify(exit.cause); + // The alias resolved: the parse error is scoped to the squash LEAF, not the root. + expect(causeJson).toContain('"commandPath":["supabase","migration","squash"]'); + expect(causeJson).not.toContain('"subcommand":"migrations"'); + } + }).pipe(Effect.provide(CliOutput.layer(textCliOutputFormatter()))); // Command.runWith's Environment type is retained even though this path only needs CliOutput - // and the mocked proxy at runtime. + // at runtime. return run as Effect.Effect; }); }); diff --git a/apps/cli/src/legacy/commands/migration/migration.layers.ts b/apps/cli/src/legacy/commands/migration/migration.layers.ts index 906d8e3477..d2dc850e00 100644 --- a/apps/cli/src/legacy/commands/migration/migration.layers.ts +++ b/apps/cli/src/legacy/commands/migration/migration.layers.ts @@ -1,11 +1,13 @@ import { Layer } from "effect"; +import { legacyHttpClientLayer } from "../../auth/legacy-http-debug.layer.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { legacyCliConfigLayer } from "../../config/legacy-cli-config.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 { legacyDockerRunLayer } from "../../shared/legacy-docker-run.layer.ts"; import { legacyIdentityStitchLayer } from "../../shared/legacy-identity-stitch.ts"; import { legacyLinkedDbResolverRuntimeLayer } from "../../shared/legacy-management-api-runtime.layer.ts"; import { legacyTelemetryStateLayer } from "../../telemetry/legacy-telemetry-state.layer.ts"; @@ -58,3 +60,20 @@ export const legacyMigrationDbRuntimeLayer = (commandPath: ReadonlyArray legacyLinkedDbResolverRuntimeLayer(commandPath).pipe(Layer.provide(legacyIdentityStitchLayer)), commandRuntimeLayer(commandPath), ); + +const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + +/** + * Runtime layer for `supabase migration squash` — `legacyMigrationDbRuntimeLayer`'s bundle + * plus the three services only squash needs: `LegacyDockerRun` (the `pg_dump` one-shot + * container + the shadow's PG15+ one-shot setup jobs), `HttpClient` (the native shadow's + * health-check wait), and `LegacyDebugLogger` (Go's `GetDebugLogger()` on the + * `LoadLocalVersions` fallback). `ChildProcessSpawner`/`RuntimeInfo`/`Tty`/`FileSystem`/ + * `Path` come from the root layer, same as `db diff`. + */ +export const legacyMigrationSquashRuntimeLayer = Layer.mergeAll( + legacyMigrationDbRuntimeLayer(["migration", "squash"]), + legacyDockerRunLayer, + httpClient, + legacyDebugLoggerLayer, +); diff --git a/apps/cli/src/legacy/commands/migration/squash/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/migration/squash/SIDE_EFFECTS.md index 0f2143dca9..27a1cf7dca 100644 --- a/apps/cli/src/legacy/commands/migration/squash/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/migration/squash/SIDE_EFFECTS.md @@ -1,55 +1,152 @@ # `supabase migration squash` +Native Effect port (CLI-1969). Squashes every local migration up to (optionally) +`--version` into the last one — diffing a natively-provisioned shadow database's +`auth`/`storage` schemas before and after applying every migration, dumping the +full schema into the target file, and deleting the merged files — then either +suggests `migration repair` (local target) or prompts to baseline the remote +migration-history table to match. + ## Files Read -| Path | Format | When | -| -------------------------------- | ---------- | ------------------------------------------------- | -| `/supabase/migrations/` | directory | always, to read migration files | -| `~/.supabase/access-token` | plain text | when `SUPABASE_ACCESS_TOKEN` unset and `--linked` | +| Path | Format | When | +| ----------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always, twice: `@supabase/config` for the shadow's own spec, `legacyReadDbToml` for shadow port/password/vault/baseline | +| `/supabase/migrations/` | directory | always | +| `/supabase/migrations/_*.sql` | SQL | each migration up to the target, applied to the shadow; the target file's own final content is read by `--version`/baseline lookups | +| `/supabase/roles.sql` | SQL | shadow `SetupDatabase` (custom-roles seed); missing file tolerated | +| `/supabase/.env`, `.env.local`, `SUPABASE_ENV`-selected dotenv | dotenv | always (`--yes`/registry/network-id overrides) | +| `/supabase/.temp/{project-ref,postgres-version,pooler-url}` | plain text | `--linked` / linked path | +| `~/.supabase/access-token` | plain text | `--linked` without `--password`/`SUPABASE_ACCESS_TOKEN` | +| `~/.docker/config.json` + Docker context store | JSON | resolving the Docker hostname for shadow/pg_dump containers | ## Files Written -| Path | Format | When | -| -------------------------------------- | -------- | --------------------------------- | -| `/supabase/migrations/` files | SQL text | always — squashes migration files | +| Path | Format | When | +| -------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------- | +| `/supabase/migrations/.sql` | SQL text | ≥2 migrations squash — **truncated** (0644) then rewritten as the full dump + separator + `auth`/`storage` line diff | +| `/supabase/migrations/.sql` (×N) | — | **deleted** — every earlier merged migration; a per-file failure is non-fatal (printed, not raised) | +| scoped temp dir | SQL | shadow's `initSchema`/`ApplyApiPrivileges` SQL (PG≤14) — removed when the scope closes | +| `/supabase/.temp/linked-project.json` | JSON | `--linked` (post-run cache, even when the command itself fails) | +| `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | + +## Docker + +- Network ensure (`legacyEnsureNetwork`, same as `db diff`/`db pull`). +- Shadow Postgres container: no `--name`, no network alias, `--publish :5432`, + `-c max_worker_processes=0`, `--rm`, PG≤14 tmpfs on `/docker-entrypoint-initdb.d` — created, + started, health-polled (`container inspect`), then removed (`rm -f -v`) once squash finishes, + success or failure. +- PG15+ one-shot realtime/storage/auth migrate jobs (`initSchema15`), dialed at the shadow + container's own 12-char short id as `DB_HOST` (no name/alias needed — see + `shared/db-bootstrap/shadow-database.ts`'s own header for why that host still resolves). +- **Three** one-shot `pg_dump` containers, each a fresh `docker run` on **host** networking + (or the named `--network-id` network when set) — `["bash","-c", , "--"]`, + `PGHOST= PGPORT= PGUSER=postgres PGPASSWORD= PGDATABASE=postgres`, + the config Postgres image: + 1. before-migration `auth`/`storage` dump — `EXTRA_FLAGS=--schema=auth|storage`, `EXTRA_SED=/^--/d` + 2. after-migration `auth`/`storage` dump — identical env + 3. the final full dump (no schema filter) — `EXCLUDED_SCHEMAS=`, `EXTRA_SED=/^--/d`, streamed straight into the truncated target file + + Unlike `db diff`/`db pull`, the shadow only ever gets Go's `SetupDatabase` (platform + baseline + roles.sql) — **no** `CREATE DATABASE contrib_regression` template database, since + squash calls `start.SetupDatabase` directly rather than going through `setupShadowConn`. ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | +| Method | Path | Auth | Purpose | +| ---------- | ---------------------------------- | ------ | ---------------------------------------------------------- | +| — | — | — | local target: none | +| POST | `/v1/projects/{ref}/roles` | Bearer | `--linked`: temp login role when no password | +| GET | `/v1/projects/{ref}/pooler/config` | Bearer | `--linked`: IPv4 pooler fallback (IPv6-only network) | +| GET/DELETE | `/v1/projects/{ref}/network-bans` | Bearer | `--linked`: unban during pooler login retry | +| GET | `/v1/projects/{ref}` | Bearer | `--linked`: linked-project cache (post-run, unconditional) | ## 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 | +`SUPABASE_YES`, `DB_PASSWORD`, `SUPABASE_ACCESS_TOKEN`, `SUPABASE_SERVICES_HOSTNAME`, +`DOCKER_HOST`/`DOCKER_CONTEXT`/`DOCKER_CONFIG`, `SUPABASE_NETWORK_ID`, +`SUPABASE_INTERNAL_IMAGE_REGISTRY`, `SUPABASE_PROJECT_ID`, `SUPABASE_DEBUG`, +`SUPABASE_EXPERIMENTAL`. ## Exit Codes -| Code | Condition | -| ---- | ----------------------------------- | -| `0` | success | -| `1` | database connection failure | -| `1` | failed to read migrations directory | +| Code | Condition | +| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `0` | success — **including** the single-migration no-op **and** a declined remote-baseline prompt | +| `1` | invalid `--version`; `--version` file not found; `version not found`; migrations-dir read failure; shadow create/health/setup/apply failure; `pg_dump` non-zero exit; migration-file open/write failure; baseline connect/batch failure; flag-group conflicts | +| `130` | SIGINT | ## Output ### `--output-format text` (Go CLI compatible) -Prints "Finished `supabase migration squash`." on success. - -### `--output-format json` - -Not applicable. - -### `--output-format stream-json` - -Not applicable. +stderr, in order (path-dependent): + +``` +Loading config override: [remotes.] (only when --linked resolves a [remotes.] block) +Initialising schema... +Seeding globals from roles.sql... (unconditional — printed even when roles.sql is absent) +Applying migration ... (once per migration applied to the shadow) + is already the earliest migration. (single-migration no-op) + -- or -- +Squashed local migrations to + (per merged-file removal failure, non-fatal) +Failed to remove container: (shadow cleanup failure, non-fatal) +Update remote migration history table? [Y/n] (remote target only) +Baselining migration history to (remote target, prompt confirmed — BEFORE connecting) +Connecting to remote database... +``` + +stdout: only `Finished supabase migration squash.` (aqua), printed inline by the +handler itself — matching Go's per-command `PostRun` (there is no shared +group-level epilogue that prints it). Local target additionally prints `Run +supabase migration repair --status applied to update your remote migration +history table.` to stderr, after the stdout line. + +### `--output-format json` / `stream-json` + +Progress lines stay on stderr (including the confirmation prompt — Go's +`Console` ignores `--output`/`--output-format` entirely); stdout carries +`output.success("Migrations squashed", { squashedInto, removed, removeFailures, +alreadyEarliest, isLocal, baselinedVersion })` instead of the `Finished …` line +and (for the local target) the repair suggestion — both suppressed in machine +mode, matching `migration repair`/`migration up`. `removed` and `removeFailures` +partition every merged file between them: `removed` is the workdir-relative +paths that were successfully deleted, `removeFailures` is +`{ path, message }` for every merged file whose removal failed (`message` is +the same relativized text the text-mode stderr line prints) — a removal failure +is always non-fatal, so `removeFailures` being non-empty never changes the exit +code or the rest of the payload. ## Notes -- `--version` squashes up to the specified migration version. -- `--local` (default true), `--linked`, and `--db-url` are mutually exclusive. -- `--password` / `-p` sets the DB password. +- `--local` defaults **true** (Go: `Bool("local", true)`); `[db-url linked local]` and + `[db-url password]` are the two mutually-exclusive flag groups. +- The shadow gets `SetupDatabase` only — **no** `CREATE DATABASE contrib_regression` (unlike + `db diff`/`db pull`). +- `--version` is compared **lexically** against zero-padded timestamps (Go's `v <= version`). +- The baseline version is re-derived from the local migrations directory listing taken + **after** the merged-file removals — so a removal that failed non-fatally causes the + baseline to target the surviving **older** version, not the original squash target. +- A failed full-schema dump leaves the target migration truncated (Go's own behaviour, not + recoverable — the file was already truncated before the dump began). +- A declined "Update remote migration history table?" prompt is a **success** path (exit 0, + no baseline query, `Finished …` still prints) — the opposite of `migration repair`/`fetch`/ + `down`, which treat a decline as a cancellation. +- **Atomicity note:** Go sends the baseline `DELETE`/`INSERT` via `pgx.Batch` (a pipeline, not + an explicit transaction) — a partial failure could leave the DELETE applied without the + INSERT. The TS port wraps both statements in an explicit `BEGIN`/`COMMIT` with `ROLLBACK` on + error (matching `migration repair`'s own equivalent divergence); the success path is + byte-identical to Go. +- **Documented divergences** (neither reproduced, both judged strictly worse to replicate): + (a) `bufio.Scanner`'s 64 KiB `MaxScanTokenSize` silently truncates `lineByLineDiff`'s output + when a single dumped line exceeds it (`scanner.Err()` is never checked in Go) — not + reproduced (`squash.diff.ts`); (b) Go's own separator-comment write (`fmt.Fprint`, + `squash.go:130`) discards its error return, while the auth/storage diff write right after it + is checked — this port combines both into one write, so a hypothetical failure isolated to + just the separator bytes would (unlike Go) surface as `failed to write line: …`; not + realistically triggerable on a real filesystem for a single already-open file descriptor. +- `Initialising schema...` is printed by the shared setup prelude just before + `SetupDatabase` rather than from inside it — inherited from CLI-1956, shared with `db +diff`/`db pull`'s identical shadow-provisioning prelude. diff --git a/apps/cli/src/legacy/commands/migration/squash/squash.command.ts b/apps/cli/src/legacy/commands/migration/squash/squash.command.ts index b4429ae73a..38a3f48bd1 100644 --- a/apps/cli/src/legacy/commands/migration/squash/squash.command.ts +++ b/apps/cli/src/legacy/commands/migration/squash/squash.command.ts @@ -1,5 +1,9 @@ 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 { legacyMigrationSquashRuntimeLayer } from "../migration.layers.ts"; import { legacyMigrationSquash } from "./squash.handler.ts"; const config = { @@ -18,6 +22,8 @@ const config = { ), local: Flag.boolean("local").pipe( Flag.withDescription("Squashes the migration history of the local database."), + // Go: `squashFlags.Bool("local", true, …)`. + Flag.withDefault(true), ), password: Flag.string("password").pipe( Flag.withAlias("p"), @@ -31,5 +37,24 @@ export type LegacyMigrationSquashFlags = CliCommand.Command.Config.Infer legacyMigrationSquash(flags)), + Command.withHandler((flags) => + legacyMigrationSquash(flags).pipe( + withLegacyCommandInstrumentation({ + flags: { + version: flags.version, + "db-url": flags.dbUrl, + linked: flags.linked, + local: flags.local, + // `password` is a credential — always reaches telemetry as ``. + password: flags.password, + }, + // Go's `markFlagTelemetrySafe(migration.go:134)` — only `--version`'s value is + // recorded verbatim. + safeFlags: ["version"], + aliases: { p: "password" }, + }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyMigrationSquashRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/migration/squash/squash.diff.ts b/apps/cli/src/legacy/commands/migration/squash/squash.diff.ts new file mode 100644 index 0000000000..3fab835169 --- /dev/null +++ b/apps/cli/src/legacy/commands/migration/squash/squash.diff.ts @@ -0,0 +1,68 @@ +/** + * Pure port of Go's `separatorComment` constant and `lineByLineDiff` + * (`apps/cli-go/internal/migration/squash/squash.go:134-157`). No Effect, no + * services — just string in, string out — so this stays tightly unit-testable in + * isolation from the Docker/shadow-database machinery `squash.handler.ts` composes. + */ + +/** + * Go's `separatorComment` (`squash.go:134-139`) — a raw string literal that opens + * immediately with a newline, so the exact bytes carry a LEADING `\n`, not just the + * trailing blank line one might expect from the source layout. + */ +export const LEGACY_SQUASH_SEPARATOR_COMMENT = + "\n--\n-- Dumped schema changes for auth and storage\n--\n\n"; + +/** + * Go's `bufio.NewScanner(...).Split(bufio.ScanLines)` tokens for `text` + * (`bufio.ScanLines`): splits on `\n`, drops the trailing empty token a final `\n` + * would otherwise produce (a `\n`-terminated final line yields no extra token; a + * final line WITHOUT a trailing `\n` still yields a token), then strips exactly one + * trailing `\r` from every token — including the final, EOF-flushed one, since Go's + * `dropCR` runs on that branch too. An empty `text` yields zero tokens, matching + * `Scan()` returning `false` immediately on an empty reader. + * + * Deliberate divergence (documented in `SIDE_EFFECTS.md`): Go's `bufio.Scanner` also + * enforces `bufio.MaxScanTokenSize` (64 KiB) and silently truncates the scan when a + * single line exceeds it (`scanner.Err()` is never checked by `lineByLineDiff`) — not + * reproduced here, since replicating a silent-data-loss quirk would only make this + * port worse for users for no observable benefit on any realistic `auth`/`storage` + * dump line. + */ +export function legacySquashScanLines(text: string): ReadonlyArray { + if (text.length === 0) return []; + const lines = text.split("\n"); + if (text.endsWith("\n")) lines.pop(); + return lines.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line)); +} + +/** + * Go's `lineByLineDiff(before, after io.Reader, f io.Writer) error` + * (`squash.go:141-157`): a single forward pass over `after`'s lines, advancing an + * "anchor" cursor into `before`'s lines whenever the current `after` line matches it + * — emitting every `after` line that DOESN'T match, each with a trailing `\n` + * (`fmt.Fprintln`). Assumes `before` is a subset of `after` (true for a + * schema-only auth/storage dump before vs. after a migration apply — entities in + * those managed schemas are never altered by user migrations). + * + * `anchorText` reproduces Go's exhausted-scanner sentinel exactly: once every + * `before` token has been consumed, `anchor.Text()` returns `""` forever (Go's + * `bufio.Scanner` resets its last token to `nil` on the final, unsuccessful `Scan()` + * call) — so every subsequent blank line in `after` is silently swallowed rather than + * emitted, matching Go byte-for-byte. + */ +export function legacySquashLineByLineDiff(before: string, after: string): string { + const beforeTokens = legacySquashScanLines(before); + const afterTokens = legacySquashScanLines(after); + let anchorIndex = 0; + let out = ""; + for (const line of afterTokens) { + const anchorText = anchorIndex < beforeTokens.length ? beforeTokens[anchorIndex]! : ""; + if (line === anchorText) { + anchorIndex++; + continue; + } + out += `${line}\n`; + } + return out; +} diff --git a/apps/cli/src/legacy/commands/migration/squash/squash.diff.unit.test.ts b/apps/cli/src/legacy/commands/migration/squash/squash.diff.unit.test.ts new file mode 100644 index 0000000000..dd5d9d1a89 --- /dev/null +++ b/apps/cli/src/legacy/commands/migration/squash/squash.diff.unit.test.ts @@ -0,0 +1,120 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +import { + LEGACY_SQUASH_SEPARATOR_COMMENT, + legacySquashLineByLineDiff, + legacySquashScanLines, +} from "./squash.diff.ts"; + +/** + * Ports Go's `TestLineByLine` (`apps/cli-go/internal/migration/squash/squash_test.go`). + * The `before.sql`/`after.sql`/`diff.sql` fixtures are read directly from the Go oracle's + * own `testdata/` directory (same pattern as `legacy-pg-dump.env.unit.test.ts`'s + * `goScriptsDir`) rather than hand-transcribed as template literals: `apps/cli` still + * gains no new `testdata/` fixtures directory of its own, but a byte-for-byte copy of + * 90+109+19 lines of real `pg_dump` output is exactly the kind of content a manual + * transcription would silently corrupt (trailing whitespace, blank lines, quoting). + */ +const goTestdataDir = fileURLToPath( + new URL("../../../../../../cli-go/internal/migration/squash/testdata/", import.meta.url), +); +const readGoFixture = (name: string) => readFileSync(`${goTestdataDir}${name}`, "utf8"); + +describe("legacySquashLineByLineDiff", () => { + it("diffs real pg_dump output into Go's exact diff.sql bytes", () => { + const before = readGoFixture("before.sql"); + const after = readGoFixture("after.sql"); + const expected = readGoFixture("diff.sql"); + expect(legacySquashLineByLineDiff(before, after)).toBe(expected); + }); + + it("keeps only after-only lines when before is shorter", () => { + const before = "select 1;"; + const after = "select 0;\nselect 1;\nselect 2;"; + expect(legacySquashLineByLineDiff(before, after)).toBe("select 0;\nselect 2;\n"); + }); + + it("emits nothing when after is shorter", () => { + const before = "select 1;\nselect 2;"; + const after = "select 1;"; + expect(legacySquashLineByLineDiff(before, after)).toBe(""); + }); + + it("emits the single after line when nothing matches", () => { + const before = "select 0;\nselect 1;"; + const after = "select 1;"; + expect(legacySquashLineByLineDiff(before, after)).toBe("select 1;\n"); + }); + + it('swallows every subsequent after line once before is exhausted (the anchor.Text() === "" sentinel)', () => { + // Once `before` runs out of tokens, Go's `anchor.Text()` returns `""` forever, so a + // blank line in `after` matches that sentinel and is silently dropped — NOT emitted + // as if it were an unmatched line. `before` has a single non-blank token; every + // remaining `after` line (including two literal blank lines) must vanish. + const before = "create schema test;"; + const after = "create schema test;\n\n\nselect 1;"; + expect(legacySquashLineByLineDiff(before, after)).toBe("select 1;\n"); + }); + + it("strips one trailing \\r per line like bufio.ScanLines (CRLF before, LF after)", () => { + const before = "select 1;\r\nselect 2;\r\n"; + const after = "select 1;\nselect 2;\n"; + // After stripping the trailing \r from each `before` token, every `after` line + // matches its anchor — the diff is empty. + expect(legacySquashLineByLineDiff(before, after)).toBe(""); + }); + + it("treats a final line without a trailing newline as a token, and a trailing newline as no extra empty token", () => { + // `before` has no trailing newline (one token, "a"); `after` DOES (two tokens: "a", + // "b"), so only "b" is unmatched — a final "\n" must not manufacture a phantom empty + // token that would otherwise consume the "b" match or emit an extra blank line. + const before = "a"; + const after = "a\nb\n"; + expect(legacySquashLineByLineDiff(before, after)).toBe("b\n"); + }); +}); + +describe("legacySquashScanLines", () => { + it("yields zero tokens for an empty string", () => { + expect(legacySquashScanLines("")).toEqual([]); + }); + + it("yields one token for a single line with no trailing newline", () => { + expect(legacySquashScanLines("select 1;")).toEqual(["select 1;"]); + }); + + it("drops the trailing empty token a final newline would otherwise produce", () => { + expect(legacySquashScanLines("a\nb\n")).toEqual(["a", "b"]); + }); + + it("keeps an interior blank line as its own empty-string token", () => { + expect(legacySquashScanLines("a\n\nb")).toEqual(["a", "", "b"]); + }); + + it("strips exactly one trailing \\r from every token, including the final EOF-flushed one", () => { + expect(legacySquashScanLines("a\r\nb\r")).toEqual(["a", "b"]); + }); + + it("does not strip more than one trailing \\r", () => { + expect(legacySquashScanLines("a\r\r\n")).toEqual(["a\r"]); + }); + + it("treats a lone \\r with no following \\n as part of the final token, then strips it", () => { + expect(legacySquashScanLines("only-cr\r")).toEqual(["only-cr"]); + }); +}); + +describe("LEGACY_SQUASH_SEPARATOR_COMMENT", () => { + it("carries Go's leading newline before the dashed comment banner", () => { + expect(LEGACY_SQUASH_SEPARATOR_COMMENT).toBe( + "\n--\n-- Dumped schema changes for auth and storage\n--\n\n", + ); + }); + + it("starts with \\n, not with the comment banner itself", () => { + expect(LEGACY_SQUASH_SEPARATOR_COMMENT.startsWith("\n--")).toBe(true); + expect(LEGACY_SQUASH_SEPARATOR_COMMENT.startsWith("--")).toBe(false); + }); +}); diff --git a/apps/cli/src/legacy/commands/migration/squash/squash.dump.ts b/apps/cli/src/legacy/commands/migration/squash/squash.dump.ts new file mode 100644 index 0000000000..798f425824 --- /dev/null +++ b/apps/cli/src/legacy/commands/migration/squash/squash.dump.ts @@ -0,0 +1,97 @@ +import { Effect } from "effect"; + +import type { LegacyPgConnInput } from "../../../shared/legacy-db-connection.service.ts"; +import { + legacyBuildSchemaDumpEnv, + type LegacyDumpOptions, +} from "../../../shared/legacy-pg-dump.env.ts"; +import { legacyDumpSchemaScript } from "../../../shared/legacy-pg-dump.scripts.ts"; +import { legacyStreamPgDump } from "../../../shared/legacy-pg-dump.run.ts"; +import { LegacyMigrationSquashDumpError } from "./squash.errors.ts"; + +/** + * Input to {@link legacySquashDumpSchema} — squash's own thin wrapper over one + * `migration.DumpSchema` call (`pkg/migration/dump.go`). + */ +export interface LegacySquashDumpParams { + /** + * `utils.Config.Db.Image` — the pin-resolved (not yet registry-mapped) Postgres + * image (`localInputs.bootstrapConfig.postgresImage`); {@link legacyStreamPgDump} + * applies the registry mirror itself, mirroring Go's `DockerStart` -> + * `GetRegistryImageUrl`. + */ + readonly image: string; + /** The shadow's own connect target (host / shadow port / `postgres` / password / `postgres`). */ + readonly conn: LegacyPgConnInput; + /** `["auth","storage"]` for the before/after diff dumps, `[]` for the unrestricted full dump. */ + readonly schema: ReadonlyArray; + /** Receives each stdout chunk in arrival order; its failure aborts the run as `E`. */ + readonly onStdout: (chunk: Uint8Array) => Effect.Effect; +} + +/** + * Port of Go's `migration.DumpSchema(ctx, cfg, w, dump.DockerExec, opts...)` + * (`pkg/migration/dump.go`): a schema-only `pg_dump`, streamed to `onStdout` at + * constant memory. `squashMigrations` calls this exactly three times + * (`apps/cli-go/internal/migration/squash/squash.go:109,116,126`): before/after + * with `WithSchema("auth","storage")`, and a third, unrestricted call for the final + * full dump written straight to the target migration file. + */ +export const legacySquashDumpSchema = Effect.fnUntraced(function* ( + params: LegacySquashDumpParams, +) { + const opt: LegacyDumpOptions = { + schema: params.schema, + keepComments: false, + excludeTable: [], + columnInsert: false, + }; + const result = yield* legacyStreamPgDump({ + image: params.image, + script: legacyDumpSchemaScript, + env: legacyBuildSchemaDumpEnv(params.conn, opt), + onStdout: params.onStdout, + }); + if (result.exitCode !== 0) { + return yield* Effect.fail( + new LegacyMigrationSquashDumpError({ + message: `error running container: exit ${result.exitCode}`, + }), + ); + } +}); + +/** Concatenates stdout chunks into one buffer, mirroring Go's `bytes.Buffer` sink. */ +const concatChunks = (chunks: ReadonlyArray): Uint8Array => { + 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 bytes; +}; + +/** + * Buffered convenience over {@link legacySquashDumpSchema} for the before/after + * diff dumps — mirrors Go's own `bytes.Buffer` sink (`squash.go:108`), which is + * inherently in-memory too: an `auth`/`storage` schema-only dump is tens of KB, not + * a streaming-scale payload. The FULL dump never goes through this — it streams + * straight to the target migration file's own handle at constant memory + * (`squash.handler.ts`'s `squashMigrations`). + */ +export const legacySquashDumpSchemaToString = Effect.fnUntraced(function* (params: { + readonly image: string; + readonly conn: LegacyPgConnInput; + readonly schema: ReadonlyArray; +}) { + const chunks: Array = []; + yield* legacySquashDumpSchema({ + image: params.image, + conn: params.conn, + schema: params.schema, + onStdout: (chunk) => Effect.sync(() => chunks.push(chunk)), + }); + return new TextDecoder().decode(concatChunks(chunks)); +}); diff --git a/apps/cli/src/legacy/commands/migration/squash/squash.e2e.test.ts b/apps/cli/src/legacy/commands/migration/squash/squash.e2e.test.ts new file mode 100644 index 0000000000..c57ebdf446 --- /dev/null +++ b/apps/cli/src/legacy/commands/migration/squash/squash.e2e.test.ts @@ -0,0 +1,77 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { runSupabase, stripAnsi } from "../../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; + +describe("supabase migration squash (legacy)", () => { + let workdir: string; + beforeEach(() => { + workdir = mkdtempSync(join(tmpdir(), "sb-mig-squash-e2e-")); + mkdirSync(join(workdir, "supabase", "migrations"), { recursive: true }); + writeFileSync(join(workdir, "supabase", "config.toml"), "[db]\nport = 54322\n"); + }); + afterEach(() => { + rmSync(workdir, { recursive: true, force: true }); + }); + + // Real-subprocess guard for the production layer graph: `--version 0_init` is + // not a valid integer, so `squash.Run`'s bare `invalid version number` message + // (no repair-style `failed to parse :` prefix) must surface — proving the + // real `legacyMigrationSquashRuntimeLayer` builds end to end, without ever + // touching Docker/Postgres. This is the same class of missing-service bug the + // `migration fetch` e2e exists to catch. Unlike a declined confirmation prompt + // (Go's `context.Canceled`), this is a genuine validation error, so the usual + // `--debug` troubleshooting hint still follows it (`output.layer.ts`'s + // `CONTEXT_CANCELED_MESSAGE` guard does not apply here). + test( + "rejects a non-numeric --version with the bare Go message", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { exitCode, stderr } = await runSupabase( + ["migration", "squash", "--version", "0_init"], + { + entrypoint: "legacy", + cwd: workdir, + }, + ); + + expect(exitCode).toBe(1); + const text = stripAnsi(stderr); + expect(text).toContain("invalid version number"); + expect(text).not.toContain("failed to parse"); + expect(text).toContain("Try rerunning the command with --debug to troubleshoot the error."); + }, + ); + + // Golden path with no Docker required: a single local migration short-circuits + // `squashToVersion` before any shadow-database work, so this proves the whole + // local no-op + `--local` suggestion path end to end. + test( + "no-ops on a single local migration and suggests migration repair", + { timeout: E2E_TIMEOUT_MS }, + async () => { + writeFileSync( + join(workdir, "supabase", "migrations", "20240101000000_init.sql"), + "select 1;\n", + ); + + const { exitCode, stdout, stderr } = await runSupabase(["migration", "squash", "--local"], { + entrypoint: "legacy", + cwd: workdir, + }); + + expect(exitCode).toBe(0); + expect(stripAnsi(stderr)).toContain( + "supabase/migrations/20240101000000_init.sql is already the earliest migration.", + ); + expect(stripAnsi(stdout)).toContain("Finished supabase migration squash."); + expect(stripAnsi(stderr)).toContain( + "Run supabase migration repair --status applied to update your remote migration history table.", + ); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/migration/squash/squash.errors.ts b/apps/cli/src/legacy/commands/migration/squash/squash.errors.ts new file mode 100644 index 0000000000..d7d0b2dc7e --- /dev/null +++ b/apps/cli/src/legacy/commands/migration/squash/squash.errors.ts @@ -0,0 +1,71 @@ +import { Data } from "effect"; + +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + +/** + * `squashToVersion` found no local migrations to squash — either the migrations + * directory is empty, or `--version` filtered out every file. Byte-matches Go's + * `ErrMissingVersion` (`squash.go:26`, `errors.New("version not found")`). + */ +export class LegacyMigrationSquashMissingVersionError extends Data.TaggedError( + "LegacyMigrationSquashMissingVersionError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** + * One of squash's three `pg_dump` containers exited non-zero. Byte-matches Go's + * `"error running container: exit " + code` (`DockerStreamLogs`, reached via + * `migration.DumpSchema` -> `dump.DockerExec`). + */ +export class LegacyMigrationSquashDumpError extends Data.TaggedError( + "LegacyMigrationSquashDumpError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} + +/** + * The target migration file could not be truncated/opened for writing, or a chunk + * of the full dump/separator/diff could not be appended to it. Byte-matches Go's + * `"failed to open migration file: " + err` (`squash.go:123`) / `"failed to write + * line: " + err` (`squash.go:153`). + */ +export class LegacyMigrationSquashWriteError extends Data.TaggedError( + "LegacyMigrationSquashWriteError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} + +/** + * `baselineMigrations`'s history-table batch (`DELETE_MIGRATION_BEFORE` + + * `INSERT_MIGRATION_VERSION`) failed to send/commit. Byte-matches Go's `"failed to + * update migration history: " + err` (`squash.go:187`). Classified `dbConnection`, + * matching `migration repair`'s `LegacyMigrationRepairUpdateError` + * (`repair.errors.ts:19`) — both wrap the identical history-table batch-send + * failure shape. + */ +export class LegacyMigrationSquashBaselineError extends Data.TaggedError( + "LegacyMigrationSquashBaselineError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} diff --git a/apps/cli/src/legacy/commands/migration/squash/squash.handler.ts b/apps/cli/src/legacy/commands/migration/squash/squash.handler.ts index c5a459f089..ef7b5657c6 100644 --- a/apps/cli/src/legacy/commands/migration/squash/squash.handler.ts +++ b/apps/cli/src/legacy/commands/migration/squash/squash.handler.ts @@ -1,16 +1,596 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Effect, FileSystem, Option, Path } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type { ChildProcessSpawner as ChildProcessSpawnerType } from "effect/unstable/process/ChildProcessSpawner"; + +import { cobraMutuallyExclusiveErrorMessage } from "../../../../shared/cli/cobra-flag-groups.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { + LegacyDebugFlag, + LegacyDnsResolverFlag, + LegacyNetworkIdFlag, + legacyResolveYesWithProjectEnv, +} from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.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 { + legacyBuildLocalDbContainerInputs, + type LegacyLocalDbContainerInputs, +} from "../../../shared/db-bootstrap/local-container-inputs.ts"; +import { + legacyResolveDbSetupPrelude, + legacySetupDatabase, +} from "../../../shared/db-bootstrap/db-setup.ts"; +import { legacyWaitForHealthyServices } from "../../../shared/db-bootstrap/health-check.ts"; +import { + legacyBuildShadowSetupDatabaseInput, + legacyConnectShadowDatabase, + legacyCreateShadowDatabase, + legacyRemoveShadowDatabase, + legacyShadowRunInputFromLocalContainerInputs, +} from "../../../shared/db-bootstrap/shadow-database.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import { + legacyLoadProjectEnv, + legacyReadDbToml, + type LegacyDbTomlValues, +} from "../../../shared/legacy-db-config.toml-read.ts"; +import type { LegacyResolvedDbConfig } from "../../../shared/legacy-db-config.types.ts"; +import { + LegacyDbConnection, + type LegacyPgConnInput, +} from "../../../shared/legacy-db-connection.service.ts"; +import { resolveLegacyDbTargetFlags } from "../../../shared/legacy-db-target-flags.ts"; +import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; +import { + legacyErrorMessage, + legacyRelativizeErrorMessage, +} from "../../../shared/legacy-error-message.ts"; +import { + legacyApplyMigrations, + LegacyMigrationApplyError, +} from "../../../shared/legacy-migration-apply.ts"; +import { + DELETE_MIGRATION_BEFORE, + INSERT_MIGRATION_VERSION, + legacyCreateMigrationTable, + legacyLoadLocalVersions, + legacyLoadPartialMigrations, + legacyReadMigrationFile, + legacyResolveMigrationFile, +} from "../../../shared/legacy-migration-history.ts"; +import { legacyParseMigrationVersion } from "../../../shared/legacy-migration-timestamp.format.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { + LegacyMigrationFileNotFoundError, + LegacyMigrationInvalidVersionError, + LegacyMigrationPasswordFlagsError, + LegacyMigrationTargetFlagsError, +} from "../migration.errors.ts"; +import { legacyMigrationConfirm } from "../migration.prompt.ts"; import type { LegacyMigrationSquashFlags } from "./squash.command.ts"; +import { LEGACY_SQUASH_SEPARATOR_COMMENT, legacySquashLineByLineDiff } from "./squash.diff.ts"; +import { legacySquashDumpSchema, legacySquashDumpSchemaToString } from "./squash.dump.ts"; +import { + LegacyMigrationSquashBaselineError, + LegacyMigrationSquashMissingVersionError, + LegacyMigrationSquashWriteError, +} from "./squash.errors.ts"; + +type Spawner = ChildProcessSpawnerType["Service"]; + +/** + * Port of Go's `squashMigrations` (`apps/cli-go/internal/migration/squash/squash.go:81-132`): + * shadow create -> health-wait -> connect -> `start.SetupDatabase` DIRECTLY (Go's `squash.go:96` + * — NOT `setupShadowConn`, so NO `CREATE DATABASE contrib_regression` template) -> dump the + * auth/storage schema before migrating -> apply every migration -> dump auth/storage again -> + * write the target file as the FULL (unrestricted) dump + the separator + the auth/storage + * line diff. `acquire` is only shadow creation (brief, Docker-API-bound); the health-wait/ + * connect/setup/dump/apply sequence runs in the interruptible `use` phase, matching the CLI-1956 + * review ruling `shadow-database.ts`/`diff.handler.ts` already established (a SIGINT during the + * health-wait must land immediately, same as Go's single cancellable `ctx`). + */ +const squashMigrations = Effect.fnUntraced(function* ( + spawner: Spawner, + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + migrations: ReadonlyArray, + localInputs: LegacyLocalDbContainerInputs, + toml: LegacyDbTomlValues, +) { + const resolvedShadowImage = yield* localInputs.resolvePostgresImage; + const shadowInput = legacyShadowRunInputFromLocalContainerInputs( + localInputs, + resolvedShadowImage, + toml, + fs, + path, + ); + const connConfig: LegacyPgConnInput = { + host: localInputs.context.hostname, + port: toml.shadowPort, + user: "postgres", + password: toml.password, + database: "postgres", + }; + // Go's `utils.Config.Db.Image` — the pin-resolved (not yet registry-mapped) image every + // `pg_dump` container below uses; `legacySquashDumpSchema` applies the registry mirror itself. + const image = localInputs.bootstrapConfig.postgresImage; + + yield* Effect.acquireUseRelease( + legacyCreateShadowDatabase(spawner, shadowInput), + (handle) => + Effect.scoped( + Effect.gen(function* () { + yield* legacyWaitForHealthyServices(spawner, [handle.containerId], { + timeoutSeconds: shadowInput.healthTimeoutSeconds, + }); + const session = yield* legacyConnectShadowDatabase(connConfig); + const resolved = yield* legacyResolveDbSetupPrelude(shadowInput.setup); + yield* legacySetupDatabase( + spawner, + legacyBuildShadowSetupDatabaseInput( + { + fs: shadowInput.fs, + path: shadowInput.path, + workdir: shadowInput.workdir, + projectId: shadowInput.projectId, + container: handle.containerId, + networkId: shadowInput.networkId, + connConfig, + setup: shadowInput.setup, + }, + session, + resolved, + ), + ); + + const before = yield* legacySquashDumpSchemaToString({ + image, + conn: connConfig, + schema: ["auth", "storage"], + }); + yield* legacyApplyMigrations( + session, + fs, + path, + migrations, + (message) => new LegacyMigrationApplyError({ message }), + ); + const after = yield* legacySquashDumpSchemaToString({ + image, + conn: connConfig, + schema: ["auth", "storage"], + }); + + const targetPath = migrations[migrations.length - 1]!; + const targetRel = path.relative(workdir, targetPath); + yield* Effect.scoped( + Effect.gen(function* () { + // Go's `OpenFile(path, O_WRONLY|O_CREATE|O_TRUNC, 0644)` (`squash.go:121`) — ONE + // call that both truncates (or creates) the target file AND opens it for the + // writes below, matching `new.handler.ts:87`'s identical `{ flag: "w" }` precedent. + // There is no separate truncate-then-reopen step to diverge from Go's single + // `OpenFile`. + const file = yield* fs.open(targetPath, { flag: "w", mode: 0o644 }).pipe( + Effect.mapError( + (cause) => + new LegacyMigrationSquashWriteError({ + message: `failed to open migration file: ${legacyRelativizeErrorMessage(legacyErrorMessage(cause), targetPath, targetRel)}`, + }), + ), + ); + // The full dump — NO schema restriction (Go's `migration.DumpSchema(ctx, config, + // f, dump.DockerExec)`, no `opt`, `squash.go:126`) — streamed straight into the + // already-truncated file at constant memory. Go's underlying failure here is + // `stdcopy.StdCopy`'s own write into `f` (`DockerStreamLogs`, `docker.go:574-576`), + // not `lineByLineDiff`'s own writer below, so it byte-matches "failed to copy + // docker logs:" rather than "failed to write line:". + yield* legacySquashDumpSchema({ + image, + conn: connConfig, + schema: [], + onStdout: (chunk) => + file.writeAll(chunk).pipe( + Effect.mapError( + (cause) => + new LegacyMigrationSquashWriteError({ + message: `failed to copy docker logs: ${legacyErrorMessage(cause)}`, + }), + ), + ), + }); + // Go writes the separator (`fmt.Fprint`, `squash.go:130` — its error return is + // discarded, unchecked) then the auth/storage line diff (`lineByLineDiff`, + // `squash.go:131`) sequentially to the SAME handle, with nothing observable + // between the two writes — combined into one `writeAll` here. + const tail = + LEGACY_SQUASH_SEPARATOR_COMMENT + legacySquashLineByLineDiff(before, after); + yield* file.writeAll(new TextEncoder().encode(tail)).pipe( + Effect.mapError( + (cause) => + new LegacyMigrationSquashWriteError({ + message: `failed to write line: ${legacyRelativizeErrorMessage(legacyErrorMessage(cause), targetPath, targetRel)}`, + }), + ), + ); + }), + ); + }), + ), + (handle) => legacyRemoveShadowDatabase(spawner, handle.containerId), + ); +}); + +/** Outcome of {@link squashToVersion} — feeds the machine-mode payload. */ +interface LegacySquashToVersionResult { + readonly alreadyEarliest: boolean; + /** Workdir-relative path of the migration everything squashed into (Go's bold `local`). */ + readonly target: string; + /** Workdir-relative paths of the merged files that were successfully removed. */ + readonly removed: ReadonlyArray; + /** The rest: merged files whose removal failed — non-fatal, so `removed`/`removeFailures` always partition every merged file between them. */ + readonly removeFailures: ReadonlyArray<{ readonly path: string; readonly message: string }>; +} + +/** + * Port of Go's `squashToVersion` (`apps/cli-go/internal/migration/squash/squash.go:54-79`): + * loads the local migrations up to `version` (all when empty), squashes every one but the + * last into the shadow-produced dump, then removes the merged files — a removal failure is + * NON-FATAL (Go only prints it to stderr and continues). + */ +const squashToVersion = Effect.fnUntraced(function* ( + spawner: Spawner, + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + migrationsDir: string, + version: string, + localInputs: LegacyLocalDbContainerInputs, + toml: LegacyDbTomlValues, +) { + const output = yield* Output; + const migrations = yield* legacyLoadPartialMigrations(fs, path, migrationsDir, version); + if (migrations.length === 0) { + return yield* Effect.fail( + new LegacyMigrationSquashMissingVersionError({ message: "version not found" }), + ); + } + + const local = migrations[migrations.length - 1]!; + const rel = path.relative(workdir, local); + if (migrations.length === 1) { + yield* output.raw(`${legacyBold(rel)} is already the earliest migration.\n`, "stderr"); + return { + alreadyEarliest: true, + target: rel, + removed: [], + removeFailures: [], + } satisfies LegacySquashToVersionResult; + } + + yield* squashMigrations(spawner, fs, path, workdir, migrations, localInputs, toml); + yield* output.raw(`Squashed local migrations to ${legacyBold(rel)}\n`, "stderr"); + + const removed: Array = []; + const removeFailures: Array<{ readonly path: string; readonly message: string }> = []; + for (const merged of migrations.slice(0, -1)) { + const mergedRel = path.relative(workdir, merged); + yield* fs.remove(merged).pipe( + Effect.matchEffect({ + onFailure: (cause) => { + const message = legacyRelativizeErrorMessage( + legacyErrorMessage(cause), + merged, + mergedRel, + ); + removeFailures.push({ path: mergedRel, message }); + return output.raw(`${message}\n`, "stderr"); + }, + onSuccess: () => + Effect.sync(() => { + removed.push(mergedRel); + }), + }), + ); + } + return { + alreadyEarliest: false, + target: rel, + removed, + removeFailures, + } satisfies LegacySquashToVersionResult; +}); + +/** + * Port of Go's `baselineMigrations` (`apps/cli-go/internal/migration/squash/squash.go:159-190`): + * re-derives an empty `version` from the (POST-file-removal) local version listing, prints the + * "Baselining…" banner BEFORE connecting, then deletes every history row `<= version` and + * inserts the target migration's row in one transaction. + * + * The re-list runs AFTER `squashToVersion`'s file removals (this function is only ever called + * once that has fully completed) — so when a merged-file removal failed non-fatally, this + * baselines to the surviving OLDER version, not the squash target. Do not "optimise" this by + * passing the already-known target version through instead; that would silently diverge from + * Go on exactly that path. + */ +const baselineMigrations = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + migrationsDir: string, + cfg: LegacyResolvedDbConfig, + dnsResolver: "native" | "https", + version: string, +) { + const output = yield* Output; + const connection = yield* LegacyDbConnection; + const debugLogger = yield* LegacyDebugLogger; + + let resolvedVersion = version; + if (resolvedVersion.length === 0) { + // Go's `list.LoadLocalVersions` — a read failure only logs via `utils.GetDebugLogger()` + // and leaves `version` empty; it never aborts the baseline. + const local = yield* legacyLoadLocalVersions(fs, path, migrationsDir).pipe( + Effect.catch((cause) => + debugLogger.debug(cause.message).pipe(Effect.as([] as ReadonlyArray)), + ), + ); + if (local.length > 0) resolvedVersion = local[0]!; + } + + // Go prints this BEFORE connecting (`squash.go:165`, ahead of `utils.ConnectByConfig` at + // `squash.go:166`) — the opposite order from every other prompting migration subcommand. + yield* output.raw(`Baselining migration history to ${resolvedVersion}\n`, "stderr"); + + yield* Effect.scoped( + Effect.gen(function* () { + // Always remote: `runSquash` already returned on the local target (step 9) before + // `baselineMigrations` is ever called, so `cfg.isLocal` is necessarily `false` here — + // matching Go's own unconditional "Connecting to remote database..." on this path + // (`ConnectByConfigStream`, `connect.go:331-336`; the `IsLocalDatabase` branch right + // above it is unreachable from `baselineMigrations`'s only caller). + yield* output.raw("Connecting to remote database...\n", "stderr"); + const session = yield* connection.connect(cfg.conn, { isLocal: cfg.isLocal, dnsResolver }); + yield* legacyCreateMigrationTable(session); + + const resolvedFile = yield* legacyResolveMigrationFile( + fs, + path, + migrationsDir, + resolvedVersion, + ); + if (Option.isNone(resolvedFile)) { + return yield* Effect.fail( + new LegacyMigrationFileNotFoundError({ + message: `glob supabase/migrations/${resolvedVersion}_*.sql: file does not exist`, + }), + ); + } + const m = yield* legacyReadMigrationFile(fs, path, resolvedFile.value); + + // Go's `pgx.Batch` (`squash.go:183-186`) — data statements only, no schema mutation, so + // (matching `migration repair`'s own `updateMigrationTable`) wrapped in an explicit + // transaction for atomicity between the DELETE and the INSERT. + const txn = Effect.gen(function* () { + yield* session.exec("BEGIN"); + yield* session.query(DELETE_MIGRATION_BEFORE, [m.version]); + yield* session.query(INSERT_MIGRATION_VERSION, [m.version, m.name, m.statements]); + yield* session.exec("COMMIT"); + }); + yield* txn.pipe( + Effect.tapError(() => session.exec("ROLLBACK").pipe(Effect.ignore)), + Effect.mapError( + (cause) => + new LegacyMigrationSquashBaselineError({ + message: `failed to update migration history: ${legacyErrorMessage(cause)}`, + }), + ), + ); + }), + ); + + return resolvedVersion; +}); + +const runSquash = Effect.fnUntraced(function* ( + flags: LegacyMigrationSquashFlags, + target: ReturnType, +) { + const output = yield* Output; + const resolver = yield* LegacyDbConfigResolver; + const cliConfig = yield* LegacyCliConfig; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dnsResolver = yield* LegacyDnsResolverFlag; + const debug = yield* LegacyDebugFlag; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtimeInfo = yield* RuntimeInfo; + const networkIdFlag = yield* LegacyNetworkIdFlag; + + // Resolved linked ref, captured so the post-run finalizer caches the project + // (GET /v1/projects/{ref}) — Go's `ensureProjectGroupsCached` (cmd/root.go:214). + let linkedRefForCache: string | undefined; + + yield* Effect.gen(function* () { + // 1. Flag groups — cobra's parse-time `MarkFlagsMutuallyExclusive`, ahead of the root + // `PersistentPreRunE` (`apps/cli-go/cmd/migration.go:66-75`). + if (target.setFlags.length > 1) { + return yield* Effect.fail( + new LegacyMigrationTargetFlagsError({ + message: cobraMutuallyExclusiveErrorMessage( + ["db-url", "linked", "local"], + target.setFlags, + ), + }), + ); + } + if (Option.isSome(flags.dbUrl) && Option.isSome(flags.password)) { + return yield* Effect.fail( + new LegacyMigrationPasswordFlagsError({ + message: cobraMutuallyExclusiveErrorMessage( + ["db-url", "password"], + ["db-url", "password"], + ), + }), + ); + } + + const migrationsDir = path.join(cliConfig.workdir, "supabase", "migrations"); + // squash defaults to `--local` (Go: `Bool("local", true)`), same as `up`/`down`. + const connType = target.connType ?? "local"; + + // 2/3. Linked pre-resolution (mirrors `db diff --linked`, `diff.handler.ts:400-430`): + // resolve + cache the project ref, and read the remote-merged config, BEFORE + // `resolver.resolve()` below — matching Go's stateful pre-run (`LoadProjectRef` -> the + // remote-merged `LoadConfig` -> only THEN `NewDbConfigWithPassword`'s actual connection + // work). Read unconditionally (base config when not linked) since the shadow is provisioned + // locally regardless of the remote/local target. + let linkedRef: string | undefined; + if (connType === "linked") { + const projectRefResolver = yield* LegacyProjectRefResolver; + linkedRef = yield* projectRefResolver.loadProjectRef(Option.none()); + linkedRefForCache = linkedRef; + } + const toml = yield* legacyReadDbToml(fs, path, cliConfig.workdir, linkedRef); + if (toml.appliedRemote !== undefined) { + yield* output.raw(`Loading config override: [remotes.${toml.appliedRemote}]\n`, "stderr"); + } + + // 4. The shadow's own container spec — always built, and built BEFORE `resolver.resolve()` + // below, matching Go's config-load-then-connect ordering (`diff.handler.ts`'s identical + // rationale: all config load/validation happens ahead of `NewDbConfigWithPassword`). + const localInputs = yield* legacyBuildLocalDbContainerInputs( + spawner, + cliConfig.workdir, + networkIdFlag, + runtimeInfo.platform, + debug, + connType === "linked" ? linkedRef : undefined, + toml.remoteOverrideKeys, + ); + + // 5. Resolve the target connection — the resolver owns `--password`/`DB_PASSWORD`/ + // temp-login-role/IPv6 handling for `--linked`, so squash needs no bespoke password prompt. + const cfg = yield* resolver.resolve({ + dbUrl: flags.dbUrl, + connType, + dnsResolver, + password: flags.password, + }); + if (linkedRef === undefined) { + linkedRef = Option.getOrUndefined(cfg.ref ?? Option.none()); + } + if (linkedRef !== undefined) linkedRefForCache = linkedRef; + + // 6. Go loads the project `.env` via `loadNestedEnv` INSIDE `ParseDatabaseConfig`, after the + // flag-group validation above — so a `SUPABASE_YES` set only in `supabase/.env` auto-confirms + // the remote-baseline prompt, but a flag conflict still surfaces before any `.env` read. + const projectEnv = yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir); + const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); + + // 7. `--version` validation — inside Go's `squash.Run`, i.e. AFTER db-config resolution. + const version = Option.getOrElse(flags.version, () => ""); + if (version.length > 0) { + if (legacyParseMigrationVersion(version) === undefined) { + // Bare message — squash does NOT inherit repair's "failed to parse : " prefix + // (`squash.go:30` is `errors.New(repair.ErrInvalidVersion)`, no `Errorf` wrap). + return yield* Effect.fail( + new LegacyMigrationInvalidVersionError({ message: "invalid version number" }), + ); + } + const versionFile = yield* legacyResolveMigrationFile(fs, path, migrationsDir, version); + if (Option.isNone(versionFile)) { + return yield* Effect.fail( + new LegacyMigrationFileNotFoundError({ + message: `glob supabase/migrations/${version}_*.sql: file does not exist`, + }), + ); + } + } + + // 8. Squash local migrations. + const squashResult = yield* squashToVersion( + spawner, + fs, + path, + cliConfig.workdir, + migrationsDir, + version, + localInputs, + toml, + ); + + // 9. Local target: suggest `migration repair` instead of touching the remote history. + if (cfg.isLocal) { + if (output.format === "text") { + yield* output.raw(`Finished ${legacyAqua("supabase migration squash")}.\n`); + yield* output.raw( + `Run ${legacyAqua("supabase migration repair --status applied")} to update your remote migration history table.\n`, + "stderr", + ); + } else { + yield* output.success("Migrations squashed", { + squashedInto: squashResult.target, + removed: squashResult.removed, + removeFailures: squashResult.removeFailures, + alreadyEarliest: squashResult.alreadyEarliest, + isLocal: true, + baselinedVersion: null, + }); + } + return; + } + + // 10. Remote target: prompt before touching the remote history table. A DECLINED prompt is + // still a SUCCESS path in Go (`squash.go:47` returns `nil`, not `context.Canceled`) — unlike + // repair/fetch/down, so this never raises `LegacyOperationCanceledError`. + const confirmed = yield* legacyMigrationConfirm("Update remote migration history table?", { + defaultValue: true, + yes, + }); + let baselinedVersion: string | null = null; + if (confirmed) { + baselinedVersion = yield* baselineMigrations( + fs, + path, + migrationsDir, + cfg, + dnsResolver, + version, + ); + } + + if (output.format === "text") { + yield* output.raw(`Finished ${legacyAqua("supabase migration squash")}.\n`); + } else { + yield* output.success("Migrations squashed", { + squashedInto: squashResult.target, + removed: squashResult.removed, + removeFailures: squashResult.removeFailures, + alreadyEarliest: squashResult.alreadyEarliest, + isLocal: false, + baselinedVersion, + }); + } + }).pipe( + Effect.ensuring( + Effect.suspend(() => + linkedRefForCache !== undefined ? linkedProjectCache.cache(linkedRefForCache) : Effect.void, + ), + ), + ); +}); export const legacyMigrationSquash = Effect.fn("legacy.migration.squash")(function* ( flags: LegacyMigrationSquashFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["migration", "squash"]; - if (Option.isSome(flags.version)) args.push("--version", flags.version.value); - 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 telemetryState = yield* LegacyTelemetryState; + const cliArgs = yield* CliArgs; + const target = resolveLegacyDbTargetFlags(cliArgs.args); + yield* runSquash(flags, target).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/migration/squash/squash.integration.test.ts b/apps/cli/src/legacy/commands/migration/squash/squash.integration.test.ts new file mode 100644 index 0000000000..1767c02ab9 --- /dev/null +++ b/apps/cli/src/legacy/commands/migration/squash/squash.integration.test.ts @@ -0,0 +1,1435 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, FileSystem, Layer, Option } from "effect"; +import { PlatformError, SystemError } from "effect/PlatformError"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; + +import { stripAnsi } from "../../../../../tests/helpers/ansi.ts"; +import { + LEGACY_FAKE_SHADOW_CONTAINER_ID, + LEGACY_VALID_REF, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyShadowContainerCliSpawner, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { + mockOutput, + mockRuntimeInfo, + mockStdin, + mockTty, +} from "../../../../../tests/helpers/mocks.ts"; +import { dockerfileServiceImage } from "../../../../shared/services/dockerfile-images.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { legacyGetRegistryImageUrl } from "../../../shared/legacy-docker-registry.ts"; +import { + LegacyDebugFlag, + LegacyDnsResolverFlag, + LegacyExperimentalFlag, + LegacyNetworkIdFlag, + LegacyYesFlag, +} from "../../../../shared/legacy/global-flags.ts"; +import type { OutputFormat } from "../../../../shared/output/types.ts"; +import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LEGACY_INTERNAL_SCHEMAS } from "../../../shared/legacy-pg-dump.env.ts"; +import { legacyDumpSchemaScript } from "../../../shared/legacy-pg-dump.scripts.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 LegacyDbSession, + type LegacyPgConnInput, +} from "../../../shared/legacy-db-connection.service.ts"; +import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; +import { + LegacyDockerRun, + type LegacyDockerRunOpts, +} from "../../../shared/legacy-docker-run.service.ts"; +import type { LegacyMigrationSquashFlags } from "./squash.command.ts"; +import { legacyMigrationSquash } from "./squash.handler.ts"; + +// --------------------------------------------------------------------------- +// A fake `LegacyDockerRun` that distinguishes squash's own three one-shot +// `pg_dump` containers from the shadow's PG15+ platform-baseline setup jobs +// purely by their env matrix: only a `pg_dump` invocation ever carries +// `PGDATABASE` (`legacyToDumpEnv`) — none of the realtime/storage/auth +// one-shot jobs do (`db-setup.ts`). Among the dump calls, the first two +// sharing `EXTRA_FLAGS=--schema=auth|storage` are the before/after diff dumps +// (in that call order — `squashMigrations` dumps `before` strictly before +// applying migrations, `after` strictly after); a dump call with no +// `EXTRA_FLAGS` at all is the final, unrestricted full dump. +// --------------------------------------------------------------------------- + +function mockSquashDockerRun( + opts: { + readonly beforeSql?: string; + readonly afterSql?: string; + readonly fullSql?: string; + readonly failDump?: "before" | "after" | "full"; + readonly failSetupJob?: boolean; + } = {}, +) { + const dumpCalls: Array = []; + const setupJobCalls: Array = []; + let authStorageCalls = 0; + + const layer = Layer.succeed(LegacyDockerRun, { + run: () => Effect.die("LegacyDockerRun.run is unused by migration squash"), + runCapture: () => Effect.die("LegacyDockerRun.runCapture is unused by migration squash"), + runStream: (dockerOpts, streamOpts) => { + const isDump = dockerOpts.env["PGDATABASE"] !== undefined; + if (!isDump) { + setupJobCalls.push(dockerOpts); + return Effect.succeed({ exitCode: opts.failSetupJob === true ? 1 : 0, stderr: "" }); + } + dumpCalls.push(dockerOpts); + const isAuthStorage = dockerOpts.env["EXTRA_FLAGS"] === "--schema=auth|storage"; + let kind: "before" | "after" | "full"; + let sql: string; + if (isAuthStorage) { + authStorageCalls += 1; + kind = authStorageCalls === 1 ? "before" : "after"; + sql = kind === "before" ? (opts.beforeSql ?? "") : (opts.afterSql ?? ""); + } else { + kind = "full"; + sql = opts.fullSql ?? ""; + } + const exitCode = opts.failDump === kind ? 1 : 0; + return streamOpts + .onStdout(new TextEncoder().encode(sql)) + .pipe(Effect.as({ exitCode, stderr: "" })); + }, + }); + + return { layer, dumpCalls, setupJobCalls }; +} + +// --------------------------------------------------------------------------- +// Filesystem fault injection — a single wrapper layer covering every +// filesystem failure squash's own scenarios need, keyed by exact absolute +// path so unrelated reads/writes elsewhere in the setup pipeline are +// unaffected. Follows `tests/helpers/legacy-mocks.ts`'s own +// `legacyFailWriteStringOnNthCallFsLayer` pattern. +// --------------------------------------------------------------------------- + +const simulatedFsError = (path: string, method: string) => + new PlatformError( + new SystemError({ + _tag: "Unknown", + module: "FileSystem", + method, + pathOrDescriptor: path, + description: "simulated failure", + }), + ); + +interface FsFaultOpts { + /** + * Makes `fs.open(path, { flag: "w" })` itself fail — squash's SINGLE target-file open + * call (CLI-1969 review: collapsed from a truncate-then-reopen two-step into one + * `O_TRUNC`-equivalent open, matching `new.handler.ts:87`'s precedent). + */ + readonly failOpenPath?: string; + /** + * Lets the Nth+ `writeAll` call on the open handle for `path` fail (1-indexed), + * succeeding on every earlier call — so the full-dump stream's own `writeAll` (call 1) + * and the separator/diff tail's `writeAll` (call 2) can be failed independently, + * exercising both of squash's distinct write-failure call sites. + */ + readonly failWriteAllFromCall?: { readonly path: string; readonly fromCall: number }; + readonly failRemovePath?: string; + readonly failReadDirectoryAtCall?: { readonly path: string; readonly atCall: number }; +} + +function faultyFsLayer(opts: FsFaultOpts): Layer.Layer { + return Layer.effect( + FileSystem.FileSystem, + Effect.map(FileSystem.FileSystem, (real) => { + let readDirCallsForPath = 0; + return FileSystem.FileSystem.of({ + ...real, + remove: (path, removeOpts) => + opts.failRemovePath !== undefined && path === opts.failRemovePath + ? Effect.fail(simulatedFsError(path, "remove")) + : real.remove(path, removeOpts), + readDirectory: (path, readOpts) => { + if ( + opts.failReadDirectoryAtCall !== undefined && + path === opts.failReadDirectoryAtCall.path + ) { + readDirCallsForPath += 1; + if (readDirCallsForPath === opts.failReadDirectoryAtCall.atCall) { + return Effect.fail(simulatedFsError(path, "readDirectory")); + } + } + return real.readDirectory(path, readOpts); + }, + open: (path, openOpts) => { + if ( + opts.failOpenPath !== undefined && + path === opts.failOpenPath && + openOpts?.flag === "w" + ) { + return Effect.fail(simulatedFsError(path, "open")); + } + return real.open(path, openOpts).pipe( + Effect.map((file) => { + if ( + opts.failWriteAllFromCall === undefined || + path !== opts.failWriteAllFromCall.path + ) { + return file; + } + let writeAllCalls = 0; + return { + ...file, + writeAll: (buffer: Uint8Array) => { + writeAllCalls += 1; + return writeAllCalls >= opts.failWriteAllFromCall!.fromCall + ? Effect.fail(simulatedFsError(path, "writeAll")) + : file.writeAll(buffer); + }, + }; + }), + ); + }, + }); + }), + ).pipe(Layer.provide(BunServices.layer)); +} + +// --------------------------------------------------------------------------- +// Setup +// --------------------------------------------------------------------------- + +const alwaysReadyHttpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 200 }))), + ), +); + +interface SetupOpts { + readonly format?: OutputFormat; + readonly isTTY?: boolean; + readonly pipedInput?: string; + readonly yes?: boolean; + readonly confirm?: boolean; + readonly args?: ReadonlyArray; + readonly isLocal?: boolean; + readonly linkedRef?: string; + /** Omits `ref` entirely from the resolved config, matching the real resolver's own `--local`/`--db-url` shape (`ref` is an optional field, not always `None` — see `legacy-db-config.types.ts`). */ + readonly omitRef?: boolean; + readonly failResolve?: boolean; + readonly failSql?: string; + readonly networkId?: string; + readonly neverHealthyShadow?: boolean; + readonly failCreateShadow?: boolean; + readonly failRemoveShadow?: boolean; + readonly failSetupJob?: boolean; + readonly beforeDumpSql?: string; + readonly afterDumpSql?: string; + readonly fullDumpSql?: string; + readonly failDumpKind?: "before" | "after" | "full"; + readonly fsFaults?: FsFaultOpts; +} + +function setup(workdir: string, opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + const telemetry = mockLegacyTelemetryStateTracked(); + const cache = mockLegacyLinkedProjectCacheTracked(); + + const spawner = mockLegacyShadowContainerCliSpawner({ + neverHealthy: opts.neverHealthyShadow ?? false, + failCreate: opts.failCreateShadow ?? false, + failRemove: opts.failRemoveShadow ?? false, + }); + const docker = mockSquashDockerRun({ + beforeSql: opts.beforeDumpSql, + afterSql: opts.afterDumpSql, + fullSql: opts.fullDumpSql, + failDump: opts.failDumpKind, + failSetupJob: opts.failSetupJob, + }); + + const execs: Array = []; + const queries: Array<{ readonly sql: string; readonly params?: ReadonlyArray }> = []; + // Every `exec`/`query` call, in ONE combined call-order log — `execs`/`queries` above + // can't prove statement ORDER (`.toContain`/`.find` are order-blind), so a swapped + // DELETE/INSERT in the baseline transaction would ship green against them alone + // (CLI-1969 review item #7). + const statements: Array<{ readonly sql: string; readonly params?: ReadonlyArray }> = []; + const connectedDatabases: Array = []; + const connection = Layer.succeed(LegacyDbConnection, { + connect: (cfg: LegacyPgConnInput) => + Effect.sync(() => { + connectedDatabases.push(cfg.database); + const session: LegacyDbSession = { + exec: (sql: string) => + Effect.suspend(() => { + execs.push(sql); + statements.push({ sql }); + return opts.failSql !== undefined && sql.includes(opts.failSql) + ? Effect.fail(new LegacyDbExecError({ message: "boom" })) + : Effect.void; + }), + query: (sql: string, params?: ReadonlyArray) => + Effect.suspend(() => { + queries.push({ sql, params }); + statements.push({ sql, params }); + return opts.failSql !== undefined && sql.includes(opts.failSql) + ? Effect.fail(new LegacyDbExecError({ message: "boom" })) + : Effect.succeed>>([]); + }), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + }; + return session; + }), + }); + + const resolverCalls: Array = []; + const resolver = Layer.succeed(LegacyDbConfigResolver, { + resolve: (flags: LegacyDbConfigFlags) => { + resolverCalls.push(flags); + if (opts.failResolve === true) { + return Effect.fail( + new LegacyProjectNotLinkedError({ + message: "Cannot find project ref. Have you run link?", + }), + ); + } + return Effect.succeed({ + conn: { + host: "127.0.0.1", + port: 54322, + user: "postgres", + password: "x", + database: "postgres", + }, + isLocal: opts.isLocal ?? true, + // A real `--local`/`--db-url` resolution can genuinely omit `ref` altogether + // (it's an optional field, not always `None`) — `omitRef` reproduces that + // shape so `runSquash`'s `cfg.ref ?? Option.none()` fallback stays exercised. + ...(opts.omitRef === true + ? {} + : { ref: opts.linkedRef !== undefined ? Option.some(opts.linkedRef) : Option.none() }), + } satisfies LegacyResolvedDbConfig); + }, + resolvePoolerFallback: () => Effect.succeed(Option.none()), + }); + + const projectRef = Layer.succeed(LegacyProjectRefResolver, { + resolve: () => Effect.succeed(opts.linkedRef ?? LEGACY_VALID_REF), + resolveForLink: () => Effect.succeed(opts.linkedRef ?? LEGACY_VALID_REF), + resolveOptional: () => Effect.succeed(Option.some(opts.linkedRef ?? LEGACY_VALID_REF)), + loadProjectRef: () => Effect.succeed(opts.linkedRef ?? LEGACY_VALID_REF), + promptProjectRef: () => Effect.succeed(opts.linkedRef ?? LEGACY_VALID_REF), + }); + + const debugLogs: Array = []; + const debugLogger = Layer.succeed(LegacyDebugLogger, { + debug: (message: string) => + Effect.sync(() => { + debugLogs.push(message); + }), + http: () => Effect.void, + }); + + const baseLayer = Layer.mergeAll( + // Listed first so every fake service layer below overrides its real + // implementation — `Layer.mergeAll` is last-wins on a shared service, + // matching `diff.integration.test.ts`'s own established ordering. + BunServices.layer, + out.layer, + telemetry.layer, + cache.layer, + resolver, + connection, + projectRef, + spawner.layer, + docker.layer, + debugLogger, + alwaysReadyHttpClientLayer, + mockLegacyCliConfig({ workdir }), + Layer.succeed(LegacyDnsResolverFlag, "native"), + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(LegacyExperimentalFlag, false), + Layer.succeed(LegacyYesFlag, opts.yes ?? false), + Layer.succeed( + LegacyNetworkIdFlag, + opts.networkId === undefined ? Option.none() : Option.some(opts.networkId), + ), + Layer.succeed(CliArgs, { args: opts.args ?? [] }), + mockTty({ stdinIsTty: opts.isTTY ?? true }), + mockStdin( + opts.isTTY ?? true, + opts.pipedInput ?? (opts.confirm === undefined ? undefined : opts.confirm ? "y\n" : "n\n"), + ), + mockRuntimeInfo(), + ); + + const layer = + opts.fsFaults === undefined ? baseLayer : Layer.merge(baseLayer, faultyFsLayer(opts.fsFaults)); + + return { + layer, + out, + telemetry, + cache, + execs, + queries, + statements, + connectedDatabases, + resolverCalls, + debugLogs, + shadowSpawned: spawner.spawned, + dumpCalls: docker.dumpCalls, + setupJobCalls: docker.setupJobCalls, + }; +} + +const flags = (over: Partial = {}): LegacyMigrationSquashFlags => ({ + version: over.version ?? Option.none(), + dbUrl: over.dbUrl ?? Option.none(), + linked: over.linked ?? false, + local: over.local ?? true, + password: over.password ?? Option.none(), +}); + +const seedMigration = (workdir: string, name: string, body = "create table t (id int);\n") => { + const dir = join(workdir, "supabase", "migrations"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, name), body); +}; + +const stdout = (out: ReturnType) => stripAnsi(out.stdoutText); +const stderr = (out: ReturnType) => stripAnsi(out.stderrText); + +const failureTag = (exit: Exit.Exit): string | undefined => { + if (!Exit.isFailure(exit)) return undefined; + const failure = Cause.findErrorOption(exit.cause); + return Option.isSome(failure) ? (failure.value as { readonly _tag?: string })._tag : undefined; +}; + +const tmp = useLegacyTempWorkdir(); + +describe("legacy migration squash", () => { + // ------------------------------------------------------------------------- + // Flag surface & ordering + // ------------------------------------------------------------------------- + + describe("flag surface & ordering", () => { + it.effect("rejects --linked combined with --local", () => { + const s = setup(tmp.current, { args: ["--linked", "--local"] }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags({ linked: true, local: true })).pipe( + Effect.exit, + ); + expect(failureTag(exit)).toBe("LegacyMigrationTargetFlagsError"); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( + "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", + ); + } + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("rejects --db-url combined with --password", () => { + const s = setup(tmp.current, { args: ["--db-url", "postgresql://x", "--password", "y"] }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash( + flags({ dbUrl: Option.some("postgresql://x"), password: Option.some("y") }), + ).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyMigrationPasswordFlagsError"); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( + "if any flags in the group [db-url password] are set none of the others can be; [db-url password] were all set", + ); + } + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "rejects a non-numeric --version with the bare Go message (no 'failed to parse' prefix)", + () => { + const s = setup(tmp.current); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags({ version: Option.some("0_init") })).pipe( + Effect.exit, + ); + expect(failureTag(exit)).toBe("LegacyMigrationInvalidVersionError"); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( + "invalid version number", + ); + } + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("rejects an out-of-int64-range --version with the same bare message", () => { + const s = setup(tmp.current); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash( + flags({ version: Option.some("99999999999999999999") }), + ).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyMigrationInvalidVersionError"); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( + "invalid version number", + ); + } + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("fails with a glob not-found error when --version matches no local file", () => { + seedMigration(tmp.current, "0_init.sql"); + const s = setup(tmp.current); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags({ version: Option.some("9") })).pipe( + Effect.exit, + ); + expect(failureTag(exit)).toBe("LegacyMigrationFileNotFoundError"); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( + "glob supabase/migrations/9_*.sql: file does not exist", + ); + } + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("surfaces a db-config resolution failure before validating --version", () => { + // Cobra's pre-run order resolves the DB target before `squash.Run`'s own + // `strconv.Atoi` version check — so an unlinked/invalid target wins over a + // bad version, matching `migration repair`'s identical ordering test. + const s = setup(tmp.current, { failResolve: true }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash( + flags({ version: Option.some("not-a-number") }), + ).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyProjectNotLinkedError"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("defaults to the local database when no target flag is given", () => { + seedMigration(tmp.current, "0_init.sql"); + // `omitRef` matches the real resolver's own `--local` shape: no `ref` at all, + // not merely `None` — exercising the `cfg.ref ?? Option.none()` fallback. + const s = setup(tmp.current, { args: [], omitRef: true }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + expect(s.resolverCalls[0]?.connType).toBe("local"); + }).pipe(Effect.provide(s.layer)); + }); + }); + + // ------------------------------------------------------------------------- + // squashToVersion + // ------------------------------------------------------------------------- + + describe("squashToVersion", () => { + it.effect("fails with 'version not found' when the migrations directory is empty", () => { + const s = setup(tmp.current); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags()).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyMigrationSquashMissingVersionError"); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( + "version not found", + ); + } + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "fails with 'version not found' when the only file is a deprecated <14-digit>_init.sql", + () => { + seedMigration(tmp.current, "20211208000000_init.sql"); + const s = setup(tmp.current); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags()).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyMigrationSquashMissingVersionError"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "surfaces 'failed to read directory' when supabase/migrations is a file, not a directory", + () => { + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", "migrations"), "not a directory"); + const s = setup(tmp.current); + return Effect.gen(function* () { + const error = yield* legacyMigrationSquash(flags()).pipe(Effect.flip); + expect((error as { message: string }).message).toContain("failed to read directory"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "no-ops on a single migration: prints the earliest-migration line, spawns no container, and still finishes", + () => { + seedMigration(tmp.current, "0_init.sql"); + const s = setup(tmp.current); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + expect(stderr(s.out)).toContain( + "supabase/migrations/0_init.sql is already the earliest migration.", + ); + expect(s.shadowSpawned).toEqual([]); + expect(s.dumpCalls).toEqual([]); + // Step 2 still runs on the no-op path (Go falls through to it). + expect(stdout(s.out)).toContain("Finished supabase migration squash."); + expect(stderr(s.out)).toContain( + "Run supabase migration repair --status applied to update your remote migration history table.", + ); + }).pipe(Effect.provide(s.layer)); + }, + ); + }); + + // ------------------------------------------------------------------------- + // Happy path — squashing two-or-more migrations + // ------------------------------------------------------------------------- + + describe("squashing local migrations", () => { + const BEFORE_SQL = "CREATE SCHEMA IF NOT EXISTS auth;\nold auth object;\n"; + const AFTER_SQL = "CREATE SCHEMA IF NOT EXISTS auth;\nnew auth object;\n"; + const FULL_SQL = "CREATE TABLE t (id int);\n"; + + function setupHappyPath(opts: SetupOpts = {}) { + seedMigration(tmp.current, "0_init.sql", "create table a (id int);\n"); + seedMigration(tmp.current, "1_target.sql", "create table b (id int);\n"); + return setup(tmp.current, { + beforeDumpSql: BEFORE_SQL, + afterDumpSql: AFTER_SQL, + fullDumpSql: FULL_SQL, + ...opts, + }); + } + + it.effect( + "squashes two migrations into the last file: applies every migration, deletes the earlier one, and prints the summary", + () => { + const s = setupHappyPath(); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + expect(stderr(s.out)).toContain("Initialising schema..."); + expect(stderr(s.out)).toContain("Applying migration 0_init.sql..."); + expect(stderr(s.out)).toContain("Applying migration 1_target.sql..."); + expect(stderr(s.out)).toContain( + "Squashed local migrations to supabase/migrations/1_target.sql", + ); + + const migrationsDir = join(tmp.current, "supabase", "migrations"); + expect(existsSync(join(migrationsDir, "0_init.sql"))).toBe(false); + expect(existsSync(join(migrationsDir, "1_target.sql"))).toBe(true); + + // Hardcoded (not recomputed via `squash.diff.ts`'s own helpers) so a + // regression in the separator constant or the diff algorithm itself + // — not just in how `squashMigrations` wires them together — still + // fails this assertion. + const expectedTail = + "\n--\n-- Dumped schema changes for auth and storage\n--\n\n" + "new auth object;\n"; + expect(readFileSync(join(migrationsDir, "1_target.sql"), "utf8")).toBe( + FULL_SQL + expectedTail, + ); + + expect(stdout(s.out)).toContain("Finished supabase migration squash."); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "runs the before/after dumps scoped to auth|storage and the full dump excluding the internal schemas", + () => { + const s = setupHappyPath(); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + expect(s.dumpCalls).toHaveLength(3); + const [before, after, full] = s.dumpCalls; + expect(before?.env["EXTRA_FLAGS"]).toBe("--schema=auth|storage"); + expect(before?.env["EXCLUDED_SCHEMAS"]).toBeUndefined(); + expect(after?.env["EXTRA_FLAGS"]).toBe("--schema=auth|storage"); + expect(after?.env["EXCLUDED_SCHEMAS"]).toBeUndefined(); + expect(full?.env["EXTRA_FLAGS"]).toBeUndefined(); + expect(full?.env["EXCLUDED_SCHEMAS"]).toBe(LEGACY_INTERNAL_SCHEMAS.join("|")); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "runs every dump container on host networking with the shadow's connection env and the config Postgres image", + () => { + const s = setupHappyPath(); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + expect(s.dumpCalls).toHaveLength(3); + for (const call of s.dumpCalls) { + expect(call.env["PGPORT"]).toBe("54320"); + expect(call.env["PGUSER"]).toBe("postgres"); + expect(call.env["PGDATABASE"]).toBe("postgres"); + expect(call.network).toEqual({ _tag: "host" }); + expect(call.cmd).toEqual(["bash", "-c", legacyDumpSchemaScript, "--"]); + // `legacyStreamPgDump` applies the registry mirror itself (Go's + // `GetRegistryImageUrl`) — the default (no override) registry rewrites + // to the ECR mirror, not the bare Dockerfile-manifest tag. + expect(call.image).toBe(legacyGetRegistryImageUrl(dockerfileServiceImage("pg"))); + } + // Every dump dials the SAME shadow host, whatever this machine's Docker + // context resolves it to (`legacyGetHostname`) — self-consistency avoids + // hardcoding the host-dependent value. + const hosts = new Set(s.dumpCalls.map((c) => c.env["PGHOST"])); + expect(hosts.size).toBe(1); + const [host] = hosts; + expect(host).toBeTruthy(); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "dials the shadow's PG15+ setup jobs at the container's 12-char short id (DB_HOST)", + () => { + const s = setupHappyPath(); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + const expectedHost = LEGACY_FAKE_SHADOW_CONTAINER_ID.slice(0, 12); + expect(s.setupJobCalls.length).toBeGreaterThan(0); + let sawHost = false; + for (const call of s.setupJobCalls) { + if (call.env["DB_HOST"] !== undefined) { + expect(call.env["DB_HOST"]).toBe(expectedHost); + sawHost = true; + } + for (const value of Object.values(call.env)) { + if (value.includes("@") && value.includes(":")) { + expect(value).toContain(`@${expectedHost}:`); + sawHost = true; + } + } + } + expect(sawHost).toBe(true); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "forwards --network-id to every dump container as a named network instead of host", + () => { + const s = setupHappyPath({ networkId: "custom-net" }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + expect(s.dumpCalls).toHaveLength(3); + for (const call of s.dumpCalls) { + expect(call.network).toEqual({ _tag: "named", name: "custom-net" }); + } + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("squashes only the migrations up to --version, leaving newer ones untouched", () => { + seedMigration(tmp.current, "0_init.sql", "create table a (id int);\n"); + seedMigration(tmp.current, "1_target.sql", "create table b (id int);\n"); + seedMigration(tmp.current, "2_after.sql", "create table c (id int);\n"); + const s = setup(tmp.current, { + beforeDumpSql: BEFORE_SQL, + afterDumpSql: AFTER_SQL, + fullDumpSql: FULL_SQL, + }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags({ version: Option.some("1") })); + const migrationsDir = join(tmp.current, "supabase", "migrations"); + expect(existsSync(join(migrationsDir, "0_init.sql"))).toBe(false); + expect(existsSync(join(migrationsDir, "1_target.sql"))).toBe(true); + // The newer file was never touched — outside the `--version 1` window. + expect(readFileSync(join(migrationsDir, "2_after.sql"), "utf8")).toBe( + "create table c (id int);\n", + ); + }).pipe(Effect.provide(s.layer)); + }); + }); + + // ------------------------------------------------------------------------- + // Failure paths — every one leaves the shadow removed (unless creation + // itself is what failed, matching Go's leak-on-create-failure parity). + // ------------------------------------------------------------------------- + + describe("squashMigrations failure paths", () => { + it.effect("fails when the shadow container cannot be created and never attempts a dump", () => { + seedMigration(tmp.current, "0_init.sql"); + seedMigration(tmp.current, "1_target.sql"); + const s = setup(tmp.current, { failCreateShadow: true }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags()).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyShadowDbError"); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + // Nothing to release — the container was never created (Go's own + // leak-on-create-failure parity, see `legacyCreateShadowDatabase`'s doc). + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toEqual([]); + expect(s.dumpCalls).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "fails with a health-check timeout when the shadow never becomes healthy, and removes it", + () => { + seedMigration(tmp.current, "0_init.sql"); + seedMigration(tmp.current, "1_target.sql"); + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + // A zero-second health timeout means zero retries after the first failed + // probe — an immediate, deterministic timeout with no real/virtual delay. + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + '[db]\nhealth_timeout = "0s"\n', + ); + const s = setup(tmp.current, { neverHealthyShadow: true }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags()).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyHealthCheckTimeoutError"); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + expect(s.dumpCalls).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "fails when the shadow's platform-baseline setup job exits non-zero, and removes it", + () => { + seedMigration(tmp.current, "0_init.sql"); + seedMigration(tmp.current, "1_target.sql"); + const s = setup(tmp.current, { failSetupJob: true }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags()).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyDbSetupError"); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + expect(s.dumpCalls).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("fails when applying a migration to the shadow errors, and removes it", () => { + seedMigration(tmp.current, "0_init.sql", "create table boom;\n"); + seedMigration(tmp.current, "1_target.sql"); + const s = setup(tmp.current, { failSql: "create table boom" }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags()).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyMigrationApplyError"); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "fails with 'error running container: exit 1' when the before/after dump container exits non-zero", + () => { + seedMigration(tmp.current, "0_init.sql"); + seedMigration(tmp.current, "1_target.sql"); + const s = setup(tmp.current, { failDumpKind: "before" }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags()).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyMigrationSquashDumpError"); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( + "error running container: exit 1", + ); + } + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "fails with 'error running container: exit 1' when the full-schema dump exits non-zero, leaving the target file truncated", + () => { + seedMigration(tmp.current, "0_init.sql"); + seedMigration(tmp.current, "1_target.sql"); + const s = setup(tmp.current, { + beforeDumpSql: "before;\n", + afterDumpSql: "after;\n", + fullDumpSql: "partial output before the container died", + failDumpKind: "full", + }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags()).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyMigrationSquashDumpError"); + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + const targetPath = join(tmp.current, "supabase", "migrations", "1_target.sql"); + // Truncated (by the earlier `O_TRUNC`), then only the partial stream the + // dying container managed to write before failing — no separator/diff + // was ever appended, since the whole operation aborted first. + expect(readFileSync(targetPath, "utf8")).toBe("partial output before the container died"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "fails with 'failed to open migration file' when the target file cannot be truncated/opened", + () => { + // Squash's ONE `O_TRUNC`-equivalent open call (CLI-1969 review: collapsed from a + // truncate-then-reopen two-step into a single `fs.open(path, { flag: "w" })`, + // matching `new.handler.ts:87`'s precedent) — a single failure site, not two. + seedMigration(tmp.current, "0_init.sql"); + seedMigration(tmp.current, "1_target.sql"); + const targetPath = join(tmp.current, "supabase", "migrations", "1_target.sql"); + const s = setup(tmp.current, { + beforeDumpSql: "before;\n", + afterDumpSql: "after;\n", + fullDumpSql: "full;\n", + fsFaults: { failOpenPath: targetPath }, + }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags()).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyMigrationSquashWriteError"); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + const message = + Option.isSome(failure) && (failure.value as { message: string }).message; + expect(message).toContain("failed to open migration file:"); + // Relativized (CLI-1969 review item #3): the absolute tmp workdir never leaks. + expect(message).not.toContain(tmp.current); + } + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "fails with 'failed to copy docker logs' when streaming the full dump into the target file fails", + () => { + // Go's underlying failure on this path is `stdcopy.StdCopy`'s own write into the + // target file (`DockerStreamLogs`, `docker.go:574-576`), byte-matching "failed to + // copy docker logs:" — NOT `lineByLineDiff`'s own "failed to write line:" below. + seedMigration(tmp.current, "0_init.sql"); + seedMigration(tmp.current, "1_target.sql"); + const targetPath = join(tmp.current, "supabase", "migrations", "1_target.sql"); + const s = setup(tmp.current, { + beforeDumpSql: "before;\n", + afterDumpSql: "after;\n", + fullDumpSql: "full;\n", + fsFaults: { failWriteAllFromCall: { path: targetPath, fromCall: 1 } }, + }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags()).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyMigrationSquashWriteError"); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect( + Option.isSome(failure) && (failure.value as { message: string }).message, + ).toContain("failed to copy docker logs:"); + } + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "fails with 'failed to write line' when appending the separator/diff tail fails (the full dump itself wrote fine)", + () => { + seedMigration(tmp.current, "0_init.sql"); + seedMigration(tmp.current, "1_target.sql"); + const targetPath = join(tmp.current, "supabase", "migrations", "1_target.sql"); + const s = setup(tmp.current, { + beforeDumpSql: "before;\n", + afterDumpSql: "after;\n", + fullDumpSql: "full;\n", + // `fromCall: 2` lets the full-dump stream's own `writeAll` (call 1) + // succeed, isolating the separator/diff tail's write (call 2). + fsFaults: { failWriteAllFromCall: { path: targetPath, fromCall: 2 } }, + }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags()).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyMigrationSquashWriteError"); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + const message = + Option.isSome(failure) && (failure.value as { message: string }).message; + expect(message).toContain("failed to write line:"); + // Relativized (CLI-1969 review item #3): the absolute tmp workdir never leaks. + expect(message).not.toContain(tmp.current); + } + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + // The full dump itself made it onto disk before the tail write failed. + expect(readFileSync(targetPath, "utf8")).toBe("full;\n"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("prints a merged-file removal error to stderr non-fatally and still succeeds", () => { + seedMigration(tmp.current, "0_init.sql"); + seedMigration(tmp.current, "1_target.sql"); + const earlierPath = join(tmp.current, "supabase", "migrations", "0_init.sql"); + const s = setup(tmp.current, { + beforeDumpSql: "before;\n", + afterDumpSql: "after;\n", + fullDumpSql: "full;\n", + fsFaults: { failRemovePath: earlierPath }, + }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + // Non-fatal: the command still finishes successfully. + expect(stdout(s.out)).toContain("Finished supabase migration squash."); + // The failed removal's relativized error text reached stderr — pinned, not just + // "non-empty", and proves the workdir-relative path (never the absolute one). + expect(stderr(s.out)).toContain("FileSystem.remove (supabase/migrations/0_init.sql)"); + // The file that failed to be removed is still on disk. + expect(existsSync(earlierPath)).toBe(true); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "reports the removal failure in the machine-mode payload's removeFailures, leaving removed empty", + () => { + seedMigration(tmp.current, "0_init.sql"); + seedMigration(tmp.current, "1_target.sql"); + const earlierPath = join(tmp.current, "supabase", "migrations", "0_init.sql"); + const s = setup(tmp.current, { + format: "json", + beforeDumpSql: "before;\n", + afterDumpSql: "after;\n", + fullDumpSql: "full;\n", + fsFaults: { failRemovePath: earlierPath }, + }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + const success = s.out.messages.find((m) => m.type === "success"); + const data = success?.data as { + readonly removed: ReadonlyArray; + readonly removeFailures: ReadonlyArray<{ + readonly path: string; + readonly message: string; + }>; + }; + expect(data.removed).toEqual([]); + expect(data.removeFailures).toHaveLength(1); + expect(data.removeFailures[0]?.path).toBe("supabase/migrations/0_init.sql"); + expect(data.removeFailures[0]?.message).toContain( + "FileSystem.remove (supabase/migrations/0_init.sql)", + ); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("reports a shadow cleanup failure without failing the command", () => { + seedMigration(tmp.current, "0_init.sql"); + seedMigration(tmp.current, "1_target.sql"); + const s = setup(tmp.current, { + beforeDumpSql: "before;\n", + afterDumpSql: "after;\n", + fullDumpSql: "full;\n", + failRemoveShadow: true, + }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + expect(stdout(s.out)).toContain("Finished supabase migration squash."); + expect(stderr(s.out)).toContain( + `Failed to remove container: ${LEGACY_FAKE_SHADOW_CONTAINER_ID}`, + ); + }).pipe(Effect.provide(s.layer)); + }); + }); + + // ------------------------------------------------------------------------- + // Step 2 — local target + // ------------------------------------------------------------------------- + + describe("local target", () => { + it.effect( + "prints Finished on stdout and the repair suggestion on stderr, and never prompts", + () => { + seedMigration(tmp.current, "0_init.sql"); + const s = setup(tmp.current, { isLocal: true }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + expect(stdout(s.out)).toContain("Finished supabase migration squash."); + expect(stderr(s.out)).toContain( + "Run supabase migration repair --status applied to update your remote migration history table.", + ); + expect(stderr(s.out)).not.toContain("Update remote migration history table?"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("a --db-url pointing at the local stack also takes the local-suggestion path", () => { + seedMigration(tmp.current, "0_init.sql"); + const s = setup(tmp.current, { isLocal: true, args: ["--db-url", "postgresql://local"] }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags({ dbUrl: Option.some("postgresql://local") })); + expect(stdout(s.out)).toContain("Finished supabase migration squash."); + }).pipe(Effect.provide(s.layer)); + }); + }); + + // ------------------------------------------------------------------------- + // Step 2 — remote target + // ------------------------------------------------------------------------- + + describe("remote target", () => { + function setupRemote(opts: SetupOpts = {}) { + seedMigration(tmp.current, "0_init.sql"); + return setup(tmp.current, { isLocal: false, linkedRef: LEGACY_VALID_REF, ...opts }); + } + + it.effect("prompts to update the remote history table and baselines on 'y'", () => { + const s = setupRemote({ confirm: true, args: ["--linked"] }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags({ linked: true })); + expect(stderr(s.out)).toContain("Update remote migration history table? [Y/n] "); + expect(s.queries.some((q) => q.sql.includes("DELETE FROM supabase_migrations"))).toBe(true); + expect(s.queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations"))).toBe(true); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "prints 'Baselining migration history to ' BEFORE 'Connecting to remote database...'", + () => { + const s = setupRemote({ confirm: true }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + const text = stderr(s.out); + const baseliningAt = text.indexOf("Baselining migration history to 0"); + const connectingAt = text.indexOf("Connecting to remote database..."); + expect(baseliningAt).toBeGreaterThanOrEqual(0); + expect(connectingAt).toBeGreaterThan(baseliningAt); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "baselines via one transaction: BEGIN, DELETE ... WHERE version <= $1, INSERT ..., COMMIT", + () => { + const s = setupRemote({ confirm: true }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + // ONE ordered log (not `execs`/`queries` separately — `.toContain`/`.find` are + // order-blind, so an INSERT-before-DELETE regression would ship green against + // them) — the baseline's own transaction is the LAST 4 statements sent, after + // `legacyCreateMigrationTable`'s own (exec-only) setup transaction. + const baseline = s.statements.slice(-4); + expect(baseline.map((entry) => entry.sql)).toEqual([ + "BEGIN", + "DELETE FROM supabase_migrations.schema_migrations WHERE version <= $1", + "INSERT INTO supabase_migrations.schema_migrations(version, name, statements) VALUES($1, $2, $3)", + "COMMIT", + ]); + expect(baseline[1]?.params).toEqual(["0"]); + expect(baseline[2]?.params).toEqual(["0", "init", ["create table t (id int)"]]); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "rolls back and reports a baseline failure when the history-table batch fails", + () => { + const s = setupRemote({ confirm: true, failSql: "INSERT INTO supabase_migrations" }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags()).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyMigrationSquashBaselineError"); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect( + Option.isSome(failure) && (failure.value as { message: string }).message, + ).toContain("failed to update migration history:"); + } + expect(s.execs).toContain("ROLLBACK"); + // Exactly one COMMIT — `legacyCreateMigrationTable`'s own setup transaction, + // which runs (and commits) BEFORE the baseline's own BEGIN/DELETE/INSERT + // batch; the baseline's OWN transaction never reaches COMMIT. + expect(s.execs.filter((e) => e === "COMMIT")).toHaveLength(1); + expect(s.execs.filter((e) => e === "BEGIN")).toHaveLength(2); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "declining the prompt exits 0, runs no baseline query, and still prints Finished", + () => { + const s = setupRemote({ confirm: false }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags()).pipe(Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(s.execs).not.toContain("BEGIN"); + expect(s.queries).toEqual([]); + expect(stdout(s.out)).toContain("Finished supabase migration squash."); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("--yes auto-confirms by echoing the prompt with 'y' and reads no stdin", () => { + const s = setupRemote({ yes: true, pipedInput: undefined }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + expect(stderr(s.out)).toContain("Update remote migration history table? [Y/n] y"); + expect(s.execs).toContain("BEGIN"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("a non-TTY run with no piped answer takes the default (yes) and baselines", () => { + const s = setupRemote({ isTTY: false, pipedInput: undefined }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + expect(s.execs).toContain("BEGIN"); + expect(s.queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations"))).toBe(true); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "--version 0 baselines exactly version 0 even though a newer migration survives", + () => { + seedMigration(tmp.current, "0_init.sql"); + seedMigration(tmp.current, "1_newer.sql"); + const s = setup(tmp.current, { + isLocal: false, + linkedRef: LEGACY_VALID_REF, + confirm: true, + }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags({ version: Option.some("0") })); + const insert = s.queries.find((q) => q.sql.includes("INSERT INTO supabase_migrations")); + expect(insert?.params?.[0]).toBe("0"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("baselines the surviving older version when a merged-file removal failed", () => { + // Go re-lists local versions AFTER the file removals — a failed removal + // means the squash TARGET survives on disk (already true), but so does + // the OLDER merged file whose removal failed, and THAT older version is + // what an empty `--version` baselines to, not the squash target. + seedMigration(tmp.current, "0_init.sql"); + seedMigration(tmp.current, "1_target.sql"); + const earlierPath = join(tmp.current, "supabase", "migrations", "0_init.sql"); + const s = setup(tmp.current, { + isLocal: false, + linkedRef: LEGACY_VALID_REF, + confirm: true, + beforeDumpSql: "before;\n", + afterDumpSql: "after;\n", + fullDumpSql: "full;\n", + fsFaults: { failRemovePath: earlierPath }, + }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + const insert = s.queries.find((q) => q.sql.includes("INSERT INTO supabase_migrations")); + // "0" (the surviving older file), NOT "1" (the squash target). + expect(insert?.params?.[0]).toBe("0"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "debug-logs and baselines with an empty version when the post-squash version reload fails", + () => { + seedMigration(tmp.current, "0_init.sql"); + seedMigration(tmp.current, "1_target.sql"); + const migrationsDir = join(tmp.current, "supabase", "migrations"); + const s = setup(tmp.current, { + isLocal: false, + linkedRef: LEGACY_VALID_REF, + confirm: true, + beforeDumpSql: "before;\n", + afterDumpSql: "after;\n", + fullDumpSql: "full;\n", + // The FIRST `readDirectory(migrationsDir)` call is `squashToVersion`'s own + // listing (must succeed so the squash itself completes); the SECOND is + // `baselineMigrations`'s post-removal re-list, which this fails — the + // THIRD (inside `legacyResolveMigrationFile`, resolving the now-empty + // version) must succeed again so the scenario isolates the reload failure. + fsFaults: { failReadDirectoryAtCall: { path: migrationsDir, atCall: 2 } }, + }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash(flags()).pipe(Effect.exit); + expect(s.debugLogs).toHaveLength(1); + expect(s.debugLogs[0]).toContain("failed to read directory"); + expect(s.debugLogs[0]).toContain("simulated failure"); + expect(stderr(s.out)).toContain("Baselining migration history to \n"); + // `repair.NewMigrationFromVersion("")` finds no match — the empty-version + // glob fails, which surfaces as the baseline's own missing-file error, + // proving `resolvedVersion` genuinely stayed "" rather than falling back + // to the squash target. + expect(failureTag(exit)).toBe("LegacyMigrationFileNotFoundError"); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure) && (failure.value as { message: string }).message).toBe( + "glob supabase/migrations/_*.sql: file does not exist", + ); + } + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("--linked caches the linked project ref even when the squash fails", () => { + const s = setup(tmp.current, { + isLocal: false, + linkedRef: LEGACY_VALID_REF, + args: ["--linked"], + }); + return Effect.gen(function* () { + const exit = yield* legacyMigrationSquash( + flags({ linked: true, version: Option.some("bad") }), + ).pipe(Effect.exit); + expect(failureTag(exit)).toBe("LegacyMigrationInvalidVersionError"); + expect(s.cache.cached).toBe(true); + expect(s.cache.cachedRef).toBe(LEGACY_VALID_REF); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "--linked reads [remotes.] and prints the config-override line before resolving", + () => { + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + ["[remotes.dev]", `project_id = "${LEGACY_VALID_REF}"`, ""].join("\n"), + ); + seedMigration(tmp.current, "0_init.sql"); + const s = setup(tmp.current, { + isLocal: false, + linkedRef: LEGACY_VALID_REF, + confirm: true, + args: ["--linked"], + }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags({ linked: true })); + const text = stderr(s.out); + expect(text).toContain("Loading config override: [remotes.dev]"); + const overrideAt = text.indexOf("Loading config override: [remotes.dev]"); + const promptAt = text.indexOf("Update remote migration history table?"); + expect(promptAt).toBeGreaterThan(overrideAt); + }).pipe(Effect.provide(s.layer)); + }, + ); + }); + + // ------------------------------------------------------------------------- + // Output formats + // ------------------------------------------------------------------------- + + describe("output formats", () => { + it.effect("json emits the squash payload on stdout and keeps progress on stderr", () => { + seedMigration(tmp.current, "0_init.sql"); + const s = setup(tmp.current, { format: "json", isLocal: true }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + expect(s.out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + message: "Migrations squashed", + data: { + squashedInto: "supabase/migrations/0_init.sql", + removed: [], + removeFailures: [], + alreadyEarliest: true, + isLocal: true, + baselinedVersion: null, + }, + }), + ); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("json suppresses the Finished line and the repair suggestion", () => { + seedMigration(tmp.current, "0_init.sql"); + const s = setup(tmp.current, { format: "json", isLocal: true }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + expect(stdout(s.out)).not.toContain("Finished"); + expect(stderr(s.out)).not.toContain("Run supabase migration repair"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("stream-json emits the result event on stdout with progress lines on stderr", () => { + seedMigration(tmp.current, "0_init.sql"); + const s = setup(tmp.current, { format: "stream-json", isLocal: true }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + expect(s.out.messages.some((m) => m.type === "success")).toBe(true); + expect(stderr(s.out)).toContain("is already the earliest migration."); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("json still writes the prompt label to stderr and reads the piped answer", () => { + seedMigration(tmp.current, "0_init.sql"); + const s = setup(tmp.current, { + format: "json", + isLocal: false, + linkedRef: LEGACY_VALID_REF, + confirm: true, + }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + expect(stderr(s.out)).toContain("Update remote migration history table? [Y/n] "); + expect(s.execs).toContain("BEGIN"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "json on the declined-prompt path reports success with baselinedVersion: null", + () => { + seedMigration(tmp.current, "0_init.sql"); + const s = setup(tmp.current, { + format: "json", + isLocal: false, + linkedRef: LEGACY_VALID_REF, + confirm: false, + }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + const success = s.out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ isLocal: false, baselinedVersion: null }); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("json on the remote-confirmed 2-migration path reports the full real payload", () => { + seedMigration(tmp.current, "0_init.sql", "create table a (id int);\n"); + seedMigration(tmp.current, "1_target.sql", "create table b (id int);\n"); + const s = setup(tmp.current, { + format: "json", + isLocal: false, + linkedRef: LEGACY_VALID_REF, + confirm: true, + beforeDumpSql: "before;\n", + afterDumpSql: "after;\n", + fullDumpSql: "full;\n", + }); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + const success = s.out.messages.find((m) => m.type === "success"); + // "1_target.sql" is the sole surviving local file once "0_init.sql" is removed, so + // the empty-`--version` baseline reload (`legacyLoadLocalVersions`, run AFTER the + // removal) resolves to its own version, "1" — matching `squashedInto` below, NOT + // the removed file's "0". + expect(success?.data).toEqual({ + squashedInto: "supabase/migrations/1_target.sql", + removed: ["supabase/migrations/0_init.sql"], + removeFailures: [], + alreadyEarliest: false, + isLocal: false, + baselinedVersion: "1", + }); + }).pipe(Effect.provide(s.layer)); + }); + }); +}); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts index e9f4c01ba6..67575eade6 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts @@ -36,7 +36,15 @@ * container.slice(0, 12)` below is not a guess — it is the exact mechanism Go itself relies on. */ -import { Data, Effect, Schedule, type FileSystem, type Path, type Scope } from "effect"; +import { + Data, + Effect, + type Option, + Schedule, + type FileSystem, + type Path, + type Scope, +} from "effect"; import type * as HttpClient from "effect/unstable/http/HttpClient"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; @@ -52,11 +60,13 @@ import { legacyDescribeContainerCliFailure, spawnContainerCli, } from "../legacy-container-cli.ts"; +import type { LegacyDbConfigLoadError } from "../legacy-db-config.errors.ts"; import { LegacyDbConnection, type LegacyDbSession } from "../legacy-db-connection.service.ts"; import type { LegacyPgConnInput } from "../legacy-db-connection.service.ts"; import { LEGACY_CLI_PROJECT_LABEL } from "../legacy-docker-ids.ts"; import type { LegacyDockerRun } from "../legacy-docker-run.service.ts"; import { legacyApplyMigrations } from "../legacy-migration-apply.ts"; +import type { LegacyVaultSecret } from "../legacy-vault.ts"; import { legacyEnsureNetwork, legacyCreateContainer, @@ -66,6 +76,7 @@ import { import type { LegacyImagePrepullError } from "./image-prepull.ts"; import type { LegacyHealthCheckTimeoutError } from "./health-check.ts"; import { legacyWaitForHealthyServices } from "./health-check.ts"; +import type { LegacyLocalDbContainerInputs } from "./local-container-inputs.ts"; import { legacyListLocalMigrationPaths } from "../legacy-migration-history.ts"; import { legacyToPostgresURL } from "../legacy-postgres-url.ts"; import { @@ -346,6 +357,121 @@ export interface LegacyShadowConnectionInput extends LegacyCreateShadowDatabaseI export type LegacyPrepareRawShadowInput = LegacyShadowConnectionInput; +/** + * {@link LegacyShadowConnectionInput} plus the platform-baseline setup fields + * {@link legacySetupDatabase}/`legacyMigrateShadowDatabase`/`legacySetupShadowDatabase` need — + * the full shape {@link legacyShadowRunInputFromLocalContainerInputs} returns. Named here + * (CLI-1969) rather than as an `Omit<...>` of a diff/pull-specific type, so `migration squash` + * — which has none of the diff/pull-specific fields (`targetLocal`/`usePgDelta`/`schemaPaths`/ + * `pgDelta`/`ctx`) — can consume the promoted function's return value directly, with no `as` + * cast. `legacy-shadow-source.ts`'s `LegacyPrepareShadowSourceInput` extends this with + * those extra fields instead of duplicating the `setup` field itself. + */ +export interface LegacyShadowSetupInput extends LegacyShadowConnectionInput { + readonly setup: LegacyShadowDbSetupInput; +} + +/** + * Adapts {@link LegacyLocalDbContainerInputs} (`local-container-inputs.ts`, the SAME + * config/image/JWKS resolution prelude `db start`/`db reset` share) plus the caller's own + * already-loaded `config.toml` slice into {@link LegacyShadowSetupInput} — every field + * `legacyPrepareShadowSource`/{@link legacyPrepareRawShadow} (`legacy-shadow-source.ts`/this + * module) or `migration squash`'s own shadow composition need EXCEPT the diff/pull-specific + * ones (`targetLocal`/`usePgDelta`/`schemaPaths`/`pgDelta`/`ctx`/`setup`, left to each call + * site — `legacy-shadow-source.ts` adds its own on top of this). Promoted here from + * `commands/db/shared/legacy-shadow-source.ts` (CLI-1969, hoist-before-duplicate): `migration + * squash` needs this same shadow run-input shape, but importing the `db`-family-scoped + * `legacy-shadow-source.ts` would drag its whole pg-delta/migra/declarative stack into a + * command that has no diff engine at all. `legacy-shadow-source.ts` re-exports this function + * unchanged so `db diff`/`db pull` keep compiling with a one-line import change. + * + * On `db diff --linked`/`db pull` (linked), the caller passes its own resolved ref straight + * through to `legacyBuildLocalDbContainerInputs` (its own `projectRef` parameter — see + * that function's doc comment), which threads it into `legacyLoadLocalProjectContext` -> + * `loadProjectConfig({ projectRef })`. So the shadow's OWN container config (image, JWT + * secret, root key, `db.settings`, service enabled-for-setup flags, sourced from + * `localInputs.context.config`/`postgresSpecBase`) reflects the matching `[remotes.]` + * override, same as `toml` (the caller's own `legacyReadDbToml(..., linkedRef)` result, + * which feeds `pgDelta`/vault/`apiAutoExposeNewTables` below) — matching Go's own uniform + * remote-merge on the linked path (`LoadConfig` seeds `flags.ProjectRef` before every field + * read). The two config reads still go through independent remote-merge implementations + * (`@supabase/config`'s `applyRemoteOverride` for `localInputs.context.config`; + * `legacy-db-config.toml-read.ts`'s own TOML-based merge for `toml`) rather than a single + * shared decode — unifying those is a larger, out-of-scope refactor, not a per-command gap. + */ +export function legacyShadowRunInputFromLocalContainerInputs( + localInputs: LegacyLocalDbContainerInputs, + resolvedImage: string, + toml: { + readonly shadowPort: number; + readonly password: string; + readonly baseline: { readonly apiAutoExposeNewTables: Option.Option }; + readonly vault: ReadonlyArray; + }, + fs: FileSystem.FileSystem, + path: Path.Path, +): LegacyShadowSetupInput { + const { postgresSpecBase } = localInputs; + return { + db: { + major_version: postgresSpecBase.db.major_version, + settings: postgresSpecBase.db.settings, + }, + experimental: postgresSpecBase.experimental, + jwtSecret: postgresSpecBase.jwtSecret, + jwtExpiry: postgresSpecBase.jwtExpiry, + networkId: localInputs.networkId, + image: resolvedImage, + configImage: postgresSpecBase.configImage, + rootKey: postgresSpecBase.rootKey, + shadowPort: toml.shadowPort, + projectId: localInputs.context.projectId, + isBitbucketPipeline: localInputs.containerOpts.isBitbucketPipeline, + workdir: localInputs.containerOpts.workdir, + extraHosts: localInputs.containerOpts.extraHosts, + fs, + path, + hostname: localInputs.context.hostname, + password: toml.password, + healthTimeoutSeconds: localInputs.dbHealthTimeoutSeconds, + setup: { + majorVersion: localInputs.setup.majorVersion, + config: localInputs.setup.config, + // NOT `localInputs.setup.dbUrl` — that carries the REGULAR local container's own + // hardcoded-"postgres" password (`legacy-local-config-values.ts`'s `DEFAULT_DB_PASSWORD`), + // for a DIFFERENT container. The shadow's own one-shot setup jobs + // (`legacyBuildShadowSetupDatabaseInput`) only ever consume this `dbUrl` to extract a + // password (`legacyStartInternalDbPassword`) for the SHADOW they actually run against, so + // it must carry the SAME resolved `toml.password` the shadow container itself is + // initialized with (see `legacyBuildShadowPostgresContainerSpec`) — otherwise a non-default + // `[db] password` authenticates against the wrong secret and every setup job fails. + dbUrl: legacyToPostgresURL({ + host: localInputs.context.hostname, + port: toml.shadowPort, + user: "postgres", + password: toml.password, + database: "postgres", + }), + jwtSecret: localInputs.setup.jwtSecret, + jwks: localInputs.setup.jwks, + apiUrl: localInputs.setup.apiUrl, + authExternalUrl: localInputs.setup.authExternalUrl, + siteUrl: localInputs.setup.siteUrl, + anonKey: localInputs.setup.anonKey, + serviceRoleKey: localInputs.setup.serviceRoleKey, + storageTargetMigration: localInputs.setup.storageTargetMigration, + realtimeEnabledForSetup: localInputs.setup.realtimeEnabledForSetup, + storageEnabledForSetup: localInputs.setup.storageEnabledForSetup, + authEnabledForSetup: localInputs.setup.authEnabledForSetup, + serviceVersionOverrides: localInputs.setup.serviceVersionOverrides, + projectEnvValues: localInputs.setup.projectEnvValues, + debug: localInputs.setup.debug, + apiAutoExposeNewTables: toml.baseline.apiAutoExposeNewTables, + vault: toml.vault, + }, + }; +} + /** * Port of Go's `PrepareRawShadow` (`apps/cli-go/internal/db/diff/shadow.go:93-116`): health-wait * against an already-{@link legacyCreateShadowDatabase}-created shadow (created + healthy, no diff --git a/apps/cli/src/legacy/shared/legacy-migration-history.ts b/apps/cli/src/legacy/shared/legacy-migration-history.ts index 5231def40a..395d45822b 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-history.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-history.ts @@ -48,6 +48,10 @@ export const UPSERT_MIGRATION_VERSION = export const DELETE_MIGRATION_VERSION = "DELETE FROM supabase_migrations.schema_migrations WHERE version = ANY($1)"; +/** `DELETE ... WHERE version <= $1` — Go's `DELETE_MIGRATION_BEFORE` (squash baseline). */ +export const DELETE_MIGRATION_BEFORE = + "DELETE FROM supabase_migrations.schema_migrations WHERE version <= $1"; + /** `TRUNCATE supabase_migrations.schema_migrations` — Go's repair-all reset. */ export const TRUNCATE_VERSION_TABLE = "TRUNCATE supabase_migrations.schema_migrations"; diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.env.ts b/apps/cli/src/legacy/shared/legacy-pg-dump.env.ts similarity index 96% rename from apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.env.ts rename to apps/cli/src/legacy/shared/legacy-pg-dump.env.ts index 770aca703a..3ca0d24f17 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.env.ts +++ b/apps/cli/src/legacy/shared/legacy-pg-dump.env.ts @@ -1,10 +1,12 @@ -import type { LegacyPgConnInput } from "../../../shared/legacy-db-connection.service.ts"; +import type { LegacyPgConnInput } from "./legacy-db-connection.service.ts"; /** * Pure pg_dump environment builders, ported 1:1 from Go's `pkg/migration/dump.go`. * No Effect or service dependencies, so the schema/role/config lists and the - * `os.Expand` dry-run expansion stay unit-testable in isolation. Shared by the - * `db` command family (`db dump`, and `db pull`'s initial-migra schema dump). + * `os.Expand` dry-run expansion stay unit-testable in isolation. Shared by `db + * dump`, `db pull`'s initial-migra schema dump, and (CLI-1969) `migration + * squash`'s before/after/full dumps — the third consumer is why this module + * lives in `legacy/shared/` rather than `commands/db/shared/`. */ /** `migration.InternalSchemas` (`pkg/migration/dump.go:18-49`). Used by schema dumps. */ diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.env.unit.test.ts b/apps/cli/src/legacy/shared/legacy-pg-dump.env.unit.test.ts similarity index 97% rename from apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.env.unit.test.ts rename to apps/cli/src/legacy/shared/legacy-pg-dump.env.unit.test.ts index 1488772664..92957b38a0 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.env.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-pg-dump.env.unit.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import type { LegacyPgConnInput } from "../../../shared/legacy-db-connection.service.ts"; +import type { LegacyPgConnInput } from "./legacy-db-connection.service.ts"; import { LEGACY_ALLOWED_CONFIGS, LEGACY_EXCLUDED_SCHEMAS, @@ -40,7 +40,7 @@ const baseOpt: LegacyDumpOptions = { // Resolve the Go `.sh` sources relative to this file so the byte-equality // assertion fails loudly if the embedded copies drift from upstream. const goScriptsDir = fileURLToPath( - new URL("../../../../../../cli-go/pkg/migration/scripts/", import.meta.url), + new URL("../../../../cli-go/pkg/migration/scripts/", import.meta.url), ); const readGoScript = (name: string) => readFileSync(`${goScriptsDir}${name}`, "utf8"); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.run.ts b/apps/cli/src/legacy/shared/legacy-pg-dump.run.ts similarity index 67% rename from apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.run.ts rename to apps/cli/src/legacy/shared/legacy-pg-dump.run.ts index 5d6918bddd..0fb8fdea92 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.run.ts +++ b/apps/cli/src/legacy/shared/legacy-pg-dump.run.ts @@ -1,9 +1,9 @@ import { Effect, Option } from "effect"; -import { LegacyNetworkIdFlag } from "../../../../shared/legacy/global-flags.ts"; -import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; -import { legacyGetRegistryImageUrl } from "../../../shared/legacy-docker-registry.ts"; -import { LegacyDockerRun } from "../../../shared/legacy-docker-run.service.ts"; +import { LegacyNetworkIdFlag } from "../../shared/legacy/global-flags.ts"; +import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; +import { legacyGetRegistryImageUrl } from "./legacy-docker-registry.ts"; +import { LegacyDockerRun } from "./legacy-docker-run.service.ts"; /** * Runs a pg_dump / pg_dumpall bash script in a one-shot container, streaming its @@ -13,10 +13,14 @@ import { LegacyDockerRun } from "../../../shared/legacy-docker-run.service.ts"; * by the global `--network-id`), no security-opt, and the Linux-only * `host.docker.internal:host-gateway` extra host. * - * Shared by `db dump` (streams to `--file`/stdout) and `db pull`'s initial-migra - * schema dump (streams to the migration file). The pooler-fallback *decision* - * stays with the caller — this helper runs a single attempt and surfaces its - * exit/stderr so the caller can classify with `legacyIsIPv6ConnectivityError`. + * Shared by `db dump` (streams to `--file`/stdout), `db pull`'s initial-migra + * schema dump (streams to the migration file), and (CLI-1969) `migration + * squash`'s three one-shot dumps (before/after `auth`/`storage` diff buffers, + * plus the full dump streamed straight into the target migration file) — the + * third consumer is why this module lives in `legacy/shared/` rather than + * `commands/db/shared/`. The pooler-fallback *decision* stays with the caller — + * this helper runs a single attempt and surfaces its exit/stderr so the caller + * can classify with `legacyIsIPv6ConnectivityError`. */ export const legacyStreamPgDump = Effect.fnUntraced(function* (params: { /** Resolved Postgres image tag (pre-registry-URL); the helper applies the registry mirror. */ diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.scripts.ts b/apps/cli/src/legacy/shared/legacy-pg-dump.scripts.ts similarity index 100% rename from apps/cli/src/legacy/commands/db/shared/legacy-pg-dump.scripts.ts rename to apps/cli/src/legacy/shared/legacy-pg-dump.scripts.ts diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts b/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts index ce1de27f3e..848197024c 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts @@ -18,14 +18,12 @@ import { import { legacyCreateShadowDatabase, legacyRemoveShadowDatabase, + legacyShadowRunInputFromLocalContainerInputs, } from "./db-bootstrap/shadow-database.ts"; import { legacyCompareUtf8Bytes } from "./legacy-glob.ts"; import { LegacyMigrationsReadError } from "./legacy-migration.errors.ts"; import { type LegacyPgDeltaContext, legacyExportCatalogPgDelta } from "./legacy-pgdelta.ts"; -import { - legacyPrepareShadowSource, - legacyShadowRunInputFromLocalContainerInputs, -} from "../commands/db/shared/legacy-shadow-source.ts"; +import { legacyPrepareShadowSource } from "../commands/db/shared/legacy-shadow-source.ts"; /** * Declarative catalog-cache key builders + on-disk catalog resolution, ported diff --git a/apps/cli/tests/helpers/legacy-mocks.ts b/apps/cli/tests/helpers/legacy-mocks.ts index 3e47545d79..2317871083 100644 --- a/apps/cli/tests/helpers/legacy-mocks.ts +++ b/apps/cli/tests/helpers/legacy-mocks.ts @@ -761,14 +761,27 @@ const LEGACY_SHADOW_STARTING_STATE = * PRRT_kwDOErm0O86XMrID): with the default healthy-immediately response, a forked fiber can run * the ENTIRE shadow-provisioning sequence to completion synchronously before a test's own * polling loop is even scheduled, making `Fiber.interrupt` a no-op on an already-finished fiber. + * + * `failCreate`/`failRemove` (both default `false`) make `docker create`/`docker rm` exit + * non-zero instead — hoisted from `migration squash`'s own scoped-down copy of this mock + * (CLI-1969 review), which needed these two extra failure knobs `db diff`/`db pull`'s own + * scenarios never exercised. Defaulting both to `false` keeps every existing caller + * (`pull.integration.test.ts`, `declarative.orchestrate.integration.test.ts`, + * `diff.integration.test.ts`) byte-identical. */ export function mockLegacyShadowContainerCliSpawner( - opts: { readonly neverHealthy?: boolean } = {}, + opts: { + readonly neverHealthy?: boolean; + readonly failCreate?: boolean; + readonly failRemove?: boolean; + } = {}, ): { readonly layer: Layer.Layer; readonly spawned: ReadonlyArray<{ readonly args: ReadonlyArray }>; } { const neverHealthy = opts.neverHealthy ?? false; + const failCreate = opts.failCreate ?? false; + const failRemove = opts.failRemove ?? false; const spawned: Array<{ readonly args: ReadonlyArray }> = []; const encoder = new TextEncoder(); @@ -791,19 +804,33 @@ export function mockLegacyShadowContainerCliSpawner( ); } let stdoutLines: ReadonlyArray = []; + let stderrLines: ReadonlyArray = []; + let exitCode = 0; if (args[0] === "create") { - stdoutLines = [LEGACY_FAKE_SHADOW_CONTAINER_ID]; + if (failCreate) { + exitCode = 1; + stderrLines = ["network error"]; + } else { + stdoutLines = [LEGACY_FAKE_SHADOW_CONTAINER_ID]; + } } else if (args[0] === "container" && args[1] === "inspect") { stdoutLines = [neverHealthy ? LEGACY_SHADOW_STARTING_STATE : LEGACY_SHADOW_HEALTHY_STATE]; + } else if (args[0] === "rm") { + if (failRemove) { + exitCode = 1; + stderrLines = ["boom removing container"]; + } } - // "image inspect", "network create", "start", "rm -f -v" all succeed with no output. + // "image inspect", "network create", "start" (and "rm -f -v" when not `failRemove`) + // all succeed with no output. const stdoutBytes = stdoutLines.map((line) => encoder.encode(`${line}\n`)); + const stderrBytes = stderrLines.map((line) => encoder.encode(`${line}\n`)); return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(7000 + spawned.length), stdout: Stream.fromIterable(stdoutBytes), - stderr: Stream.empty, + stderr: Stream.fromIterable(stderrBytes), all: Stream.empty, - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), isRunning: Effect.succeed(false), stdin: Sink.drain, kill: () => Effect.void, From 5c4d68ab9b0af387b8ebac9fc3db7d39c26dbeb1 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 11 Aug 2026 11:52:59 +0100 Subject: [PATCH 2/3] fix(cli): address codex review on migration squash port (CLI-1969) --- .../migration/squash/squash.errors.ts | 2 +- .../migration/squash/squash.handler.ts | 6 +-- .../legacy/shared/legacy-migration-history.ts | 14 ++++--- .../legacy-migration-history.unit.test.ts | 41 ++++++++++++++++++- 4 files changed, 53 insertions(+), 10 deletions(-) diff --git a/apps/cli/src/legacy/commands/migration/squash/squash.errors.ts b/apps/cli/src/legacy/commands/migration/squash/squash.errors.ts index d7d0b2dc7e..d8d17f134c 100644 --- a/apps/cli/src/legacy/commands/migration/squash/squash.errors.ts +++ b/apps/cli/src/legacy/commands/migration/squash/squash.errors.ts @@ -53,7 +53,7 @@ export class LegacyMigrationSquashWriteError extends Data.TaggedError( } /** - * `baselineMigrations`'s history-table batch (`DELETE_MIGRATION_BEFORE` + + * `baselineMigrations`'s history-table batch (`LEGACY_DELETE_MIGRATION_BEFORE` + * `INSERT_MIGRATION_VERSION`) failed to send/commit. Byte-matches Go's `"failed to * update migration history: " + err` (`squash.go:187`). Classified `dbConnection`, * matching `migration repair`'s `LegacyMigrationRepairUpdateError` diff --git a/apps/cli/src/legacy/commands/migration/squash/squash.handler.ts b/apps/cli/src/legacy/commands/migration/squash/squash.handler.ts index ef7b5657c6..68c6aae038 100644 --- a/apps/cli/src/legacy/commands/migration/squash/squash.handler.ts +++ b/apps/cli/src/legacy/commands/migration/squash/squash.handler.ts @@ -53,8 +53,8 @@ import { LegacyMigrationApplyError, } from "../../../shared/legacy-migration-apply.ts"; import { - DELETE_MIGRATION_BEFORE, INSERT_MIGRATION_VERSION, + LEGACY_DELETE_MIGRATION_BEFORE, legacyCreateMigrationTable, legacyLoadLocalVersions, legacyLoadPartialMigrations, @@ -336,7 +336,7 @@ const baselineMigrations = Effect.fnUntraced(function* ( // and leaves `version` empty; it never aborts the baseline. const local = yield* legacyLoadLocalVersions(fs, path, migrationsDir).pipe( Effect.catch((cause) => - debugLogger.debug(cause.message).pipe(Effect.as([] as ReadonlyArray)), + debugLogger.debug(cause.message).pipe(Effect.as>([])), ), ); if (local.length > 0) resolvedVersion = local[0]!; @@ -377,7 +377,7 @@ const baselineMigrations = Effect.fnUntraced(function* ( // transaction for atomicity between the DELETE and the INSERT. const txn = Effect.gen(function* () { yield* session.exec("BEGIN"); - yield* session.query(DELETE_MIGRATION_BEFORE, [m.version]); + yield* session.query(LEGACY_DELETE_MIGRATION_BEFORE, [m.version]); yield* session.query(INSERT_MIGRATION_VERSION, [m.version, m.name, m.statements]); yield* session.exec("COMMIT"); }); diff --git a/apps/cli/src/legacy/shared/legacy-migration-history.ts b/apps/cli/src/legacy/shared/legacy-migration-history.ts index 395d45822b..fbf6354776 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-history.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-history.ts @@ -2,6 +2,7 @@ import { Effect, type FileSystem, Option, type Path } from "effect"; import { legacyListLocalMigrations } from "./legacy-pgdelta.cache.ts"; import { legacyBold } from "./legacy-colors.ts"; +import { legacyCompareUtf8Bytes } from "./legacy-glob.ts"; import type { LegacyDbExecError } from "./legacy-db-connection.errors.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; import { @@ -49,7 +50,7 @@ export const DELETE_MIGRATION_VERSION = "DELETE FROM supabase_migrations.schema_migrations WHERE version = ANY($1)"; /** `DELETE ... WHERE version <= $1` — Go's `DELETE_MIGRATION_BEFORE` (squash baseline). */ -export const DELETE_MIGRATION_BEFORE = +export const LEGACY_DELETE_MIGRATION_BEFORE = "DELETE FROM supabase_migrations.schema_migrations WHERE version <= $1"; /** `TRUNCATE supabase_migrations.schema_migrations` — Go's repair-all reset. */ @@ -425,9 +426,12 @@ export const legacyReadMigrationTable = (session: LegacyDbSession) => /** * Resolves the local migration file for a version by globbing `_*.sql` * against the migrations dir. Mirrors Go's `repair.GetMigrationFile` - * (`internal/migration/repair/repair.go:90`): the lexically-first match, or - * `None` when nothing matches (the caller raises the not-found error so the - * exact Go message can be assembled). A missing directory is treated as no match. + * (`internal/migration/repair/repair.go:90-100`): `afero.Glob` reads the + * directory then byte-sorts entries (`sort.Strings`, `afero/match.go:91`) before + * matching, so ties resolve to the byte-ordered (Go `sort.Strings`) first match, + * not JS's default UTF-16-code-unit order — or `None` when nothing matches (the + * caller raises the not-found error so the exact Go message can be assembled). A + * missing directory is treated as no match. */ export const legacyResolveMigrationFile = ( fs: FileSystem.FileSystem, @@ -449,7 +453,7 @@ export const legacyResolveMigrationFile = ( const prefix = `${version}_`; const matches = names .filter((name) => name.startsWith(prefix) && name.endsWith(".sql")) - .sort(); + .sort(legacyCompareUtf8Bytes); return matches.length > 0 ? Option.some(path.join(migrationsDir, matches[0]!)) : Option.none(); diff --git a/apps/cli/src/legacy/shared/legacy-migration-history.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-history.unit.test.ts index 4b84852b88..8e126aa5e8 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-history.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-history.unit.test.ts @@ -1,4 +1,4 @@ -import { Effect, Exit } from "effect"; +import { Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; import { describe, expect, it } from "vitest"; import { stripAnsi } from "../../../tests/helpers/ansi.ts"; @@ -8,6 +8,7 @@ import { legacyFindPendingMigrations, legacyListRemoteMigrations, legacyReconcileMigrations, + legacyResolveMigrationFile, legacySuggestMigrationRepair, legacySuggestRevertHistory, } from "./legacy-migration-history.ts"; @@ -188,3 +189,41 @@ describe("legacySuggestRevertHistory", () => { expect(legacySuggestRevertHistory(["0002"])).toContain("supabase db pull"); }); }); + +describe("legacyResolveMigrationFile (byte-ordered match, Go's sort.Strings via afero match.go:91)", () => { + it("picks the UTF-8-byte-first match, not JS's default UTF-16 code-unit order", async () => { + // A supplementary-plane character (U+1F600, a UTF-16 surrogate pair) alongside a BMP + // private-use character (U+E000): JS's default `.sort()` (no comparator) ranks the + // surrogate pair FIRST — its leading high-surrogate code unit (0xD83D) is less than + // the private-use code unit (0xE000). Go's `sort.Strings` (UTF-8 byte order) ranks the + // private-use character first instead (0xEE... < 0xF0... in its UTF-8 encoding). + const surrogatePair = "20240101000000_a\u{1f600}.sql"; + const privateUse = "20240101000000_a\u{e000}.sql"; + expect([surrogatePair, privateUse].sort()[0]).toBe(surrogatePair); + + const layer = Layer.mergeAll( + Layer.succeed( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readDirectory: () => Effect.succeed([surrogatePair, privateUse]), + }), + ), + Path.layer, + ); + const result = await Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* legacyResolveMigrationFile( + fs, + path, + "/supabase/migrations", + "20240101000000", + ); + }).pipe(Effect.provide(layer)), + ); + expect(Option.isSome(result) ? result.value : undefined).toBe( + `/supabase/migrations/${privateUse}`, + ); + }); +}); From 9dfed1fcd800288a1c843e7741dbc0fcfbc252bd Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 11 Aug 2026 12:30:21 +0100 Subject: [PATCH 3/3] fix(cli): honor project-dotenv registry and network for squash pg_dump containers (CLI-1969) --- .../legacy/commands/db/dump/dump.handler.ts | 5 +- .../commands/db/dump/dump.integration.test.ts | 27 ++++++++ .../legacy/commands/db/pull/pull.handler.ts | 1 + .../commands/db/pull/pull.integration.test.ts | 46 ++++++++++++- .../commands/migration/squash/squash.dump.ts | 5 ++ .../migration/squash/squash.handler.ts | 15 +++++ .../squash/squash.integration.test.ts | 66 +++++++++++++++++++ .../src/legacy/shared/legacy-pg-dump.run.ts | 28 +++++++- 8 files changed, 186 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/legacy/commands/db/dump/dump.handler.ts b/apps/cli/src/legacy/commands/db/dump/dump.handler.ts index dc25726ff9..1d99b7b2dc 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.handler.ts +++ b/apps/cli/src/legacy/commands/db/dump/dump.handler.ts @@ -82,7 +82,8 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy // image), reverted when this scope closes. Go's `loadNestedEnv` `os.Setenv`s the // project `.env`; the pure `legacyLoadProjectEnv` no longer does that as a side // effect of `resolveDbPassword`, so `db dump` opts in explicitly here. - yield* legacyApplyProjectEnv(yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir)); + const projectEnv = yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir); + yield* legacyApplyProjectEnv(projectEnv); // The grouped boolean flags are modelled as `Option` (presence = pflag `Changed`) // for the mutex/target checks; resolve their effective values here for the places @@ -307,6 +308,7 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy env, onStdout: (chunk) => file.writeAll(chunk).pipe(Effect.mapError(toOpenFileError)), + projectEnvValues: projectEnv, }); }), ), @@ -320,6 +322,7 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy script: mode.script, env, onStdout: (chunk) => output.rawBytes(chunk), + projectEnvValues: projectEnv, }); // 7b. Container-level IPv6 → IPv4-pooler retry (Go's `RunWithPoolerFallback`, diff --git a/apps/cli/src/legacy/commands/db/dump/dump.integration.test.ts b/apps/cli/src/legacy/commands/db/dump/dump.integration.test.ts index 42f7c393c7..45eeeed6de 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/dump/dump.integration.test.ts @@ -526,6 +526,33 @@ describe("legacy db dump integration", () => { }).pipe(Effect.provide(layer)); }); + it.live( + "resolves the pg_dump network via SUPABASE_NETWORK_ID from supabase/.env when neither the flag nor the ambient env is set", + () => { + // Go's `dockerExec` sets host networking by default (dump.go:91-93), but + // `DockerStart` overrides it with `viper.GetString("network-id")` whenever that + // resolves non-empty (docker.go:379-380) — a value sourced only from + // `supabase/.env` (after `loadNestedEnv`'s `os.Setenv`) still wins over host. + const prev = process.env["SUPABASE_NETWORK_ID"]; + delete process.env["SUPABASE_NETWORK_ID"]; + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_NETWORK_ID=dotenv-net\n"); + const { layer, docker } = setup({ isLocal: true, workdir: tmp.current }); + return Effect.gen(function* () { + yield* legacyDbDump(flags({ local: Option.some(true) })); + expect(docker.lastOpts?.network).toEqual({ _tag: "named", name: "dotenv-net" }); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_NETWORK_ID"]; + else process.env["SUPABASE_NETWORK_ID"] = prev; + }), + ), + Effect.provide(layer), + ); + }, + ); + it.live("defaults to the linked connection when neither --local nor --db-url is set", () => { const { layer, resolver } = setup({ conn: REMOTE_CONN, isLocal: false }); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts index 7a1fd05405..5a7d16b428 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -691,6 +691,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy image, script: legacyDumpSchemaScript, env: legacyBuildSchemaDumpEnv(target, dumpEnvOpt), + projectEnvValues: projectEnv, onStdout: (chunk) => { if (chunk.length > 0) seedWroteBytes = true; return file.writeAll(chunk).pipe( diff --git a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts index b7412430f4..ac83b5f80a 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts @@ -35,7 +35,10 @@ import type { OutputFormat } from "../../../../shared/output/types.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; -import { LegacyDockerRun } from "../../../shared/legacy-docker-run.service.ts"; +import { + LegacyDockerRun, + type LegacyDockerRunOpts, +} from "../../../shared/legacy-docker-run.service.ts"; import { LegacyEdgeRuntimeScriptError } from "../../../shared/legacy-edge-runtime-script.errors.ts"; import { type LegacyEdgeRuntimeRunOpts, @@ -148,7 +151,11 @@ function setup(workdir: string, opts: SetupOpts = {}) { // `runStream`; deliver the configured bytes to `onStdout` (as Go's StdCopy would), // then report the exit code + stderr. `dumpFailFirstWith` fails the first attempt // so the pooler retry runs. - const dumpCalls: Array<{ env: Readonly>; image: string }> = []; + const dumpCalls: Array<{ + env: Readonly>; + image: string; + network: LegacyDockerRunOpts["network"]; + }> = []; let dumpRunCount = 0; const docker = Layer.succeed(LegacyDockerRun, { run: () => Effect.die("run unused"), @@ -165,7 +172,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { return { exitCode: 0, stderr: "" }; } dumpRunCount += 1; - dumpCalls.push({ env: runOpts.env, image: runOpts.image }); + dumpCalls.push({ env: runOpts.env, image: runOpts.image, network: runOpts.network }); if (opts.dumpFailFirstWith !== undefined && dumpRunCount === 1) { if (opts.dumpFailFirstPartialBytes !== undefined) { const partial = new TextEncoder().encode(opts.dumpFailFirstPartialBytes); @@ -1256,6 +1263,39 @@ describe("legacy db pull", () => { }, ); + it.effect( + "resolves the pg_dump network via SUPABASE_NETWORK_ID from supabase/.env when neither the flag nor the ambient env is set", + () => { + // Go's `dockerExec` sets host networking by default (dump.go:91-93), but + // `DockerStart` overrides it with `viper.GetString("network-id")` whenever that + // resolves non-empty (docker.go:379-380) — a value sourced only from + // `supabase/.env` (after `loadNestedEnv`'s `os.Setenv`) still wins over host. + const prev = process.env["SUPABASE_NETWORK_ID"]; + delete process.env["SUPABASE_NETWORK_ID"]; + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_NETWORK_ID=dotenv-net\n"); + const s = setup(tmp.current, { + remoteVersions: [], // no remote history → initial-migra pg_dump path + dumpStdout: "create table dumped ();\n", + edgeStdout: "", + yes: true, + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags()); + expect(s.dumpCalls.length).toBeGreaterThanOrEqual(1); + expect(s.dumpCalls[0]?.network).toEqual({ _tag: "named", name: "dotenv-net" }); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_NETWORK_ID"]; + else process.env["SUPABASE_NETWORK_ID"] = prev; + }), + ), + Effect.provide(s.layer), + ); + }, + ); + it.effect("an explicit --yes=false overrides SUPABASE_YES and honors the piped answer", () => { // Go binds `--yes` to viper, so an explicit `--yes=false` wins over the // SUPABASE_YES env (AutomaticEnv). `printf 'n\n' | SUPABASE_YES=1 supabase diff --git a/apps/cli/src/legacy/commands/migration/squash/squash.dump.ts b/apps/cli/src/legacy/commands/migration/squash/squash.dump.ts index 798f425824..4e84c0d317 100644 --- a/apps/cli/src/legacy/commands/migration/squash/squash.dump.ts +++ b/apps/cli/src/legacy/commands/migration/squash/squash.dump.ts @@ -27,6 +27,8 @@ export interface LegacySquashDumpParams { readonly schema: ReadonlyArray; /** Receives each stdout chunk in arrival order; its failure aborts the run as `E`. */ readonly onStdout: (chunk: Uint8Array) => Effect.Effect; + /** Loaded project `supabase/.env` map — forwarded to {@link legacyStreamPgDump}'s own `SUPABASE_NETWORK_ID` fallback. */ + readonly projectEnvValues?: Readonly>; } /** @@ -51,6 +53,7 @@ export const legacySquashDumpSchema = Effect.fnUntraced(function* ( script: legacyDumpSchemaScript, env: legacyBuildSchemaDumpEnv(params.conn, opt), onStdout: params.onStdout, + projectEnvValues: params.projectEnvValues, }); if (result.exitCode !== 0) { return yield* Effect.fail( @@ -85,6 +88,7 @@ export const legacySquashDumpSchemaToString = Effect.fnUntraced(function* (param readonly image: string; readonly conn: LegacyPgConnInput; readonly schema: ReadonlyArray; + readonly projectEnvValues?: Readonly>; }) { const chunks: Array = []; yield* legacySquashDumpSchema({ @@ -92,6 +96,7 @@ export const legacySquashDumpSchemaToString = Effect.fnUntraced(function* (param conn: params.conn, schema: params.schema, onStdout: (chunk) => Effect.sync(() => chunks.push(chunk)), + projectEnvValues: params.projectEnvValues, }); return new TextDecoder().decode(concatChunks(chunks)); }); diff --git a/apps/cli/src/legacy/commands/migration/squash/squash.handler.ts b/apps/cli/src/legacy/commands/migration/squash/squash.handler.ts index 68c6aae038..49803175bc 100644 --- a/apps/cli/src/legacy/commands/migration/squash/squash.handler.ts +++ b/apps/cli/src/legacy/commands/migration/squash/squash.handler.ts @@ -33,6 +33,7 @@ import { } from "../../../shared/db-bootstrap/shadow-database.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import { + legacyApplyProjectEnv, legacyLoadProjectEnv, legacyReadDbToml, type LegacyDbTomlValues, @@ -153,6 +154,7 @@ const squashMigrations = Effect.fnUntraced(function* ( image, conn: connConfig, schema: ["auth", "storage"], + projectEnvValues: localInputs.context.projectEnvValues, }); yield* legacyApplyMigrations( session, @@ -165,6 +167,7 @@ const squashMigrations = Effect.fnUntraced(function* ( image, conn: connConfig, schema: ["auth", "storage"], + projectEnvValues: localInputs.context.projectEnvValues, }); const targetPath = migrations[migrations.length - 1]!; @@ -194,6 +197,7 @@ const squashMigrations = Effect.fnUntraced(function* ( image, conn: connConfig, schema: [], + projectEnvValues: localInputs.context.projectEnvValues, onStdout: (chunk) => file.writeAll(chunk).pipe( Effect.mapError( @@ -491,6 +495,14 @@ const runSquash = Effect.fnUntraced(function* ( // flag-group validation above — so a `SUPABASE_YES` set only in `supabase/.env` auto-confirms // the remote-baseline prompt, but a flag conflict still surfaces before any `.env` read. const projectEnv = yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir); + // Make an allowlisted `supabase/.env` registry override visible to the + // synchronous `process.env` reader in `legacyGetRegistryImageUrl`, reverted + // when this scope closes. Go's `loadNestedEnv` `os.Setenv`s the project `.env` + // (config.go:789) before any container starts, and each of squash's three + // pg_dump containers resolves its image through `DockerStart` -> + // `GetRegistryImageUrl`/`GetRegistryImageUrls` (docker.go:221-246,326-348, + // 363-371) — so a dotenv-only mirror override reaches all three dumps below. + yield* legacyApplyProjectEnv(projectEnv); const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); // 7. `--version` validation — inside Go's `squash.Run`, i.e. AFTER db-config resolution. @@ -583,6 +595,9 @@ const runSquash = Effect.fnUntraced(function* ( linkedRefForCache !== undefined ? linkedProjectCache.cache(linkedRefForCache) : Effect.void, ), ), + // Scope the `SUPABASE_INTERNAL_IMAGE_REGISTRY`-from-`.env` apply above to this + // command run: `legacyApplyProjectEnv` registers a finalizer that reverts it. + Effect.scoped, ); }); diff --git a/apps/cli/src/legacy/commands/migration/squash/squash.integration.test.ts b/apps/cli/src/legacy/commands/migration/squash/squash.integration.test.ts index 1767c02ab9..664a6a29ad 100644 --- a/apps/cli/src/legacy/commands/migration/squash/squash.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/squash/squash.integration.test.ts @@ -747,6 +747,72 @@ describe("legacy migration squash", () => { }, ); + it.effect( + "resolves the pg_dump image via SUPABASE_INTERNAL_IMAGE_REGISTRY from supabase/.env", + () => { + // Go's `loadNestedEnv` `os.Setenv`s the project `.env` (config.go:789) before any of + // squash's three pg_dump containers start; each one resolves its image through + // `DockerStart` -> `GetRegistryImageUrl`/`GetRegistryImageUrls` (docker.go:221-246, + // 326-348,363-371) — so a registry mirror set only in `supabase/.env` reaches all + // three. The handler mirrors that with `legacyApplyProjectEnv`, scoped to the run + // and reverted when it completes. + const prev = process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; + delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; + const s = setupHappyPath(); + writeFileSync( + join(tmp.current, "supabase", ".env"), + "SUPABASE_INTERNAL_IMAGE_REGISTRY=my-mirror.example.com\n", + ); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + expect(s.dumpCalls).toHaveLength(3); + for (const call of s.dumpCalls) { + expect(call.image).toMatch(/^my-mirror\.example\.com\/supabase\//u); + } + // Reverted once the command's own scope closes (`Effect.scoped` on `runSquash`'s + // terminal pipe) — never leaks into a later command in the same process. + expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBeUndefined(); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; + else process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"] = prev; + }), + ), + Effect.provide(s.layer), + ); + }, + ); + + it.effect( + "resolves the pg_dump network via SUPABASE_NETWORK_ID from supabase/.env when neither the flag nor the ambient env is set", + () => { + // Go's `dockerExec` sets host networking by default (dump.go:91-93), but + // `DockerStart` overrides it with `viper.GetString("network-id")` whenever that + // resolves non-empty (docker.go:379-380) — a value sourced only from + // `supabase/.env` (after `loadNestedEnv`'s `os.Setenv`) still wins over host. + const prev = process.env["SUPABASE_NETWORK_ID"]; + delete process.env["SUPABASE_NETWORK_ID"]; + const s = setupHappyPath(); + writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_NETWORK_ID=dotenv-net\n"); + return Effect.gen(function* () { + yield* legacyMigrationSquash(flags()); + expect(s.dumpCalls).toHaveLength(3); + for (const call of s.dumpCalls) { + expect(call.network).toEqual({ _tag: "named", name: "dotenv-net" }); + } + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["SUPABASE_NETWORK_ID"]; + else process.env["SUPABASE_NETWORK_ID"] = prev; + }), + ), + Effect.provide(s.layer), + ); + }, + ); + it.effect("squashes only the migrations up to --version, leaving newer ones untouched", () => { seedMigration(tmp.current, "0_init.sql", "create table a (id int);\n"); seedMigration(tmp.current, "1_target.sql", "create table b (id int);\n"); diff --git a/apps/cli/src/legacy/shared/legacy-pg-dump.run.ts b/apps/cli/src/legacy/shared/legacy-pg-dump.run.ts index 0fb8fdea92..6afa5c494c 100644 --- a/apps/cli/src/legacy/shared/legacy-pg-dump.run.ts +++ b/apps/cli/src/legacy/shared/legacy-pg-dump.run.ts @@ -1,6 +1,7 @@ import { Effect, Option } from "effect"; import { LegacyNetworkIdFlag } from "../../shared/legacy/global-flags.ts"; +import { legacyViperEnvStringWithProjectFallback } from "../../shared/legacy/legacy-viper-env.ts"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; import { legacyGetRegistryImageUrl } from "./legacy-docker-registry.ts"; import { LegacyDockerRun } from "./legacy-docker-run.service.ts"; @@ -10,8 +11,9 @@ import { LegacyDockerRun } from "./legacy-docker-run.service.ts"; * stdout chunk-by-chunk to `onStdout` and teeing stderr live, returning the exit * code + captured stderr for failure classification. Mirrors Go's `dockerExec` * (`apps/cli-go/internal/db/dump/dump.go`): host networking by default (overridden - * by the global `--network-id`), no security-opt, and the Linux-only - * `host.docker.internal:host-gateway` extra host. + * by the global `--network-id` flag, the ambient `SUPABASE_NETWORK_ID` env var, or + * a project `supabase/.env` value, in that precedence), no security-opt, and the + * Linux-only `host.docker.internal:host-gateway` extra host. * * Shared by `db dump` (streams to `--file`/stdout), `db pull`'s initial-migra * schema dump (streams to the migration file), and (CLI-1969) `migration @@ -30,16 +32,36 @@ export const legacyStreamPgDump = Effect.fnUntraced(function* (params: { readonly env: Readonly>; /** Receives each stdout chunk in arrival order; its failure aborts the run as `E`. */ readonly onStdout: (chunk: Uint8Array) => Effect.Effect; + /** + * Loaded project `supabase/.env` map, consulted for a `SUPABASE_NETWORK_ID` + * value when neither `--network-id` nor the ambient shell env set one. Omitted + * (or `{}`) by callers that haven't loaded a project env map. + */ + readonly projectEnvValues?: Readonly>; }) { const docker = yield* LegacyDockerRun; const runtimeInfo = yield* RuntimeInfo; const networkIdFlag = yield* LegacyNetworkIdFlag; + // Go's `dockerExec` sets `NetworkMode` to host (`dump.go:91-93`), but + // `DockerStart` then overrides it with `viper.GetString("network-id")` whenever + // that resolves non-empty (`docker.go:379-380`) — a bound flag/env value wins, + // flag > ambient env > project-`.env` (`legacyViperEnvStringWithProjectFallback` + // precedence). Only when NEITHER the flag nor the env resolves does Go fall back + // to `NetId` (`docker.go:381-382`) — but that branch only fires when the caller + // left `NetworkMode` empty, which the dump path never does, so the effective + // pg_dump fallback is host networking, not the generated `supabase_network_*`. const networkId = Option.getOrUndefined(networkIdFlag); + const envNetworkId = legacyViperEnvStringWithProjectFallback( + "SUPABASE_NETWORK_ID", + params.projectEnvValues ?? {}, + ); const network = networkId !== undefined && networkId.length > 0 ? { _tag: "named" as const, name: networkId } - : { _tag: "host" as const }; + : envNetworkId.length > 0 + ? { _tag: "named" as const, name: envNetworkId } + : { _tag: "host" as const }; const extraHosts = runtimeInfo.platform === "linux" ? ["host.docker.internal:host-gateway"] : []; return yield* docker.runStream(