diff --git a/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md index f075c41d17..0c7a4c6cef 100644 --- a/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md @@ -36,17 +36,17 @@ it, and JSON `null` disables formatting without disabling safe compaction. ## Files Written -| Path | Format | When | -| --------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/migrations/_.sql` | SQL | non-empty `--file` diff; bundled pg-delta may emit ordered transaction-aware files, while pgAdmin always emits one | -| `` (from `--output` / `-o`) | SQL | explicit `--from/--to` mode with `--output`; flattened review representation, not a portable apply script | -| `/supabase/.temp/pgdelta/*.json` | JSON | legacy opt-out's explicit migrations catalog | -| `/supabase/.temp/pgdelta/pgdelta-target-ca.crt` | PEM | legacy opt-out, for a Supabase TLS target | -| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | bundled engine with `PGDELTA_DEBUG` | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | cache-enabled (default) COLD shadow provision creates the current key's snapshot (native diff targets + the explicit `--from/--to migrations` catalog miss; never `--use-pgadmin`/`--use-pg-schema`); a warm hit `touch`es its mtime (LRU); every cache-eligible acquire may delete other keys under LRU keep-8 + 14-day mtime TTL — ~90MB (`SUPABASE_HOME` overrides the root) | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | during a cold export — the in-flight temp file, `rename`d into the tar above on success and removed on failure; only a crash/SIGKILL leaves it behind, and later cold exports / warm hits sweep leftovers older than an hour | -| `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | -| `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | +| Path | Format | When | +| --------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/migrations/_.sql` | SQL | non-empty `--file` diff; bundled pg-delta may emit ordered transaction-aware files, while pgAdmin always emits one | +| `` (from `--output` / `-o`) | SQL | explicit `--from/--to` mode with `--output`; flattened review representation, not a portable apply script | +| `/supabase/.temp/pgdelta/*.json` | JSON | legacy opt-out's explicit migrations catalog | +| `/supabase/.temp/pgdelta/pgdelta-target-ca.crt` | PEM | legacy opt-out, for a Supabase TLS target | +| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | bundled engine with `PGDELTA_DEBUG` | +| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | cache-enabled (default) COLD shadow provision creates the current key's snapshot (native diff targets, `--use-pgadmin`, and the explicit `--from/--to migrations` catalog miss; never `--use-pg-schema`, which delegates to the bundled Go binary); a warm hit `touch`es its mtime (LRU); every cache-eligible acquire may delete other keys under LRU keep-8 + 14-day mtime TTL — ~90MB (`SUPABASE_HOME` overrides the root) | +| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | during a cold export — the in-flight temp file, `rename`d into the tar above on success and removed on failure; only a crash/SIGKILL leaves it behind, and later cold exports / warm hits sweep leftovers older than an hour | +| `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | +| `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | ## Docker @@ -62,9 +62,10 @@ it, and JSON `null` disables formatting without disabling safe compaction. called with `targetLocal: false`/`usePgDelta: false` to skip the declarative-schema-override branch — not a second, `__catalog`-specific shadow, and not a shared `mode: "diff"` parameter (that seam-era concept no longer exists). `--use-pgadmin` provisions its OWN shadow via a - narrower composition — `legacyCreateShadowDatabase` -> health-wait -> `legacyMigrateShadowDatabase` - directly (`diff.handler.ts`'s pgadmin branch) — with no declarative-schema-override branch and - no `targetUrlOverride`. + narrower composition — `legacyWithShadowDatabase` (the same cached acquire/release seam as the + native branch) -> `legacyWaitForShadowReady` (the same connect probe, not the Docker + HEALTHCHECK) -> `legacyMigrateShadowDatabase` directly (`diff.handler.ts`'s pgadmin branch) — + with no declarative-schema-override branch and no `targetUrlOverride`. - `supabase/migra` container — the migra OOM bash fallback only. - **Differ container** (`--use-pgadmin`, CLI-1968) — `supabase/pgadmin-schema-diff:cli-0.0.5` (`dockerfileServiceImage("differ")`). One `docker run --rm` when no `--schema` is given; one @@ -107,8 +108,9 @@ of this command's own target resolve, ahead of the differ container. | `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the differ's / shadow's image registry (shell **or** project `.env`, applied for the run via `legacyApplyProjectEnv`, matching `db push`/`db pull`/`db dump`) | no | `SUPABASE_DB_SHADOW_PORT`/`SUPABASE_NETWORK_ID`/`--network-id`/`SUPABASE_PROJECT_ID`/ -`SUPABASE_DB_HEALTH_TIMEOUT` all apply to `--use-pgadmin` too — its shadow is provisioned -through the same primitives. +`SUPABASE_DB_HEALTH_TIMEOUT`/`SUPABASE_HOME`/`SUPABASE_SHADOW_CACHE`/`SUPABASE_SHADOW_DEBUG` all +apply to `--use-pgadmin` too — its shadow is provisioned through the same primitives, including +the same cached acquire. `SUPABASE_EXPERIMENTAL_PG_DELTA` is **read, no effect** on the pgadmin path: the pg-delta engine-selection lookup (`legacyShouldUsePgDelta`) runs unconditionally, before the @@ -245,8 +247,9 @@ Container lifecycle is identical to the uncached path except a cold run drops `- on release). A cache anomaly never fails the command — a warm-path anomaly cold-provisions instead, a cold export failure only warns and leaves the run uncached (one exception: a shadow that fails to come back up after the snapshot fails the run rather than reporting a false success). See `shared/db-bootstrap/ -shadow-cache.ts`'s doc comment for the mechanics. `--use-pgadmin` is NOT cached — its shadow keeps -the plain create/remove lifecycle. +shadow-cache.ts`'s doc comment for the mechanics. `--use-pgadmin` shares this cache and these +snapshots: its shadow runs the same forced-on Webhooks/`pg_net` baseline +(`legacyMigrateShadowDatabase`), so it keys to the same tars as the native branch. ### `--use-pgadmin` parity quirks and deliberate divergence (CLI-1968) 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 c063c10c1e..dede679c2e 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -29,12 +29,10 @@ import { legacySchemaToCsvField } from "../../../shared/legacy-schema-flags.ts"; import { legacyFindDropStatements } from "../../../shared/legacy-sql-split.ts"; import { legacyBuildLocalDbContainerInputs } from "../../../shared/db-bootstrap/local-container-inputs.ts"; import { legacyIsLocalDbRunning } from "../../../shared/db-bootstrap/local-db-running.ts"; -import { legacyWaitForHealthyServices } from "../../../shared/db-bootstrap/health-check.ts"; +import { legacyWaitForShadowReady } from "../../../shared/db-bootstrap/health-check.ts"; import { legacyWithShadowDatabase } from "../../../shared/db-bootstrap/shadow-cache.ts"; import { - legacyCreateShadowDatabase, legacyMigrateShadowDatabase, - legacyRemoveShadowDatabase, legacyShadowRunInputFromLocalContainerInputs, } from "../../../shared/db-bootstrap/shadow-database.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; @@ -639,24 +637,39 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy password: shadowBase.password, database: "postgres", }; - // Register cleanup atomically with shadow creation; preparation stays interruptible. - const sql = yield* Effect.acquireUseRelease( - legacyCreateShadowDatabase(spawner, shadowBase), + // Register cleanup atomically with shadow acquisition; preparation stays interruptible. + // Same cached provisioning seam as the native branch below (`legacyWithShadowDatabase`, + // `shadow-cache.ts`) — see that call site's comment for the full rationale. The migrate + // step is untouched: it still receives the whole local migration set through the SAME + // `legacyMigrateShadowDatabase`, only now told (via `handle`) whether the cluster already + // carries the platform baseline. `webhooks: "enabled"` matches that function's own forced + // `pg_net` baseline, so this shares the snapshots the native branch below keys for the SAME + // forced-on baseline (its legacy-engine runs) rather than a second, pgAdmin-only set. It + // deliberately does NOT share with next's config-following migrate — see that branch's own + // `migrationMode`-conditional `webhooks` opt. + const sql = yield* legacyWithShadowDatabase( + spawner, + shadowBase, (handle) => Effect.gen(function* () { - yield* legacyWaitForHealthyServices(spawner, [handle.containerId], { + yield* legacyWaitForShadowReady(spawner, handle.containerId, shadowConnConfig, { timeoutSeconds: shadowBase.healthTimeoutSeconds, + image: shadowBase.image, }); - yield* legacyMigrateShadowDatabase(spawner, { - fs, - path, - workdir: cliConfig.workdir, - projectId: shadowBase.projectId, - container: handle.containerId, - networkId: shadowBase.networkId, - connConfig: shadowConnConfig, - setup: shadowBase.setup, - }); + yield* legacyMigrateShadowDatabase( + spawner, + { + fs, + path, + workdir: cliConfig.workdir, + projectId: shadowBase.projectId, + container: handle.containerId, + networkId: shadowBase.networkId, + connConfig: shadowConnConfig, + setup: shadowBase.setup, + }, + handle, + ); yield* emitStatus("Diffing local database with current migrations..."); return yield* legacyDiffSchemaPgAdmin({ // `source`/`target` are INVERTED relative to the migra/pg-delta path below: @@ -672,7 +685,7 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy emitStatus, }); }), - (handle) => legacyRemoveShadowDatabase(spawner, handle.containerId), + { webhooks: "enabled" }, ); diffResult = { sql, files: undefined }; } else { @@ -698,11 +711,14 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy // why the cache seam sits here (with `SUPABASE_SHADOW_CACHE` unset it IS today's // create/remove pair; otherwise a key-matching PGDATA snapshot is restored into the fresh // container in a few seconds instead of cold-provisioning the baseline in ~15s). - // The key's webhooks policy mirrors the migrate `legacyPrepareShadowSource` will actually - // select for this mode: legacy's `legacyMigrateShadowDatabase` forces `pg_net` on, next's - // `legacyMigrateNextShadowDatabase` follows project config — a key that said "enabled" for - // a config-following baseline would let the two engines restore each other's tars - // (review: Codex on #6184). + // The `webhooks` policy MUST describe the baseline the `use` callback below actually + // provisions, because that is what the cache key hashes: `legacyPrepareShadowSource` + // dispatches on `migrationMode`, running `legacyMigrateShadowDatabase` (forced `pg_net`) for + // the legacy engine but `legacyMigrateNextShadowDatabase` (config-following) for pg-delta + // next. Hardcoding `"enabled"` for both would make a next-mode cold run on a + // webhooks-disabled project publish a `pg_net`-less cluster under the + // `webhooks_enabled=true` key that the pgAdmin branch above (whose baseline really is + // forced-on) could then warm-restore, and vice versa. diffResult = yield* legacyWithShadowDatabase( spawner, shadowInput, @@ -753,7 +769,7 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy // single migration file. return { sql, files: undefined }; }), - { webhooks: migrationMode === "pgdelta-next" ? "config" : "enabled" }, + migrationMode === "pgdelta-next" ? {} : { webhooks: "enabled" }, ); } const out = diffResult.sql; diff --git a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts index 20d749ddbe..1e7caf9d3d 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from import { basename, join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Fiber, Layer, Option } from "effect"; +import { Effect, Exit, Fiber, Layer, Option, Path } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -20,6 +20,7 @@ import { mockLegacyTelemetryStateTracked, useLegacyShadowCacheDisabled, useLegacyTempWorkdir, + withLegacyShadowCacheEnabled, legacySequentialExecBatch, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockOutput, mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts"; @@ -57,6 +58,7 @@ import { LegacyEdgeRuntimeScript, } from "../../../shared/legacy-edge-runtime-script.service.ts"; import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; +import { legacyShadowBaselineCacheDir } from "../../../shared/legacy-pgdelta.paths.ts"; import { LegacyPgDeltaEngine, type LegacyPgDeltaDatabaseDiffInput, @@ -97,16 +99,11 @@ interface SetupOpts { // over `failWriteOnCall` when shadow setup writes extra SQL before the // command's `--file` migration. readonly failWriteMatching?: (path: string) => boolean; - // When set, the shadow container never reports healthy — for the interrupt-during- - // health-wait regression coverage (review: PRRT_kwDOErm0O86XMrID). See - // `mockLegacyShadowContainerCliSpawner`'s own doc comment for why this is required - // (not `Effect.never`) to observe a genuinely suspended retry loop. Only the - // `--use-pgadmin` branch still gates on the Docker healthcheck; the shadow-source - // branch gates on `neverConnectableShadow` below instead. - readonly neverHealthyShadow?: boolean; // When set, every connect to the shadow's own port is refused, so the readiness gate - // (`legacyWaitForShadowReady`) keeps polling — the shadow-source branch's equivalent of - // `neverHealthyShadow`, since that wait no longer consults the Docker healthcheck. + // (`legacyWaitForShadowReady`) keeps polling — for the interrupt-during-readiness-wait + // regression coverage (review: PRRT_kwDOErm0O86XMrID). A refused connect, not an unhealthy + // container, is what keeps a provisioning fiber genuinely suspended: NO branch (pgAdmin + // included) gates on the Docker healthcheck any more. readonly neverConnectableShadow?: boolean; // `LegacyCliConfig.projectId` (the `SUPABASE_PROJECT_ID` env-only reader). Defaults // to `Option.some("test")`; pass `Option.none()` to exercise the @@ -204,7 +201,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { // and a real (fake) Postgres session backs the shadow's own // platform-baseline/migration/declarative setup. const shadowSpawner = mockLegacyShadowContainerCliSpawner({ - neverHealthy: opts.neverHealthyShadow ?? false, dbNotRunning: opts.dbNotRunning ?? false, dbInspectFailsWith: opts.dbInspectFailsWith, }); @@ -1096,6 +1092,87 @@ describe("legacy db diff", () => { }, ); + // The shadow baseline cache (`shared/db-bootstrap/shadow-cache.ts`) is ON by default in + // production; the suite-wide `useLegacyShadowCacheDisabled` above turns it off everywhere else. + // `--use-pgadmin` used to be the one native branch that provisioned a bare, uncached shadow — + // this scenario turns the cache back on (under a per-test `SUPABASE_HOME`) and drives two + // pgAdmin diffs to prove it now shares the same seam as the migra/pg-delta branch. The cache's + // own mechanics are covered in `shared/db-bootstrap/shadow-cache.integration.test.ts`. + it.effect( + "--use-pgadmin reuses the cached platform baseline on a second run, without changing the diff it produces", + () => + withLegacyShadowCacheEnabled( + join(tmp.current, "_supabase_home"), + Effect.gen(function* () { + const run = Effect.fnUntraced(function* () { + // The stateful Docker model, so the cold export publishes a genuinely stamped tar + // the warm run's archive inspection accepts. + const s = setup(tmp.current, { + statefulDocker: true, + pgadminStdout: [JSON.stringify([pgadminEntry()])], + }); + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })).pipe( + Effect.provide(s.layer), + ); + return s; + }); + + const cold = yield* run(); + expect(cold.shadowSetupJobCalls.length).toBeGreaterThan(0); + expect(cold.dockerDaemon?.calls("stop")).toHaveLength(1); + + const warm = yield* run(); + // The restored cluster already carries the platform baseline, so + // `legacyMigrateShadowDatabase` skips `SetupDatabase` and its one-shot jobs — but + // still creates `contrib_regression` and replays every local migration, and the + // differ run and its output are byte-identical to the cold run's. + expect(warm.shadowSetupJobCalls).toHaveLength(0); + expect(warm.dockerDaemon?.calls("stop")).toHaveLength(0); + expect(warm.dockerDaemon?.calls("rm")).toHaveLength(1); + expect(warm.differCalls).toHaveLength(1); + expect(stdout(warm.out)).toBe(stdout(cold.out)); + }), + ), + ); + + // The two native branches provision DIFFERENT clusters on a webhooks-disabled project: + // `--use-pgadmin` migrates through `legacyMigrateShadowDatabase`, whose baseline installs + // `pg_net` unconditionally, while pg-delta next migrates through + // `legacyMigrateNextShadowDatabase`, which follows `config.toml` (webhooks absent = off). If the + // cache key described the caller's literal `webhooks` opt rather than the baseline the run + // actually builds, one would warm-restore the other's snapshot and silently diff against a + // cluster with the wrong extension set. + it.effect( + "--use-pgadmin does not reuse the pg-delta next baseline when config leaves webhooks disabled", + () => + withLegacyShadowCacheEnabled( + join(tmp.current, "_supabase_home"), + Effect.gen(function* () { + const next = setup(tmp.current, { pgDeltaImplementation: "next" }); + yield* legacyDbDiff(flags({ usePgDelta: Option.some(true) })).pipe( + Effect.provide(next.layer), + ); + expect(next.shadowSpawned.filter((c) => c.args[0] === "stop")).toHaveLength(1); + + const pgadmin = setup(tmp.current, { + pgadminStdout: [JSON.stringify([pgadminEntry()])], + }); + yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })).pipe( + Effect.provide(pgadmin.layer), + ); + // Cold, not warm: it ran its own `SetupDatabase` one-shot jobs and published its own + // snapshot instead of restoring the config-following one next just wrote. + expect(pgadmin.shadowSetupJobCalls.length).toBeGreaterThan(0); + expect(pgadmin.shadowSpawned.filter((c) => c.args[0] === "stop")).toHaveLength(1); + + // Two keys, two tars — the poisoning scenario cannot arise. + const cacheDir = legacyShadowBaselineCacheDir(yield* Path.Path); + const tars = readdirSync(cacheDir).filter((name) => name.endsWith(".tar")); + expect(tars).toHaveLength(2); + }).pipe(Effect.provide(BunServices.layer)), + ), + ); + it.effect("rejects --project-ref combined with --use-pg-schema before delegating", () => { // The bundled Go binary's own `db diff` never registered `--project-ref`, so // the flag can't be forwarded — fail up front instead of silently dropping it. @@ -2583,23 +2660,18 @@ describe("legacy db diff", () => { it.live( "removes the shadow container on interruption during the health wait for --use-pgadmin too", () => { - const s = setup(tmp.current, { neverHealthyShadow: true }); + const s = setup(tmp.current, { neverConnectableShadow: true }); return Effect.gen(function* () { const fiber = yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })).pipe( Effect.provide(s.layer), Effect.forkChild({ startImmediately: true }), ); - // Wait for the SHADOW's own health probe specifically (its 64-hex id) — - // the pgadmin path's separate `supabase_db_test` "is running" probe fires - // first and would otherwise satisfy a looser check immediately. - while ( - !s.shadowSpawned.some( - (c) => - c.args[0] === "container" && - c.args[1] === "inspect" && - c.args[2] === LEGACY_FAKE_SHADOW_CONTAINER_ID, - ) - ) { + // Wait until the shadow's readiness gate has actually refused a connect at least + // once — proving the fiber is genuinely suspended inside `legacyWaitForShadowReady`'s + // retry loop, not merely past the `create` call. Same gate as the native branch's + // own interrupt test above: pgAdmin gates on the connect probe too now, not on the + // Docker healthcheck. + while (s.shadowConnectedDatabases.length === 0) { yield* Effect.sleep("5 millis"); } yield* Fiber.interrupt(fiber); 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 d3266d8c0d..73fbc8b5ce 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -795,11 +795,14 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // pooler-retry attempt still acquires and releases its own shadow — on the warm path // each attempt restores its own fresh container from the same cached snapshot, // sequentially. - // The key's webhooks policy mirrors the migrate `legacyPrepareShadowSource` will - // actually select for this mode: legacy's `legacyMigrateShadowDatabase` forces - // `pg_net` on, next's `legacyMigrateNextShadowDatabase` follows project config — - // a key that said "enabled" for a config-following baseline would let the two - // engines restore each other's tars (review: Codex on #6184). + // The `webhooks` policy MUST describe the baseline the `use` callback below actually + // provisions, because that is what the cache key hashes: `legacyPrepareShadowSource` + // dispatches on `migrationMode`, running `legacyMigrateShadowDatabase` (forced + // `pg_net`) for the legacy engine but `legacyMigrateNextShadowDatabase` + // (config-following) for pg-delta next. Hardcoding `"enabled"` for both would make a + // next-mode cold run on a webhooks-disabled project publish a `pg_net`-less cluster + // under the `webhooks_enabled=true` key, which `db diff --use-pgadmin` (whose baseline + // really is forced-on) could then warm-restore, and vice versa. return yield* legacyWithShadowDatabase( spawner, shadowInput, @@ -852,7 +855,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy }); return { sql, files: undefined, debug: undefined }; }), - { webhooks: migrationMode === "pgdelta-next" ? "config" : "enabled" }, + migrationMode === "pgdelta-next" ? {} : { webhooks: "enabled" }, ); }); const diffOutcome = yield* withPoolerFallback(targetEndpoint, runShadowDiff); 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 cced4a7655..fdc63f87ac 100644 --- a/apps/cli/src/legacy/commands/migration/squash/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/migration/squash/SIDE_EFFECTS.md @@ -19,24 +19,29 @@ migration-history table to match. | `/supabase/.temp/{project-ref,postgres-version,pooler-url}` | plain text | `--linked` / linked path — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | | `~/.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 | +| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | warm shadow-cache hit — the matching snapshot is streamed into the fresh shadow (see the shadow baseline cache section below) | ## Files Written -| 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) | +| 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/cache/shadow-baseline/shadow-baseline-.tar` | tar | cache-enabled (default) COLD shadow provision creates the current key's snapshot; a warm hit `touch`es its mtime (LRU); every cache-eligible acquire may delete other keys under LRU keep-8 + 14-day mtime TTL — ~90MB (`SUPABASE_HOME` overrides the root) | +| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | during a cold export — the in-flight temp file, `rename`d into the tar above on success and removed on failure; only a crash/SIGKILL leaves it behind, and later cold exports / warm hits sweep leftovers older than an hour | +| `/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. + started, readiness-polled, then removed (`rm -f -v`) once squash finishes, success or failure. + Readiness is `legacyWaitForShadowReady`: each round a `container inspect` still-running check + plus a direct Postgres connect probe — NOT the container's own Docker HEALTHCHECK, whose first + probe only fires at t+10s. - 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). @@ -51,6 +56,27 @@ migration-history table to match. Unlike `db diff`/`db pull`, the shadow only ever gets `legacySetupDatabase` (platform baseline + roles.sql) — **no** `CREATE DATABASE contrib_regression` template database. +### Shadow baseline cache (`SUPABASE_SHADOW_CACHE`, default ON) + +Squash acquires its shadow through the same `legacyWithShadowDatabase` seam as `db diff`/`db +pull` (`shared/db-bootstrap/shadow-cache.ts`), so everything documented in those commands' +`SIDE_EFFECTS.md` applies verbatim: ON by default, `SUPABASE_SHADOW_CACHE=false`/`=0` opts out +(honored from the ambient env AND the project's dotenv), the artifact is a ~90MB PGDATA snapshot +under `~/.supabase/cache/shadow-baseline/` keyed by a hash of every input baked into the cluster, +retention is LRU keep-8 + 14-day mtime TTL, a cold run drops `--rm` (still removed on release), +and a cache anomaly never fails the command. + +Two squash-specific points: + +- The snapshot covers the platform baseline ONLY. A warm hit skips `legacySetupDatabase` — so + neither `Initialising schema...` nor `Seeding globals from roles.sql...` prints, and the PG15+ + one-shot realtime/storage/auth migrate jobs do not run — and then resumes at exactly the same + seam as a cold run: the before-migration `auth`/`storage` dump, the migrations up to the target, + the after-migration dump, and the full dump are all unchanged. +- Squash's `SetupDatabase` follows `config.toml` for Webhooks/`pg_net`, unlike `db diff`/`db +pull`'s forced-on `legacyMigrateShadowDatabase` baseline. That effective policy is part of the + cache key, so squash keys to its own tars and can never warm-restore a `pg_net`-forced cluster. + ## API Routes | Method | Path | Auth | Purpose | @@ -66,7 +92,9 @@ migration-history table to match. `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`. +`SUPABASE_EXPERIMENTAL`, `SUPABASE_HOME` (root of the shadow baseline cache), +`SUPABASE_SHADOW_CACHE` (shadow baseline cache; ON by default, `false`/`0` opts out), +`SUPABASE_SHADOW_DEBUG` (opt-in shadow phase-timing diagnostics on stderr). ## Exit Codes @@ -85,8 +113,8 @@ 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) +Initialising schema... (cold shadow only — a warm baseline-cache hit skips it) +Seeding globals from roles.sql... (cold shadow only; then 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 -- @@ -155,4 +183,6 @@ code or the rest of the payload. already-open file descriptor. - `Initialising schema...` is printed by the shared setup prelude just before `legacySetupDatabase` runs rather than from inside it — inherited from CLI-1956, shared with - `db diff`/`db pull`'s identical shadow-provisioning prelude. + `db diff`/`db pull`'s identical shadow-provisioning prelude. A warm shadow-cache hit never + reaches that prelude, so the line (and `Seeding globals from roles.sql...`) is absent — + progress text reflects the work actually performed. 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 7d3e85f4e7..9198a3ea96 100644 --- a/apps/cli/src/legacy/commands/migration/squash/squash.handler.ts +++ b/apps/cli/src/legacy/commands/migration/squash/squash.handler.ts @@ -19,16 +19,10 @@ import { legacyBuildLocalDbContainerInputs, type LegacyLocalDbContainerInputs, } from "../../../shared/db-bootstrap/local-container-inputs.ts"; +import { legacyWaitForShadowReady } from "../../../shared/db-bootstrap/health-check.ts"; +import { legacyWithShadowDatabase } from "../../../shared/db-bootstrap/shadow-cache.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, + legacyOpenShadowBaselineSession, legacyShadowRunInputFromLocalContainerInputs, } from "../../../shared/db-bootstrap/shadow-database.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; @@ -85,14 +79,31 @@ type Spawner = ChildProcessSpawnerType["Service"]; /** * `squashMigrations`: - * shadow create -> health-wait -> connect -> `start.SetupDatabase` DIRECTLY + * shadow acquire -> health-wait -> connect -> `start.SetupDatabase` DIRECTLY * (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, from a single cancellable scope). + * auth/storage schema before migrating -> apply the migrations up to the target -> dump + * auth/storage again -> write the target file as the FULL (unrestricted) dump + the separator + + * the auth/storage line diff. `acquire` is only shadow acquisition (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, from a single cancellable scope). + * + * `legacyWithShadowDatabase` (`shadow-cache.ts`) rather than a bare `legacyCreateShadowDatabase`/ + * `legacyRemoveShadowDatabase` pair — see its doc comment for both halves of the rationale: why + * the lifecycle is an `Effect.acquireUseRelease` (an interrupt must not be able to land between + * creation and the finalizer being attached) and why the cache seam sits here (with + * `SUPABASE_SHADOW_CACHE` unset it IS today's create/remove pair; otherwise a key-matching PGDATA + * snapshot is restored into the fresh container in a few seconds instead of cold-provisioning the + * baseline in ~15s). No `webhooks` override, unlike `db diff`/`db pull`'s forced-on + * `legacyMigrateShadowDatabase` baseline: squash's `SetupDatabase` call has always followed + * `config.toml`, so the cache key must hash the config-following policy or a squash run would + * warm-restore a `pg_net`-forced cluster. + * + * The baseline is the ONLY thing the cache covers, and + * {@link legacyOpenShadowBaselineSession} hands back the open session at exactly that seam — so + * squash's own `before` dump / apply-migrations / `after` dump sequence is unchanged, and a warm + * shadow merely reaches the `before` dump without having re-run `SetupDatabase` (and therefore + * without printing its `Initialising schema...`/`Seeding globals...` progress lines). */ const squashMigrations = Effect.fnUntraced(function* ( spawner: Spawner, @@ -122,107 +133,104 @@ const squashMigrations = Effect.fnUntraced(function* ( // `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, - ), - ); + yield* legacyWithShadowDatabase(spawner, shadowInput, (handle) => + Effect.scoped( + Effect.gen(function* () { + yield* legacyWaitForShadowReady(spawner, handle.containerId, connConfig, { + timeoutSeconds: shadowInput.healthTimeoutSeconds, + // The shadow container's OWN resolved image, not the (pin-resolved, unmapped) `image` + // the `pg_dump` containers below use — this one only names the shadow in the + // exec-format recovery hint. + image: shadowInput.image, + }); + const session = yield* legacyOpenShadowBaselineSession( + spawner, + { + fs: shadowInput.fs, + path: shadowInput.path, + workdir: shadowInput.workdir, + projectId: shadowInput.projectId, + container: handle.containerId, + networkId: shadowInput.networkId, + connConfig, + setup: shadowInput.setup, + }, + {}, + handle, + ); - const before = yield* legacySquashDumpSchemaToString({ - image, - conn: connConfig, - schema: ["auth", "storage"], - projectEnvValues: localInputs.context.projectEnvValues, - }); - yield* legacyApplyMigrations( - session, - fs, - path, - migrations, - (message) => new LegacyMigrationApplyError({ message }), - ); - const after = yield* legacySquashDumpSchemaToString({ - image, - conn: connConfig, - schema: ["auth", "storage"], - projectEnvValues: localInputs.context.projectEnvValues, - }); - - const targetPath = migrations[migrations.length - 1]!; - const targetRel = path.relative(workdir, targetPath); - yield* Effect.scoped( - Effect.gen(function* () { - // One open 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. - 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 — streamed straight into the - // already-truncated file at constant memory. The underlying failure here is - // the docker-log-stream write into the file handle, - // not the line-diff writer below, so it byte-matches "failed to copy - // docker logs:" rather than "failed to write line:". - yield* legacySquashDumpSchema({ - image, - conn: connConfig, - schema: [], - projectEnvValues: localInputs.context.projectEnvValues, - onStdout: (chunk) => - file.writeAll(chunk).pipe( - Effect.mapError( - (cause) => - new LegacyMigrationSquashWriteError({ - message: `failed to copy docker logs: ${legacyErrorMessage(cause)}`, - }), - ), + const before = yield* legacySquashDumpSchemaToString({ + image, + conn: connConfig, + schema: ["auth", "storage"], + projectEnvValues: localInputs.context.projectEnvValues, + }); + yield* legacyApplyMigrations( + session, + fs, + path, + migrations, + (message) => new LegacyMigrationApplyError({ message }), + ); + const after = yield* legacySquashDumpSchemaToString({ + image, + conn: connConfig, + schema: ["auth", "storage"], + projectEnvValues: localInputs.context.projectEnvValues, + }); + + const targetPath = migrations[migrations.length - 1]!; + const targetRel = path.relative(workdir, targetPath); + yield* Effect.scoped( + Effect.gen(function* () { + // One open 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. + 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 — streamed straight into the + // already-truncated file at constant memory. The underlying failure here is + // the docker-log-stream write into the file handle, + // not the line-diff writer below, so it byte-matches "failed to copy + // docker logs:" rather than "failed to write line:". + yield* legacySquashDumpSchema({ + image, + conn: connConfig, + schema: [], + projectEnvValues: localInputs.context.projectEnvValues, + onStdout: (chunk) => + file.writeAll(chunk).pipe( + Effect.mapError( + (cause) => + new LegacyMigrationSquashWriteError({ + message: `failed to copy docker logs: ${legacyErrorMessage(cause)}`, + }), ), - }); - // The separator and the auth/storage line diff write 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), + }); + // The separator and the auth/storage line diff write 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)}`, + }), + ), + ); + }), + ); + }), + ), ); }); 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 dafd73c57d..9fb0d97809 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 @@ -1,8 +1,8 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readdirSync, 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 { Cause, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; import { PlatformError, SystemError } from "effect/PlatformError"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -13,10 +13,12 @@ import { LEGACY_VALID_REF, mockLegacyCliConfig, mockLegacyLinkedProjectCacheTracked, + mockLegacyDockerDaemonCliSpawner, mockLegacyShadowContainerCliSpawner, mockLegacyTelemetryStateTracked, useLegacyShadowCacheDisabled, useLegacyTempWorkdir, + withLegacyShadowCacheEnabled, legacySequentialExecBatch, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { @@ -40,12 +42,16 @@ import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref. 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 { legacyShadowBaselineCacheDir } from "../../../shared/legacy-pgdelta.paths.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 { + LegacyDbConnectError, + LegacyDbExecError, +} from "../../../shared/legacy-db-connection.errors.ts"; import { LegacyDbConnection, type LegacyDbSession, @@ -209,6 +215,9 @@ function faultyFsLayer(opts: FsFaultOpts): Layer.Layer { ).pipe(Layer.provide(BunServices.layer)); } +/** The default `[db] shadow_port`, i.e. the port squash's own shadow listens on. */ +const LEGACY_SHADOW_PORT = 54320; + const alwaysReadyHttpClientLayer = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => @@ -230,7 +239,13 @@ interface SetupOpts { readonly failResolve?: boolean; readonly failSql?: string; readonly networkId?: string; - readonly neverHealthyShadow?: boolean; + /** + * Every connect to the shadow's own port is refused, so its readiness gate + * (`legacyWaitForShadowReady`) keeps polling until the health budget runs out. That gate is a + * direct Postgres connect probe, not the Docker healthcheck, so an unconnectable shadow — not + * an unhealthy container — is what a squash readiness timeout actually looks like. + */ + readonly neverConnectableShadow?: boolean; readonly failCreateShadow?: boolean; readonly failRemoveShadow?: boolean; readonly failSetupJob?: boolean; @@ -239,6 +254,10 @@ interface SetupOpts { readonly fullDumpSql?: string; readonly failDumpKind?: "before" | "after" | "full"; readonly fsFaults?: FsFaultOpts; + // Swaps the stateless shadow spawner for the stateful Docker model, whose + // `stop`/`cp`/`start` really move bytes. Required by (and only by) the tests that + // enable the shadow BASELINE CACHE — see `mockLegacyDockerDaemonCliSpawner`. + readonly statefulDocker?: boolean; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -247,10 +266,14 @@ function setup(workdir: string, opts: SetupOpts = {}) { const cache = mockLegacyLinkedProjectCacheTracked(); const spawner = mockLegacyShadowContainerCliSpawner({ - neverHealthy: opts.neverHealthyShadow ?? false, failCreate: opts.failCreateShadow ?? false, failRemove: opts.failRemoveShadow ?? false, }); + // The shadow baseline cache's cold export and warm restore only mean anything against a + // daemon that actually holds container state and carries `docker cp` bytes, so the cache + // tests below opt into the stateful model instead. + const dockerDaemon = + opts.statefulDocker === true ? mockLegacyDockerDaemonCliSpawner() : undefined; const docker = mockSquashDockerRun({ beforeSql: opts.beforeDumpSql, afterSql: opts.afterDumpSql, @@ -269,8 +292,11 @@ function setup(workdir: string, opts: SetupOpts = {}) { const connectedDatabases: Array = []; const connection = Layer.succeed(LegacyDbConnection, { connect: (cfg: LegacyPgConnInput) => - Effect.sync(() => { + Effect.gen(function* () { connectedDatabases.push(cfg.database); + if (opts.neverConnectableShadow === true && cfg.port === LEGACY_SHADOW_PORT) { + return yield* Effect.fail(new LegacyDbConnectError({ message: "connection refused" })); + } const session: LegacyDbSession = { exec: (sql: string) => Effect.suspend(() => { @@ -366,7 +392,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { resolver, connection, projectRef, - spawner.layer, + dockerDaemon?.layer ?? spawner.layer, docker.layer, debugLogger, alwaysReadyHttpClientLayer, @@ -403,6 +429,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { resolverCalls, debugLogs, shadowSpawned: spawner.spawned, + dockerDaemon, dumpCalls: docker.dumpCalls, setupJobCalls: docker.setupJobCalls, }; @@ -887,6 +914,95 @@ describe("legacy migration squash", () => { }); }); + // The shadow baseline cache (`shared/db-bootstrap/shadow-cache.ts`) is ON by default in + // production; the suite-wide `useLegacyShadowCacheDisabled` above turns it off everywhere else + // so the other scenarios assert the plain shadow lifecycle. These scenarios turn it back on — + // under a per-test `SUPABASE_HOME`, so the ~90MB-in-production tar never lands in the + // developer's real `~/.supabase` — and drive squash twice to prove the seam is wired: the + // second run must reuse the first's baseline WITHOUT changing anything squash itself produces. + // The cache's own mechanics (key derivation, atomic publish, retention, degradation) are + // covered at their own level in `shared/db-bootstrap/shadow-cache.integration.test.ts`. + describe("shadow baseline cache", () => { + 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"; + const EXPECTED_TARGET = + FULL_SQL + + "\n--\n-- Dumped schema changes for auth and storage\n--\n\n" + + "new auth object;\n"; + + /** Re-enables the cache the suite-wide gate turned off, rooted at a per-test `SUPABASE_HOME`. */ + const withCacheEnabled = (body: Effect.Effect): Effect.Effect => + withLegacyShadowCacheEnabled(join(tmp.current, "_supabase_home"), body); + + /** One full squash run over a freshly re-seeded two-migration project. */ + const runSquash = Effect.fnUntraced(function* () { + 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, { + statefulDocker: true, + beforeDumpSql: BEFORE_SQL, + afterDumpSql: AFTER_SQL, + fullDumpSql: FULL_SQL, + }); + yield* legacyMigrationSquash(flags()).pipe(Effect.provide(s.layer)); + return s; + }); + + it.effect( + "reuses the first run's platform baseline on the second squash, without changing the dumps it produces", + () => + withCacheEnabled( + Effect.gen(function* () { + const cold = yield* runSquash(); + // Cold: the baseline really ran (progress lines + the PG15+ one-shot setup jobs), + // and the snapshot was taken at the baseline seam (`docker stop` -> `cp` -> `start`). + expect(stderr(cold.out)).toContain("Initialising schema..."); + expect(stderr(cold.out)).toContain("Seeding globals from roles.sql..."); + expect(cold.setupJobCalls.length).toBeGreaterThan(0); + expect(cold.dockerDaemon?.calls("stop")).toHaveLength(1); + expect(cold.dockerDaemon?.calls("start").length).toBeGreaterThan(0); + + const warm = yield* runSquash(); + // Warm: the restored cluster already carries the baseline, so `SetupDatabase` — and + // therefore its progress text and its one-shot jobs — is skipped entirely, and + // nothing is re-snapshotted. + expect(stderr(warm.out)).not.toContain("Initialising schema..."); + expect(stderr(warm.out)).not.toContain("Seeding globals from roles.sql..."); + expect(warm.setupJobCalls).toHaveLength(0); + expect(warm.dockerDaemon?.calls("stop")).toHaveLength(0); + + // Everything downstream of the baseline seam is untouched: both dumps, the + // migrations, the rewritten target file, and the shadow's own lifecycle. + expect(warm.dumpCalls).toHaveLength(3); + expect(stderr(warm.out)).toContain("Applying migration 0_init.sql..."); + expect(stderr(warm.out)).toContain("Applying migration 1_target.sql..."); + expect(stderr(warm.out)).toContain( + "Squashed local migrations to supabase/migrations/1_target.sql", + ); + expect(warm.dockerDaemon?.calls("rm")).toHaveLength(1); + const target = join(tmp.current, "supabase", "migrations", "1_target.sql"); + expect(readFileSync(target, "utf8")).toBe(EXPECTED_TARGET); + }), + ), + ); + + it.effect("keys its snapshots under the cache root, not the project directory", () => + withCacheEnabled( + Effect.gen(function* () { + yield* runSquash(); + // The production path helper resolving the `SUPABASE_HOME` `withCacheEnabled` pinned, + // not a hand-built join — so this stays honest if the cache root ever moves. + const cacheDir = legacyShadowBaselineCacheDir(yield* Path.Path); + const tars = readdirSync(cacheDir).filter((name) => name.endsWith(".tar")); + expect(tars).toHaveLength(1); + expect(tars[0]).toMatch(/^shadow-baseline-[0-9a-f]+\.tar$/); + expect(existsSync(join(tmp.current, "supabase", ".temp", "shadow-baseline"))).toBe(false); + }).pipe(Effect.provide(BunServices.layer)), + ), + ); + }); + // Failure paths — every one leaves the shadow removed (unless creation // itself is what failed, matching the established leak-on-create-failure behavior). @@ -907,7 +1023,7 @@ describe("legacy migration squash", () => { }); it.effect( - "fails with a health-check timeout when the shadow never becomes healthy, and removes it", + "fails with a health-check timeout when the shadow never becomes connectable, and removes it", () => { seedMigration(tmp.current, "0_init.sql"); seedMigration(tmp.current, "1_target.sql"); @@ -918,7 +1034,7 @@ describe("legacy migration squash", () => { join(tmp.current, "supabase", "config.toml"), '[db]\nhealth_timeout = "0s"\n', ); - const s = setup(tmp.current, { neverHealthyShadow: true }); + const s = setup(tmp.current, { neverConnectableShadow: true }); return Effect.gen(function* () { const exit = yield* legacyMigrationSquash(flags()).pipe(Effect.exit); expect(failureTag(exit)).toBe("LegacyHealthCheckTimeoutError"); 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 ecba06ebed..f4870358a1 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts @@ -730,22 +730,96 @@ export const legacyBuildShadowSetupDatabaseInput = ( }); /** - * Port of Go's `SetupShadowDatabase` (`apps/cli-go/internal/db/diff/diff.go:181-193`): - * connects to the shadow (Go's `ConnectShadowDatabase`, {@link legacyConnectShadowDatabase}) - * FIRST, THEN resolves the setup prelude (JWKS/pinned image names, {@link - * legacyResolveDbSetupPrelude}) and runs {@link legacySetupShadowConn} — the platform - * baseline plus the template database, no user migrations. Connect-then-setup, matching Go's - * own `SetupShadowDatabase` (which dials `ConnectShadowDatabase` before ever calling - * `start.SetupDatabase`, `diff.go:186-192`) and this same module's `legacyRunFreshDbSetup` - * (`db-setup.ts`) for the real local `db` container: an unconnectable shadow must surface a - * connect error immediately, not pay for JWKS work first. The connection is closed once this - * resolves (Go's `defer conn.Close(...)`), matching `Effect.scoped`'s finalizer running at the - * end of this function rather than leaking a `Scope.Scope` requirement to the caller. + * Go's `SetupShadowDatabase`/`MigrateShadowDatabase` shared prologue: connect to the shadow + * (Go's `ConnectShadowDatabase`, {@link legacyConnectShadowDatabase}), resolve the setup prelude + * (JWKS/pinned image names, {@link legacyResolveDbSetupPrelude}), run the platform baseline + * ({@link legacySetupDatabase}) — and hand the caller back the STILL-OPEN session everything + * after the baseline runs on. Connect-then-setup, matching Go's own `SetupShadowDatabase` + * (which dials `ConnectShadowDatabase` before ever calling `start.SetupDatabase`, + * `diff.go:186-192`) and this same module's `legacyRunFreshDbSetup` (`db-setup.ts`) for the real + * local `db` container: an unconnectable shadow must surface a connect error immediately, not + * pay for JWKS work first. + * + * The returned session's lifetime is the CALLER's enclosing `Scope.Scope` (Go's + * `defer conn.Close(...)`), which is why this function leaks that requirement instead of + * wrapping itself in `Effect.scoped`. * * `baseline` defaults to {@link LEGACY_SHADOW_BASELINE_COLD}, i.e. exactly the sequence above. - * A warm shadow-cache hit skips the prelude + `SetupDatabase` (the restored cluster already - * has them) and only recreates `contrib_regression`; a cache-enabled COLD provision snapshots - * between the baseline and the template, matching {@link migrateShadowDatabase}. + * A warm shadow-cache hit skips the prelude + `SetupDatabase` entirely (the restored cluster + * already carries them); a cache-enabled COLD provision runs the baseline in its OWN scope, so + * its session is closed before {@link LegacyShadowBaselineState.snapshotBaseline} stops the + * container, and returns a second session opened against the restarted one. + * + * Shared by all three baseline-running shadow compositions — {@link legacySetupShadowDatabase} + * and {@link migrateShadowDatabase} here, plus `migration squash`'s own dump/apply/dump sequence + * (`migration/squash/squash.handler.ts`), which needs the baseline WITHOUT the template database + * (see {@link legacySetupShadowConn}'s own doc comment) and keeps the session open across its + * mid-sequence `pg_dump`s. + */ +export const legacyOpenShadowBaselineSession = ( + spawner: Spawner, + input: LegacyShadowSetupRunInput, + options: LegacySetupDatabaseOptions = {}, + baseline: LegacyShadowBaselineState = LEGACY_SHADOW_BASELINE_COLD, +): Effect.Effect< + LegacyDbSession, + LegacyStartSetupLocalDatabaseError | LegacyShadowDbError | LegacyImagePrepullError | E, + Output | LegacyDockerRun | RuntimeInfo | LegacyDbConnection | Scope.Scope +> => + Effect.gen(function* () { + if (!baseline.baselinePresent && baseline.snapshotRequired) { + // Own scope: the baseline session must be closed before `snapshotBaseline` — see + // {@link LegacyShadowBaselineState.snapshotRequired}. + yield* Effect.scoped( + Effect.gen(function* () { + const setupSession = yield* legacyConnectShadowDatabase(input.connConfig); + const resolved = yield* legacyResolveDbSetupPrelude(input.setup); + yield* legacySetupDatabase( + spawner, + legacyBuildShadowSetupDatabaseInput(input, setupSession, resolved), + options, + ).pipe( + // The baseline's batched SQL files check their own connection out of the pool; + // failing to acquire one is a shadow CONNECT failure, like + // `legacyConnectShadowDatabase`'s, never a setup/statement failure. + Effect.catchTag("LegacyDbConnectError", (cause) => + Effect.fail(new LegacyShadowDbError({ message: cause.message, reason: "connect" })), + ), + ); + }), + ); + yield* baseline.snapshotBaseline; + } + const session = yield* legacyConnectShadowDatabase(input.connConfig); + if (!baseline.baselinePresent && !baseline.snapshotRequired) { + // Go's single-connection flow, verbatim: baseline and everything after it on this one + // session — see {@link LegacyShadowBaselineState.snapshotRequired}. + const resolved = yield* legacyResolveDbSetupPrelude(input.setup); + yield* legacySetupDatabase( + spawner, + legacyBuildShadowSetupDatabaseInput(input, session, resolved), + options, + ).pipe( + // Same pooled-connection failure mapping as the snapshotting branch above. + Effect.catchTag("LegacyDbConnectError", (cause) => + Effect.fail(new LegacyShadowDbError({ message: cause.message, reason: "connect" })), + ), + ); + } + return session; + }); + +/** + * Port of Go's `SetupShadowDatabase` (`apps/cli-go/internal/db/diff/diff.go:181-193`): + * {@link legacyOpenShadowBaselineSession} (connect + platform baseline) followed by the template + * database — together Go's `setupShadowConn`, no user migrations. The connection is closed once + * this resolves (Go's `defer conn.Close(...)`), matching `Effect.scoped`'s finalizer running at + * the end of this function rather than leaking a `Scope.Scope` requirement to the caller. + * + * `baseline` defaults to {@link LEGACY_SHADOW_BASELINE_COLD}. A warm shadow-cache hit skips the + * prelude + `SetupDatabase` (the restored cluster already has them) and only recreates + * `contrib_regression`; a cache-enabled COLD provision snapshots between the baseline and the + * template, matching {@link migrateShadowDatabase}. */ export const legacySetupShadowDatabase = ( spawner: Spawner, @@ -759,41 +833,7 @@ export const legacySetupShadowDatabase = ( > => Effect.scoped( Effect.gen(function* () { - if (!baseline.baselinePresent && baseline.snapshotRequired) { - yield* Effect.scoped( - Effect.gen(function* () { - const setupSession = yield* legacyConnectShadowDatabase(input.connConfig); - const resolved = yield* legacyResolveDbSetupPrelude(input.setup); - yield* legacySetupDatabase( - spawner, - legacyBuildShadowSetupDatabaseInput(input, setupSession, resolved), - options, - ).pipe( - // The baseline's batched SQL files check their own connection out of the pool; - // failing to acquire one is a shadow CONNECT failure, like - // `legacyConnectShadowDatabase`'s, never a setup/statement failure. - Effect.catchTag("LegacyDbConnectError", (cause) => - Effect.fail(new LegacyShadowDbError({ message: cause.message, reason: "connect" })), - ), - ); - }), - ); - yield* baseline.snapshotBaseline; - } - const session = yield* legacyConnectShadowDatabase(input.connConfig); - if (!baseline.baselinePresent && !baseline.snapshotRequired) { - const resolved = yield* legacyResolveDbSetupPrelude(input.setup); - yield* legacySetupDatabase( - spawner, - legacyBuildShadowSetupDatabaseInput(input, session, resolved), - options, - ).pipe( - // Same connect-vs-setup classification as the snapshot branch above. - Effect.catchTag("LegacyDbConnectError", (cause) => - Effect.fail(new LegacyShadowDbError({ message: cause.message, reason: "connect" })), - ), - ); - } + const session = yield* legacyOpenShadowBaselineSession(spawner, input, options, baseline); yield* legacyCreateShadowTemplateDatabase(session); }), ); @@ -872,14 +912,9 @@ export const LEGACY_SHADOW_BASELINE_COLD: LegacyShadowBaselineState = { * the template database and the user migrations; a COLD cache-enabled provision passes the same * cold sequence plus a `snapshotBaseline` step between the baseline and the template database. * - * The one structural divergence from Go is confined to the SNAPSHOTTING cold branch - * (`baseline.snapshotRequired`): there the baseline runs in its own scope, its session is CLOSED - * before {@link LegacyShadowBaselineState.snapshotBaseline} (the disk-level PGDATA snapshot stops - * the container, which severs any live backend), and the template database + migrations run on a - * second session. Every OTHER state — uncached (cache off / `--no-cache` / OrioleDB) and warm — - * uses exactly one session, matching Go's single connection: see - * {@link LegacyShadowBaselineState.snapshotRequired} for why the split must not leak into the - * uncached path. The SQL every path issues is unchanged. + * The one structural divergence from Go is confined to the SNAPSHOTTING cold branch and is owned + * by {@link legacyOpenShadowBaselineSession} — see its doc comment. The SQL every path issues is + * unchanged. */ const migrateShadowDatabase = ( spawner: Spawner, @@ -904,45 +939,12 @@ const migrateShadowDatabase = ( ), ); - if (!baseline.baselinePresent && baseline.snapshotRequired) { - // Own scope: the baseline session must be closed before `snapshotBaseline` — see this - // function's own doc comment. - yield* Effect.scoped( - Effect.gen(function* () { - const setupSession = yield* legacyConnectShadowDatabase(input.connConfig); - const resolved = yield* legacyResolveDbSetupPrelude(input.setup); - yield* legacySetupDatabase( - spawner, - legacyBuildShadowSetupDatabaseInput(input, setupSession, resolved), - setupOptions, - ).pipe( - // The baseline's batched SQL files check their own connection out of the pool; - // failing to acquire one is a shadow CONNECT failure, like - // `legacyConnectShadowDatabase`'s, never a setup/statement failure. - Effect.catchTag("LegacyDbConnectError", (cause) => - Effect.fail(new LegacyShadowDbError({ message: cause.message, reason: "connect" })), - ), - ); - }), - ); - yield* baseline.snapshotBaseline; - } - const session = yield* legacyConnectShadowDatabase(input.connConfig); - if (!baseline.baselinePresent && !baseline.snapshotRequired) { - // Go's single-connection flow, verbatim: baseline + template + migrations all on this - // one session — see this function's own doc comment. - const resolved = yield* legacyResolveDbSetupPrelude(input.setup); - yield* legacySetupDatabase( - spawner, - legacyBuildShadowSetupDatabaseInput(input, session, resolved), - setupOptions, - ).pipe( - // Same connect-vs-setup classification as the snapshot branch above. - Effect.catchTag("LegacyDbConnectError", (cause) => - Effect.fail(new LegacyShadowDbError({ message: cause.message, reason: "connect" })), - ), - ); - } + const session = yield* legacyOpenShadowBaselineSession( + spawner, + input, + setupOptions, + baseline, + ); yield* legacyCreateShadowTemplateDatabase(session); yield* legacyApplyMigrations( session, diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts b/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts index 11b4349e42..5d32e9fda5 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts @@ -890,9 +890,12 @@ export const legacyResolveMigrationsCatalogRef = Effect.fnUntraced(function* ( timestamp, ); }), - // `legacyProvisionMigrationsShadow` migrates via `legacyMigrateShadowDatabase`, which forces - // `pg_net` on — the key must record that, not the config-following default (no `bypassCache`: - // `db diff` has no `--no-cache` on this path). + // Same forced-on policy {@link legacyGetMigrationsCatalogRef} passes below, for the same + // reason: `legacyProvisionMigrationsShadow` migrates through `legacyMigrateShadowDatabase`, + // whose baseline installs `pg_net` regardless of `config.toml`. Leaving this at the + // config-following default would key the published tar as `webhooks_enabled=false` on a + // webhooks-disabled project even though the snapshotted cluster HAS `pg_net` — poisoning + // every other config-following consumer of that key. { webhooks: "enabled" }, ); }); diff --git a/apps/cli/tests/helpers/legacy-mocks.ts b/apps/cli/tests/helpers/legacy-mocks.ts index e9b097a6df..1489eb6d1c 100644 --- a/apps/cli/tests/helpers/legacy-mocks.ts +++ b/apps/cli/tests/helpers/legacy-mocks.ts @@ -792,6 +792,44 @@ export function useLegacyShadowCacheDisabled(): void { }); } +/** + * The opposite direction of {@link useLegacyShadowCacheDisabled}, scoped to ONE effect rather than + * a whole file: turns the shadow baseline cache back on for `body` and roots it at `homeDir`, + * restoring whatever the host had afterwards. Both variables have to move together — the cache + * reads `SUPABASE_SHADOW_CACHE` for the gate and `SUPABASE_HOME` for the tar directory + * (`legacyShadowBaselineCacheDir`), so pinning only the gate would write ~90MB-shaped tars into + * the developer's real `~/.supabase`. + * + * For a suite that has opted out file-wide, this is how a single cache-focused scenario opts back + * in. Point `homeDir` at a per-test temp dir (see {@link useLegacyTempWorkdir}). + */ +export const withLegacyShadowCacheEnabled = ( + homeDir: string, + body: Effect.Effect, +): Effect.Effect => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = { + cache: process.env["SUPABASE_SHADOW_CACHE"], + home: process.env["SUPABASE_HOME"], + }; + process.env["SUPABASE_SHADOW_CACHE"] = "1"; + process.env["SUPABASE_HOME"] = homeDir; + return previous; + }), + () => body, + (previous) => + Effect.sync(() => { + for (const [name, value] of [ + ["SUPABASE_SHADOW_CACHE", previous.cache], + ["SUPABASE_HOME", previous.home], + ] as const) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + }), + ); + /** * Ambient isolation for tests that construct the REAL `legacyCliConfigLayer` / * `legacyCredentialsLayer` (directly or inside a command runtime layer) against @@ -884,30 +922,19 @@ export const LEGACY_FAKE_SHADOW_CONTAINER_ID = "abc123456789shadow0".padEnd(64, const LEGACY_SHADOW_HEALTHY_STATE = '{"Running":true,"Status":"running","Health":{"Status":"healthy"}}'; -/** - * A real (Docker-valid) "still starting" state — NOT `Effect.never` — so - * {@link legacyWaitForHealthyServices}'s retry loop genuinely retries on its real 1-second - * `Schedule.spaced` backoff instead of hanging on a single probe forever. Mirrors - * `start.integration.test.ts`'s own "never healthy" containers (same rationale: a fiber - * interrupted mid-retry must be observed actually suspended inside the retry loop, not merely - * past the initial `create` call). - */ -const LEGACY_SHADOW_STARTING_STATE = - '{"Running":true,"Status":"running","Health":{"Status":"starting"}}'; - /** * Fakes every `docker`/`podman` subprocess call the native shadow-provisioning path issues * (`legacyBuildLocalDbContainerInputs`'s image-cache check, `legacyCreateShadowDatabase`'s - * network-create + container create/start, `legacyWaitForHealthyServices`'s container + * network-create + container create/start, `legacyWaitForShadowReady`'s container * inspect, and `legacyRemoveShadowDatabase`'s cleanup) — scoped-down port of * `start.integration.test.ts`'s own `mockContainerCliSpawner`, since both callers only ever * create one (shadow) container, never named. * - * `neverHealthy` (default `false`) makes every `container inspect` report `"starting"` instead - * of `"healthy"` — for the interrupt-during-health-wait regression coverage (review: - * 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. + * `container inspect` on the shadow's own id always reports `"healthy"`: no shadow consumer + * gates on the Docker healthcheck any more — they all wait on `legacyWaitForShadowReady`'s direct + * connect probe instead, so a suite that needs a shadow to stay un-ready refuses connects on the + * shadow port through its own `LegacyDbConnection` fake (see `diff.integration.test.ts`'s + * `neverConnectableShadow`) rather than faking a `"starting"` container. * * `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 @@ -920,7 +947,7 @@ const LEGACY_SHADOW_STARTING_STATE = * supabase_db_` probe `legacyIsLocalDbRunning` issues before `--use-pgadmin` * provisions anything — distinguished from the shadow's own `container inspect <64-hex-id>` * health probe by the target id's `supabase_db_` prefix, so both options leave the shadow's - * own health check on its normal (healthy/never-healthy) path. `dbNotRunning` reports the + * own health check on its normal path. `dbNotRunning` reports the * Go/Docker "container doesn't exist" shape (`legacyIsContainerNotFoundMessage`); mutually * exclusive with `dbInspectFailsWith`, which instead reports a daemon-unreachable failure * (`legacyIsDockerDaemonUnreachable`) with the given stderr text — enforced below (a test @@ -928,7 +955,6 @@ const LEGACY_SHADOW_STARTING_STATE = */ export function mockLegacyShadowContainerCliSpawner( opts: { - readonly neverHealthy?: boolean; readonly failCreate?: boolean; readonly failRemove?: boolean; readonly dbNotRunning?: boolean; @@ -943,7 +969,6 @@ export function mockLegacyShadowContainerCliSpawner( "mockLegacyShadowContainerCliSpawner: dbNotRunning and dbInspectFailsWith are mutually exclusive", ); } - const neverHealthy = opts.neverHealthy ?? false; const failCreate = opts.failCreate ?? false; const failRemove = opts.failRemove ?? false; const spawned: Array<{ readonly args: ReadonlyArray }> = []; @@ -1002,7 +1027,7 @@ export function mockLegacyShadowContainerCliSpawner( stdoutLines = [LEGACY_FAKE_SHADOW_CONTAINER_ID]; } } else if (args[0] === "container" && args[1] === "inspect") { - stdoutLines = [neverHealthy ? LEGACY_SHADOW_STARTING_STATE : LEGACY_SHADOW_HEALTHY_STATE]; + stdoutLines = [LEGACY_SHADOW_HEALTHY_STATE]; } else if (args[0] === "rm") { if (failRemove) { exitCode = 1; @@ -1158,9 +1183,9 @@ interface LegacyFakeContainer { * restore's copy-in have no meaning against a stateless spawner, and a failed export is * fail-open (a warning, no tar), so a stateless fake makes cache assertions pass vacuously. * - * `container inspect supabase_db_` reports "no such container" here (the table only - * holds shadows), i.e. the local stack is DOWN — fine for every non-`--use-pgadmin` path, which - * never issues that probe. + * `container inspect supabase_db_` reports running-and-healthy (the table only holds + * shadows, so the local stack is faked UP rather than looked up) — `--use-pgadmin`'s local-stack + * probe is the one caller, and its cache tests need this model for the export round trip. */ export function mockLegacyDockerDaemonCliSpawner( opts: { @@ -1293,16 +1318,26 @@ export function mockLegacyDockerDaemonCliSpawner( marker === undefined ? LEGACY_FAKE_UNSTAMPED_PGDATA_TAR : legacyFakePgDataTar(marker); } } else if (args[0] === "container" && args[1] === "inspect") { - const container = containers.get(args[2] ?? ""); - exitCode = container === undefined ? 1 : 0; - stdout = - container === undefined - ? "" - : JSON.stringify({ - Running: container.running, - Status: container.running ? "running" : "exited", - Health: { Status: "healthy" }, - }); + if ((args[2] ?? "").startsWith("supabase_db_")) { + // The local stack's own container — not a shadow this table tracks. Report it + // running-and-healthy so `--use-pgadmin`'s local-stack probe passes. + stdout = JSON.stringify({ + Running: true, + Status: "running", + Health: { Status: "healthy" }, + }); + } else { + const container = containers.get(args[2] ?? ""); + exitCode = container === undefined ? 1 : 0; + stdout = + container === undefined + ? "" + : JSON.stringify({ + Running: container.running, + Status: container.running ? "running" : "exited", + Health: { Status: "healthy" }, + }); + } } return ChildProcessSpawner.makeHandle({