From b0a3cbc440ed25ecff48df76609ae631070e9b70 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:31:15 +0000 Subject: [PATCH 01/17] feat(cli): remove the legacy edge-runtime pg-delta engine and its escape hatch (CLI-2211) The bundled in-process pg-delta engine (formerly "pg-delta next") is now the only pg-delta implementation. Setting SUPABASE_USE_PG_DELTA_NEXT no longer has any effect; the temporary opt-out shipped in v2.115.0 is retired as announced in its release notes. Removed along with the legacy engine: - the SUPABASE_USE_PG_DELTA_NEXT flag module and the engine selector layer; the LegacyPgDeltaEngine service loses its `implementation` discriminator and every handler branch keyed on it - the edge-runtime pg-delta Deno script templates, the npm-version pin read from supabase/.temp/pgdelta-version, and the PGDELTA_NPM_REGISTRY / PGDELTA_{SOURCE,TARGET}_SSLROOTCERT plumbing (all legacy-engine-only) - the migrations-catalog cache warmup after db push / db reset / start / db start / bootstrap, and the whole catalog snapshot machinery (legacy-pgdelta.cache.ts); the shared migration lister moves to legacy-migration-list.ts - the legacy declarative catalog seam (LegacyDeclarativeSeam.exportCatalog), the pg-delta declarative-apply engine (legacy-pgdelta.apply.ts) and its unreachable shadow-source sub-branch, and db pull's legacy empty-diff debug bundle - the "--strict-coverage has no effect with the legacy pg-delta engine" warning: the flag is now always effective SIDE_EFFECTS.md files and db diff/pull docs are updated to match; tests that exercised the legacy implementation are removed or repointed at the in-process engine. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BQAoY9JF4uiZRvKVkSd9Wy --- .../legacy/commands/bootstrap/SIDE_EFFECTS.md | 52 +- .../legacy/commands/db/diff/SIDE_EFFECTS.md | 58 +- .../legacy/commands/db/diff/diff.handler.ts | 19 +- .../commands/db/diff/diff.integration.test.ts | 62 +- .../legacy/commands/db/pull/SIDE_EFFECTS.md | 54 +- .../src/legacy/commands/db/pull/pull.debug.ts | 211 --- .../commands/db/pull/pull.debug.unit.test.ts | 114 -- .../legacy/commands/db/pull/pull.handler.ts | 103 +- .../commands/db/pull/pull.integration.test.ts | 137 +- .../legacy/commands/db/push/SIDE_EFFECTS.md | 43 +- .../commands/db/push/push.integration.test.ts | 290 +--- .../legacy/commands/db/reset/SIDE_EFFECTS.md | 61 +- .../legacy/commands/db/reset/reset.handler.ts | 59 +- .../db/reset/reset.integration.test.ts | 154 +- .../db/schema/declarative/declarative.flow.ts | 11 +- .../declarative/declarative.flow.unit.test.ts | 22 +- ...eclarative.orchestrate.integration.test.ts | 776 +--------- .../declarative/declarative.orchestrate.ts | 92 +- .../declarative/generate/SIDE_EFFECTS.md | 78 +- .../declarative/generate/generate.handler.ts | 36 +- .../generate/generate.integration.test.ts | 224 ++- .../schema/declarative/sync/SIDE_EFFECTS.md | 78 +- .../schema/declarative/sync/sync.e2e.test.ts | 3 - .../schema/declarative/sync/sync.handler.ts | 42 +- .../declarative/sync/sync.integration.test.ts | 333 +---- .../commands/db/shared/legacy-debug-bundle.ts | 2 +- .../db/shared/legacy-pgdelta-engine.layer.ts | 65 +- .../legacy-pgdelta-engine.layer.unit.test.ts | 120 -- .../legacy-pgdelta-engine.legacy.layer.ts | 265 ---- ...elta-engine.next.layer.integration.test.ts | 22 - .../legacy-pgdelta-engine.next.layer.ts | 1 - .../shared/legacy-pgdelta-engine.service.ts | 11 +- .../legacy-pgdelta.apply.integration.test.ts | 983 ------------ .../db/shared/legacy-pgdelta.apply.ts | 1002 ------------- .../shared/legacy-pgdelta.apply.unit.test.ts | 460 ------ .../shared/legacy-pgdelta.deno-templates.ts | 72 - ...legacy-pgdelta.deno-templates.unit.test.ts | 75 - .../db/shared/legacy-pgdelta.errors.ts | 71 - .../shared/legacy-pgdelta.errors.unit.test.ts | 27 +- .../legacy-pgdelta.seam.integration.test.ts | 74 +- .../db/shared/legacy-pgdelta.seam.layer.ts | 77 +- .../db/shared/legacy-pgdelta.seam.service.ts | 26 - .../db/shared/legacy-shadow-source.ts | 80 +- .../shared/legacy-shadow-source.unit.test.ts | 91 +- .../legacy/commands/db/start/SIDE_EFFECTS.md | 51 +- .../db/start/start.integration.test.ts | 82 +- .../src/legacy/commands/start/SIDE_EFFECTS.md | 11 +- .../commands/start/start.integration.test.ts | 80 +- .../legacy/shared/db-bootstrap/db-setup.ts | 148 +- .../shared/db-bootstrap/db-setup.unit.test.ts | 202 +-- .../db-bootstrap/recreate-local-database.ts | 8 - .../shared/db-bootstrap/shadow-cache.ts | 4 +- .../shared/db-bootstrap/shadow-database.ts | 56 +- .../shared/db-bootstrap/start-database.ts | 4 - .../db-bootstrap/start-local-database.ts | 4 +- .../shared/legacy-db-config.toml-read.ts | 48 +- .../legacy-db-config.toml-read.unit.test.ts | 47 +- .../src/legacy/shared/legacy-db-push-core.ts | 65 +- apps/cli/src/legacy/shared/legacy-glob.ts | 2 +- .../legacy/shared/legacy-migration-history.ts | 2 +- .../legacy/shared/legacy-migration-list.ts | 96 ++ .../legacy/shared/legacy-pgdelta-next-flag.ts | 41 - .../legacy-pgdelta-next-flag.unit.test.ts | 34 - .../src/legacy/shared/legacy-pgdelta-ssl.ts | 115 +- .../shared/legacy-pgdelta-ssl.unit.test.ts | 155 +- .../src/legacy/shared/legacy-pgdelta.cache.ts | 1327 ----------------- .../shared/legacy-pgdelta.cache.unit.test.ts | 857 ----------- .../shared/legacy-pgdelta.integration.test.ts | 351 ----- .../src/legacy/shared/legacy-pgdelta.paths.ts | 5 - apps/cli/src/legacy/shared/legacy-pgdelta.ts | 337 +---- .../legacy/shared/legacy-pgdelta.unit.test.ts | 73 - 71 files changed, 683 insertions(+), 10158 deletions(-) delete mode 100644 apps/cli/src/legacy/commands/db/pull/pull.debug.ts delete mode 100644 apps/cli/src/legacy/commands/db/pull/pull.debug.unit.test.ts delete mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.layer.unit.test.ts delete mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.legacy.layer.ts delete mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.integration.test.ts delete mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.ts delete mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.unit.test.ts delete mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.ts delete mode 100644 apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.unit.test.ts create mode 100644 apps/cli/src/legacy/shared/legacy-migration-list.ts delete mode 100644 apps/cli/src/legacy/shared/legacy-pgdelta-next-flag.ts delete mode 100644 apps/cli/src/legacy/shared/legacy-pgdelta-next-flag.unit.test.ts delete mode 100644 apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts delete mode 100644 apps/cli/src/legacy/shared/legacy-pgdelta.cache.unit.test.ts delete mode 100644 apps/cli/src/legacy/shared/legacy-pgdelta.integration.test.ts diff --git a/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md index 3550e0148d..ac9c7d21a9 100644 --- a/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md @@ -6,26 +6,20 @@ health poll → write `.env` → `db push` → start suggestion. Every step is n including the migration push (`legacyDbPushCore`, shared with the standalone `supabase db push` command — see Notes). -The push step uses the bundled in-process pg-delta engine by default. Set -`SUPABASE_USE_PG_DELTA_NEXT=false` to retain legacy catalog warming; only that -path uses the runtime pg-delta package/edge-runtime settings and catalog cache. - ## Files Read -| Path | Format | When | -| ------------------------------------------------------------------ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `~/.supabase/access-token` | plain text | ensure-login token miss (env unset and keyring unavailable) | -| `/.env.example` | dotenv | optional; merged into the generated `.env` | -| `/supabase/{.env..local,.env.local,.env.,.env}` | dotenv | step I (`legacyLoadProjectEnv`), before config.toml validation and again inside `legacyCheckDbToml`; `` is `SUPABASE_ENV` (default `development`), `.env.local` is skipped when `SUPABASE_ENV=test`; first of the 4 files (in this order) to set a key wins, and this `supabase/` directory tier beats the workdir-root tier below — feeds config.toml `env(VAR)` expansion, the push step's `SUPABASE_YES` auto-confirm default, `[experimental.pgdelta]`'s env gate, `SUPABASE_INTERNAL_IMAGE_REGISTRY`, and `PGDELTA_NPM_REGISTRY` | -| `/{.env..local,.env.local,.env.,.env}` | dotenv | same read as above; lower-precedence fallback tier, only consulted for a key none of the `supabase/` directory's 4 files above already set | -| `/supabase/config.toml` | TOML | native push step (embedded defaults used when absent) | -| `/supabase/.temp/pooler-url` | plain text | native push step's connection resolution, only when the direct `db..:5432` host is unreachable (IPv4-only network) — `legacyResolveLinkedConn` falls back through the saved pooler URL `link.LinkServices` wrote in the earlier link-services step | -| `/supabase/migrations/` | directory | native push step, when `[db.migrations].enabled` (default true) | -| `/supabase/migrations/*.sql` | SQL | native push step, for each pending migration applied | -| seed files from `[db.seed].sql_paths` | SQL | native push step (`--include-seed` is always set; gated on `[db.seed].enabled`) | -| `/supabase/roles.sql` | SQL | native push step (`--include-roles` is always set; existence check + apply) | -| `/supabase/.temp/pgdelta-version` | plain text | loaded for compatibility; used only by the legacy pg-delta opt-out | -| `/supabase/.temp/edge-runtime-version` | plain text | legacy opt-out's catalog warmup image tag, resolved against the bootstrap workdir | +| Path | Format | When | +| ------------------------------------------------------------------ | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text | ensure-login token miss (env unset and keyring unavailable) | +| `/.env.example` | dotenv | optional; merged into the generated `.env` | +| `/supabase/{.env..local,.env.local,.env.,.env}` | dotenv | step I (`legacyLoadProjectEnv`), before config.toml validation and again inside `legacyCheckDbToml`; `` is `SUPABASE_ENV` (default `development`), `.env.local` is skipped when `SUPABASE_ENV=test`; first of the 4 files (in this order) to set a key wins, and this `supabase/` directory tier beats the workdir-root tier below — feeds config.toml `env(VAR)` expansion and the push step's `SUPABASE_YES` auto-confirm default | +| `/{.env..local,.env.local,.env.,.env}` | dotenv | same read as above; lower-precedence fallback tier, only consulted for a key none of the `supabase/` directory's 4 files above already set | +| `/supabase/config.toml` | TOML | native push step (embedded defaults used when absent) | +| `/supabase/.temp/pooler-url` | plain text | native push step's connection resolution, only when the direct `db..:5432` host is unreachable (IPv4-only network) — `legacyResolveLinkedConn` falls back through the saved pooler URL `link.LinkServices` wrote in the earlier link-services step | +| `/supabase/migrations/` | directory | native push step, when `[db.migrations].enabled` (default true) | +| `/supabase/migrations/*.sql` | SQL | native push step, for each pending migration applied | +| seed files from `[db.seed].sql_paths` | SQL | native push step (`--include-seed` is always set; gated on `[db.seed].enabled`) | +| `/supabase/roles.sql` | SQL | native push step (`--include-roles` is always set; existence check + apply) | ## Files Written @@ -36,8 +30,6 @@ path uses the runtime pg-delta package/edge-runtime settings and catalog cache. | `/supabase/.temp/project-ref` | plain text | always (mandatory; fails the command on write error) | | `/supabase/.temp/{pooler-url,rest-version,gotrue-version,storage-version,storage-migration}` | plain text | best-effort, from `link.LinkServices` | | `/.env` | dotenv | best-effort (write failure prints a warning and continues) | -| `/supabase/.temp/pgdelta/catalog--migrations--.json` | JSON | legacy pg-delta opt-out, best-effort after migration apply (write failure only warns) | -| `/supabase/.temp/pgdelta/pgdelta-target-ca.crt` | PEM | legacy pg-delta opt-out, when the target requires SSL | | `/supabase/.temp/linked-project.json` | JSON | PersistentPostRun linked-project cache (`Effect.ensuring`); resolves against the bootstrap workdir (the prompted/`--workdir`/env target), not `cliSettings.workdir` | | `~/.supabase/telemetry.json` | JSON | PersistentPostRun telemetry flush (`Effect.ensuring`) | @@ -69,18 +61,14 @@ neither branch ever reaches the temp-login-role/Management-API path a passwordle ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------- | --------- | -| `SUPABASE_WORKDIR` | target dir (`--workdir` flag → env → prompt → cwd) | no | -| `SUPABASE_DB_PASSWORD` | DB password (`-p` flag → env → prompt/generate) | no | -| `GITHUB_TOKEN` | raise the GitHub API rate limit for template fetch | no | -| `SUPABASE_ACCESS_TOKEN` | auth bypass for ensure-login | no | -| `SUPABASE_PROFILE` | profile name/path (env → `~/.supabase/profile` → `supabase`) | no | -| `SUPABASE_YES` | auto-confirm the native push step's prompts, read project-`.env`-aware like the standalone `db push` | no | -| `SUPABASE_EXPERIMENTAL_PG_DELTA` | enables the legacy opt-out's catalog cache when `[experimental.pgdelta].enabled` is unset, read project-`.env`-aware | no | -| `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` for legacy catalog warming, read project-`.env`-aware | no | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | legacy opt-out's edge-runtime image registry, read project-`.env`-aware | no | -| `PGDELTA_NPM_REGISTRY` | legacy opt-out's edge-runtime npm registry, read project-`.env`-aware | no | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------------------------------------------------------- | --------- | +| `SUPABASE_WORKDIR` | target dir (`--workdir` flag → env → prompt → cwd) | no | +| `SUPABASE_DB_PASSWORD` | DB password (`-p` flag → env → prompt/generate) | no | +| `GITHUB_TOKEN` | raise the GitHub API rate limit for template fetch | no | +| `SUPABASE_ACCESS_TOKEN` | auth bypass for ensure-login | no | +| `SUPABASE_PROFILE` | profile name/path (env → `~/.supabase/profile` → `supabase`) | no | +| `SUPABASE_YES` | auto-confirm the native push step's prompts, read project-`.env`-aware like the standalone `db push` | no | ## Exit Codes 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 8b57af8184..3db3f5333c 100644 --- a/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md @@ -8,12 +8,10 @@ edge-runtime involved). `--use-pg-schema` is the CLI's sole remaining Go delegation on this command — a documented keep-in-Go exception (CLI-1960), not a pending port. -Set `SUPABASE_USE_PG_DELTA_NEXT=false` to use the legacy edge-runtime pg-delta -implementation and its runtime package/catalog cache. The bundled engine has no -automatic fallback; coverage gaps warn, while `--strict-coverage` makes them fatal, -and `PGDELTA_DEBUG` writes diagnostic JSON under -`supabase/.temp/pgdelta/v2/debug//`. Its SQL and transaction-aware file -splits may differ from legacy output; applicable, convergent SQL is the contract. +Pg-delta runs in-process. Coverage gaps warn, while `--strict-coverage` makes +them fatal, and `PGDELTA_DEBUG` writes diagnostic JSON under +`supabase/.temp/pgdelta/v2/debug//`. The engine may emit transaction-aware +file splits; applicable, convergent SQL is the contract. The bundled formatter defaults to lowercase SQL at width 180; config overrides it, and JSON `null` disables formatting without disabling safe compaction. @@ -22,46 +20,38 @@ it, and JSON `null` disables formatting without disabling safe compaction. | Path | Format | When | | --------------------------------------------------------------------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `/supabase/config.toml` | TOML | always (db port/password, `[experimental.pgdelta]`, deno_version) | -| `/supabase/.env`, `.env.local`, project-root/`SUPABASE_ENV`-selected dotenv file | dotenv | shadow provisioning (all native targets, and the explicit `--from/--to migrations` cache miss) | +| `/supabase/.env`, `.env.local`, project-root/`SUPABASE_ENV`-selected dotenv file | dotenv | shadow provisioning (all native targets, including the explicit `--from/--to migrations` shadow) | | `api.tls.cert_path` / `api.tls.key_path` (under `/supabase/`) | PEM | shadow provisioning, when `api.enabled && api.tls.enabled` | | `/supabase/migrations/*.sql` | SQL | shadow provisioning (applied to the shadow source) — `--use-pgadmin` too, via the SAME `legacyMigrateShadowDatabase` | | `/supabase/roles.sql` | SQL | shadow provisioning, PG14 and PG15 alike (unlike `db reset`'s PG15-only local path); also hashed into the shadow-baseline cache key on every cache-eligible acquire, warm hits included (where no baseline is applied at all); missing file tolerated | | `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | warm shadow-cache hit — the matching snapshot is streamed into the fresh shadow; every cache-eligible acquire (warm hit and successful cold export) also enumerates and `stat`s every `shadow-baseline-*.tar` for LRU keep-3 + 2-day mtime TTL and may delete other keys (`SUPABASE_HOME` overrides the `~/.supabase` root) | | `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | abandoned-partial sweep on every cache-eligible acquire (warm hit and cold export) — enumerated and `stat`ed, and removed when older than 5 minutes (a crashed/SIGKILLed earlier export's leftover) | -| `[db.migrations].schema_paths` globs / `/supabase/database/**` / `/supabase/schemas/**` | SQL | legacy engines only, for the local-target declarative-schema fallback; pg-delta next always compares the migrations baseline directly to the live target | +| `[db.migrations].schema_paths` globs / `/supabase/database/**` / `/supabase/schemas/**` | SQL | migra engine only, for the local-target declarative-schema fallback; pg-delta always compares the migrations baseline directly to the live target | | `~/.supabase/access-token` | plain text | `--linked` / `--db-url` with no `SUPABASE_ACCESS_TOKEN` | | `/supabase/.temp/project-ref` | plain text | `--linked` ref resolution — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | -| `/supabase/.temp/{pgdelta-version,edge-runtime-version}` | plain text | legacy pg-delta opt-out only | -| `/supabase/.temp/pgdelta/*.json` | JSON | legacy opt-out's explicit `--from/--to migrations` catalog cache | ## 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 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-3 + 2-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 5 minutes | -| `~/.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/v2/debug//*.json` | JSON | bundled engine with `PGDELTA_DEBUG` | +| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | cache-enabled COLD shadow provision creates the current key's snapshot (native diff targets + the explicit `--from/--to migrations` shadow; 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-3 + 2-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 5 minutes | +| `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | +| `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | ## Docker -- Edge-runtime container (migra, or pg-delta under the legacy opt-out; also runs the legacy - declarative apply script and the pg-delta - catalog-export script for explicit `--from/--to migrations` on a cache miss — - CLI-1959, native, no longer the hidden Go `__catalog` seam). +- Edge-runtime container (migra engine only). - Shadow Postgres container — provisioned and torn down natively (`legacyPrepareShadowSource` in `legacy/commands/db/shared/legacy-shadow-source.ts`, over the lower-level primitives in `legacy/shared/db-bootstrap/shadow-database.ts`), no longer via a Go seam. Explicit - `--from/--to migrations` reuses the SAME native primitives on a cache miss - (`legacyResolveMigrationsCatalogRef` -> `exportViaShadowCatalog`, `legacy-pgdelta.cache.ts`), - 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 + `--from/--to migrations` provisions its migrations shadow through the pg-delta shadow layer + (`legacy-pgdelta-next-shadow.layer.ts`), which builds on the same shadow-baseline cache + primitives (`legacyAcquireShadowDatabase`), with no declarative-schema-override branch. + `--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`. @@ -100,8 +90,6 @@ of this command's own target resolve, ahead of the differ container. | `SUPABASE_SHADOW_CACHE` | shadow baseline cache; opt-in (`1`/`true`); the shadow's post-baseline PGDATA is snapshotted to a tar and restored into the next run's fresh container (see Notes) | no | | `SUPABASE_EXPERIMENTAL_PG_DELTA` | force pg-delta engine | no | | `PGDELTA_DEBUG` | pg-delta debug capture | no | -| `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` for legacy edge-runtime pg-delta | no | -| `PGDELTA_NPM_REGISTRY` | legacy opt-out's scoped npm registry | no | | `SUPABASE_SSL_DEBUG` | migra SSL debug logging | no | | `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 | @@ -222,12 +210,6 @@ transaction metadata. per-unit migration files for the CLI apply paths. - Normal mode always compares the migrations shadow to the selected live database; declarative files and `schema_paths` do not replace that baseline. -- Under the legacy opt-out, the explicit `migrations` target resolves natively (CLI-1959): a bare - migrations-content hash cache lookup (`/supabase/.temp/pgdelta/catalog-local-migrations--.json`, - shared with `db push`'s post-apply cache write), and on a miss, a natively-provisioned - shadow database (CLI-1956 — `legacyCreateShadowDatabase`/`legacyPrepareShadowSource`, - no longer the `db __shadow` seam) plus a native pg-delta catalog export. No hidden Go - `db schema declarative __catalog` subprocess runs for this path any more. ### Shadow baseline cache (`SUPABASE_SHADOW_CACHE`, default OFF) 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 f1c7a3bd51..b532bb8fc8 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -338,7 +338,6 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy const explicitCtx: LegacyPgDeltaContext = { projectId: legacyResolvePgDeltaProjectId(cliSettings.projectId, cfg, cliSettings.workdir), cwd: cliSettings.workdir, - npmVersion: Option.getOrUndefined(cfg.pgDelta.npmVersion), denoVersion: cfg.denoVersion, projectEnv: cfg.projectEnv, }; @@ -546,7 +545,6 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy // that helper's own doc comment. projectId: legacyResolvePgDeltaProjectId(cliSettings.projectId, cfg, cliSettings.workdir), cwd: cliSettings.workdir, - npmVersion: Option.getOrUndefined(cfg.pgDelta.npmVersion), denoVersion: cfg.denoVersion, projectEnv: cfg.projectEnv, }; @@ -565,9 +563,8 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy usePgSchema, pgDeltaDefault, }); - // Only the next engine ignores schema_paths when building its migrations baseline. - const usesPgDeltaNext = useDelta && pgDelta.implementation === "next"; - if (usesPgDeltaNext && cfg.schemaPaths !== undefined && cfg.schemaPaths.length > 0) { + // pg-delta ignores schema_paths when building its migrations baseline. + if (useDelta && cfg.schemaPaths !== undefined && cfg.schemaPaths.length > 0) { yield* output.raw(legacySchemaPathsTransitionWarning, "stderr"); } @@ -677,11 +674,10 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy diffResult = { sql, files: undefined }; } else { yield* output.raw("Creating shadow database...\n", "stderr"); - const migrationMode: "legacy" | "pgdelta-next" = usesPgDeltaNext ? "pgdelta-next" : "legacy"; + const migrationMode: "legacy" | "pgdelta-next" = useDelta ? "pgdelta-next" : "legacy"; const shadowInput = { ...(yield* resolveShadowRunInput()), targetLocal: resolved.isLocal, - usePgDelta: useDelta, migrationMode, // `cfg.schemaPathPatterns`, NOT `localInputs.context.config.db.migrations.schema_paths`: // the latter is the raw `@supabase/config` field, which never applies @@ -689,7 +685,6 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy // resolves that env override. schemaPaths: cfg.schemaPathPatterns, pgDelta: cfg.pgDelta, - ctx, }; // `legacyWithShadowDatabase` (`shadow-cache.ts`) owns the interrupt-safe lifecycle and the // cache seam — a plain create/remove pair when `SUPABASE_SHADOW_CACHE` is unset. The key's @@ -772,13 +767,7 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy : legacyFindDropStatements(out); const writtenFiles: Array = []; let ignoredDeclarativeAdvisory: ReturnType | undefined; - if ( - out.length >= 2 && - useDelta && - pgDelta.implementation === "next" && - Option.isSome(flags.file) && - flags.file.value.length > 0 - ) { + if (out.length >= 2 && useDelta && Option.isSome(flags.file) && flags.file.value.length > 0) { // This is an informational, best-effort probe only. Declarative files are // intentionally not inputs to normal db diff, so an unreadable or changing // directory must never turn a previously successful diff into a failure. 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 97ee7735d2..ab617b77c8 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 @@ -80,7 +80,6 @@ interface SetupOpts { // Exact suffixes returned by the next renderer, parallel to `diffFiles`. readonly diffSuffixes?: ReadonlyArray; readonly hazards?: LegacyPgDeltaHazardReport; - readonly pgDeltaImplementation?: "legacy" | "next"; readonly oom?: boolean; // edge-runtime OOMs; the bash fallback returns `diffSql` readonly delegateStdout?: string; // stdout returned by a captured Go-delegate run // When set, the PGDELTA_DEBUG shadow-catalog export fails with this message @@ -252,9 +251,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { const pgDeltaEngine = Layer.succeed( LegacyPgDeltaEngine, LegacyPgDeltaEngine.of({ - // The handler must route through this strategy even when the selected - // implementation is legacy; the strategy owns edge runtime and shadows. - implementation: opts.pgDeltaImplementation ?? "legacy", diffExplicit: (input) => Effect.sync(() => { explicitDiffCalls.push(input); @@ -657,7 +653,6 @@ describe("legacy db diff", () => { "create table ignored ();\n", ); const s = setup(tmp.current, { - pgDeltaImplementation: "next", diffSql: "create table result ();\n", }); return Effect.gen(function* () { @@ -682,10 +677,10 @@ describe("legacy db diff", () => { }).pipe(Effect.provide(s.layer)); }); - // The transition warning is only true for the bundled next engine. Every other - // engine still routes a local target with declarative files through the - // declared-schema `contrib_regression` override, so schema_paths DOES still shape - // their output and claiming otherwise would be a lie. + // The transition warning is only true for pg-delta. Migra still routes a local + // target with declarative files through the declared-schema `contrib_regression` + // override, so schema_paths DOES still shape its output and claiming otherwise + // would be a lie. const writeSchemaPathsConfig = (pgDeltaEnabled: boolean) => { mkdirSync(join(tmp.current, "supabase", "database"), { recursive: true }); writeFileSync( @@ -702,14 +697,13 @@ describe("legacy db diff", () => { writeFileSync(join(tmp.current, "supabase", "configured.sql"), "create table configured ();\n"); }; - it.effect("legacy pg-delta local diff does not print the schema_paths transition warning", () => { - writeSchemaPathsConfig(true); + it.effect("migra local diff does not print the schema_paths transition warning", () => { + writeSchemaPathsConfig(false); const s = setup(tmp.current, { - pgDeltaImplementation: "legacy", diffSql: "create table result ();\n", }); return Effect.gen(function* () { - yield* legacyDbDiff(flags({ usePgDelta: Option.some(true) })); + yield* legacyDbDiff(flags()); expect(stderr(s.out)).not.toContain("schema_paths no longer changes the migrations baseline"); }).pipe(Effect.provide(s.layer)); }); @@ -1444,7 +1438,6 @@ describe("legacy db diff", () => { "create table declarative_only ();\n", ); const s = setup(tmp.current, { - pgDeltaImplementation: "next", diffSql: "create table live_only ();\n", }); return Effect.gen(function* () { @@ -1470,7 +1463,6 @@ describe("legacy db diff", () => { ); const s = setup(tmp.current, { format: "json", - pgDeltaImplementation: "next", diffSql: "create table dogfood_note ();\n", }); return Effect.gen(function* () { @@ -1511,7 +1503,6 @@ describe("legacy db diff", () => { writeFileSync(join(tmp.current, "supabase", "not-a-directory.sql"), "select 1;\n"); const s = setup(tmp.current, { format: "json", - pgDeltaImplementation: "next", diffSql: "create table dogfood_note ();\n", }); return Effect.gen(function* () { @@ -1917,7 +1908,6 @@ describe("legacy db diff", () => { it.effect("warns on semantic data-loss hazards without a DROP statement", () => { const sql = "ALTER TABLE public.accounts ALTER COLUMN email TYPE text;"; const s = setup(tmp.current, { - pgDeltaImplementation: "next", diffSql: sql, hazards: { actions: [{ actionIndex: 0, kinds: ["data_loss"] }], @@ -2619,10 +2609,9 @@ describe("legacy db diff", () => { * Runs `db diff` with the shadow baseline cache on and artifacts under the workdir, * against the stateful Docker model the export/restore round trip needs. */ - const runCached = (implementation: "legacy" | "next") => { + const runCached = (engine: "migra" | "pg-delta") => { const s = setup(tmp.current, { statefulDocker: true, - pgDeltaImplementation: implementation, diffSql: "create table t ();\n", }); return legacyWithEnv( @@ -2631,36 +2620,43 @@ describe("legacy db diff", () => { legacyWithEnv( "SUPABASE_SHADOW_CACHE", "1", - legacyDbDiff(flags({ usePgDelta: Option.some(true) })).pipe(Effect.provide(s.layer)), + legacyDbDiff( + flags( + engine === "pg-delta" + ? { usePgDelta: Option.some(true) } + : { useMigra: Option.some(true) }, + ), + ).pipe(Effect.provide(s.layer)), ), ).pipe(Effect.as(s)); }; // Regression: both migrate paths used to pass a hardcoded `{ webhooks: "enabled" }`, so the - // legacy run's forced-`pg_net` baseline and the next run's config-following baseline keyed + // migra run's forced-`pg_net` baseline and the pg-delta run's config-following baseline keyed // to the SAME tar and silently restored each other's cluster. The handler now forks the // policy on `migrationMode`; `shadow-cache.integration.test.ts` covers the cache's half of // the contract, this covers `db diff`'s call site. - it.live("a legacy-engine baseline is never restored into a pg-delta-next run", () => { + it.live("a migra-engine baseline is never restored into a pg-delta run", () => { mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync( join(tmp.current, "supabase", "config.toml"), "[experimental.pgdelta]\nenabled = true\n", ); return Effect.gen(function* () { - // Legacy migrate forces `pg_net` on regardless of config, and publishes that baseline. - const legacyRun = yield* runCached("legacy"); - expect(legacyRun.dockerDaemon?.stepCalls("cp-out")).toHaveLength(1); - const legacyTars = publishedTars(); - expect(legacyTars).toHaveLength(1); - - // pg-delta next follows the config (webhooks are off here), so it must cold-provision + // Migra's migrate path forces `pg_net` on regardless of config, and publishes + // that baseline. + const migraRun = yield* runCached("migra"); + expect(migraRun.dockerDaemon?.stepCalls("cp-out")).toHaveLength(1); + const migraTars = publishedTars(); + expect(migraTars).toHaveLength(1); + + // pg-delta follows the config (webhooks are off here), so it must cold-provision // and publish its OWN baseline rather than restore the forced-on one above. - const nextRun = yield* runCached("next"); - expect(nextRun.dockerDaemon?.stepCalls("cp-in")).toHaveLength(0); - expect(nextRun.dockerDaemon?.stepCalls("cp-out")).toHaveLength(1); + const pgDeltaRun = yield* runCached("pg-delta"); + expect(pgDeltaRun.dockerDaemon?.stepCalls("cp-in")).toHaveLength(0); + expect(pgDeltaRun.dockerDaemon?.stepCalls("cp-out")).toHaveLength(1); expect(publishedTars()).toHaveLength(2); - expect(publishedTars()).toEqual(expect.arrayContaining(legacyTars)); + expect(publishedTars()).toEqual(expect.arrayContaining(migraTars)); }); }); }); diff --git a/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md index 3c13990982..0609afcb7e 100644 --- a/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md @@ -24,12 +24,10 @@ Go checks `usePgDelta` before `EXPERIMENTAL`, so that combination never delegates and just runs the declarative export normally (see the Notes/Delegation section below). -Pg-delta runs in-process by default. Set `SUPABASE_USE_PG_DELTA_NEXT=false` for -the legacy edge-runtime implementation and runtime package/catalog cache; there -is no automatic fallback. Coverage gaps warn; `--strict-coverage` makes them +Pg-delta runs in-process. Coverage gaps warn; `--strict-coverage` makes them fatal, while `PGDELTA_DEBUG` writes diagnostic JSON under -`supabase/.temp/pgdelta/v2/debug//`. Bundled output may use different SQL -and transaction-aware file splits but must apply and converge. Its formatter +`supabase/.temp/pgdelta/v2/debug//`. The engine may emit transaction-aware +file splits; applicable, convergent SQL is the contract. Its formatter defaults to lowercase SQL at width 180; config overrides it, and JSON `null` disables formatting without disabling safe compaction. @@ -38,41 +36,37 @@ disables formatting without disabling safe compaction. | Path | Format | When | | ----------------------------------------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `/supabase/config.toml` | TOML | always (db port/password, `[experimental.pgdelta]`) | -| `/supabase/.env`, `.env.local`, project-root/`SUPABASE_ENV`-selected dotenv file | dotenv | shadow provisioning (`--declarative` and migration-style pull; not the delegated `--experimental` structured-dump path) | +| `/supabase/.env`, `.env.local`, project-root/`SUPABASE_ENV`-selected dotenv file | dotenv | migration-style pull's shadow provisioning, and `--declarative`'s config/env resolution (not the delegated `--experimental` structured-dump path) | | `api.tls.cert_path` / `api.tls.key_path` (under `/supabase/`) | PEM | shadow provisioning, when `api.enabled && api.tls.enabled` | | `/supabase/migrations/*.sql` | SQL | history reconciliation + shadow provisioning | -| `/supabase/roles.sql` | SQL | migration-style pull only (`--declarative`'s bare shadow skips `SetupDatabase`); also hashed into the shadow-baseline cache key on every cache-eligible acquire, warm hits included (where no baseline is applied at all); missing file tolerated | +| `/supabase/roles.sql` | SQL | migration-style pull only (`--declarative` provisions no shadow); also hashed into the shadow-baseline cache key on every cache-eligible acquire, warm hits included (where no baseline is applied at all); missing file tolerated | | `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | warm shadow-cache hit (migration-style pull) — the matching snapshot is streamed into the fresh shadow; every cache-eligible acquire (warm hit and successful cold export) also enumerates and `stat`s every `shadow-baseline-*.tar` for LRU keep-3 + 2-day mtime TTL and may delete other keys (`SUPABASE_HOME` overrides the `~/.supabase` root) | | `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | abandoned-partial sweep on every cache-eligible acquire (warm hit and cold export) — enumerated and `stat`ed, and removed when older than 5 minutes (a crashed/SIGKILLed earlier export's leftover) | | `~/.supabase/access-token` | plain text | linked target with no `SUPABASE_ACCESS_TOKEN` | | `/supabase/.temp/project-ref` | plain text | linked ref resolution — skipped when `--project-ref` (or `SUPABASE_PROJECT_ID`) is set | -| `/supabase/.temp/{pgdelta-version,edge-runtime-version}` | plain text | legacy pg-delta opt-out only | -| `/supabase/.temp/pgdelta/*.json` | JSON | legacy opt-out's catalog snapshots | ## Files Written -| Path | Format | When | -| --------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/migrations/_.sql` | SQL | migration-style pull (non-empty diff, or the initial-migra `pg_dump` seed) | -| `/supabase/schemas/**` | SQL | `--declarative` | -| `/supabase/schemas/.pgdelta-export.json` | JSON | bundled `--declarative` export metadata | -| `/supabase/.temp/pgdelta/catalog-*.json` | JSON | legacy pg-delta opt-out catalog snapshots | -| `/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/schemas/**`, `/supabase/cluster/**` | SQL | `--experimental` structured dump (delegated to Go; both dirs are `RemoveAll`'d then rewritten by `format.WriteStructuredSchemas`, not just written to) | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | cache-enabled COLD shadow provision creates the current key's snapshot, migration-style pull only (never `--declarative`'s bare shadow or the delegated `--experimental` path); a warm hit `touch`es its mtime (LRU); every cache-eligible acquire may delete other keys under LRU keep-3 + 2-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 5 minutes | -| `~/.supabase//linked-project.json` | JSON | linked (post-run cache) | -| `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | +| Path | Format | When | +| --------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/migrations/_.sql` | SQL | migration-style pull (non-empty diff, or the initial-migra `pg_dump` seed) | +| `/supabase/schemas/**` | SQL | `--declarative` | +| `/supabase/schemas/.pgdelta-export.json` | JSON | bundled `--declarative` export metadata | +| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | bundled engine with `PGDELTA_DEBUG` | +| `/supabase/schemas/**`, `/supabase/cluster/**` | SQL | `--experimental` structured dump (delegated to Go; both dirs are `RemoveAll`'d then rewritten by `format.WriteStructuredSchemas`, not just written to) | +| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | cache-enabled COLD shadow provision creates the current key's snapshot, migration-style pull only (never `--declarative`, which provisions no shadow, or the delegated `--experimental` path); a warm hit `touch`es its mtime (LRU); every cache-eligible acquire may delete other keys under LRU keep-3 + 2-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 5 minutes | +| `~/.supabase//linked-project.json` | JSON | linked (post-run cache) | +| `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | ## Docker -- Edge-runtime container (migra, or pg-delta under the legacy opt-out). +- Edge-runtime container (migra engine only). - Shadow Postgres container — provisioned and torn down natively (`legacyPrepareShadowSource` in - `legacy/commands/db/shared/legacy-shadow-source.ts` / `legacyPrepareRawShadow` in - `legacy/shared/db-bootstrap/shadow-database.ts`, which also owns the lower-level primitives - both build on), no longer via a Go seam. Torn down with `docker rm -f -v` on every run, - cache or no cache — see the shadow baseline cache section below. + `legacy/commands/db/shared/legacy-shadow-source.ts`, over the lower-level primitives in + `legacy/shared/db-bootstrap/shadow-database.ts`), no longer via a Go seam. Torn down with + `docker rm -f -v` on every run, cache or no cache — see the shadow baseline cache section + below. Migration-style pulls only; `--declarative` provisions no shadow. - `supabase/migra` container — the migra OOM bash fallback only. - `pg_dump` container — the initial-migra pull's native remote-schema dump (`legacyStreamPgDump`, shared with `db dump`). @@ -97,8 +91,8 @@ Session-semantics caveat on the cached paths: migrations run on a session opened platform baseline, so role-level defaults installed by `supabase/roles.sql` (`ALTER ROLE … SET …`) apply to migration execution; with the cache off, the single-session flow runs migrations before those defaults take effect. Each pooler-retry attempt acquires/releases its -own shadow (a warm hit restores the same tar each time); `--declarative`'s bare shadow runs no -baseline, so it is never cached. +own shadow (a warm hit restores the same tar each time); `--declarative` provisions no shadow +at all, so nothing is cached for it. ## API Routes / DB @@ -124,8 +118,6 @@ baseline, so it is never cached. | `SUPABASE_SHADOW_CACHE` | shadow baseline cache; opt-in (`1`/`true`); the shadow's post-baseline PGDATA is snapshotted to a tar and restored into the next run's fresh container (see Notes) | no | | `SUPABASE_EXPERIMENTAL_PG_DELTA` | force pg-delta diff engine | no | | `SUPABASE_EXPERIMENTAL` | selects the deprecated structured-dump branch (still delegates to Go, see below) | no | -| `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` for legacy edge-runtime pg-delta | no | -| `PGDELTA_NPM_REGISTRY` | legacy opt-out's npm registry | no | ## Exit Codes diff --git a/apps/cli/src/legacy/commands/db/pull/pull.debug.ts b/apps/cli/src/legacy/commands/db/pull/pull.debug.ts deleted file mode 100644 index 558ae84e6e..0000000000 --- a/apps/cli/src/legacy/commands/db/pull/pull.debug.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { type FileSystem, Effect, type Path } from "effect"; - -import { Output } from "../../../../shared/output/output.service.ts"; -import { legacyBold } from "../../../shared/legacy-colors.ts"; -import { - type LegacyDebugBundle, - legacyDebugBundleMessage, - legacySaveDebugBundle, -} from "../shared/legacy-debug-bundle.ts"; -import { legacyPgDeltaTempPath } from "../../../shared/legacy-pgdelta.paths.ts"; -import { - type LegacyPgDeltaContext, - legacyExportCatalogPgDelta, -} from "../../../shared/legacy-pgdelta.ts"; - -// Established output contract. -const ERR_IN_SYNC = "No schema changes found"; - -const byteLength = (value: string): number => new TextEncoder().encode(value).length; - -/** - * Replaces the password (keeping the username) with `xxxxx`; an empty username - * becomes `redacted`; a URL with no userinfo is unchanged; a parse failure - * returns the literal ``. - */ -export function legacyRedactPostgresURL(raw: string): string { - let parsed: URL; - try { - parsed = new URL(raw); - } catch { - return ""; - } - if (parsed.username !== "" || parsed.password !== "") { - if (parsed.username === "") parsed.username = "redacted"; - parsed.password = "xxxxx"; - } - return parsed.toString(); -} - -/** A single-line, password-redacted connection summary. */ -export function legacyFormatConnectionInfo( - conn: { - readonly host: string; - readonly port: number; - readonly user: string; - readonly database: string; - }, - url: string, -): string { - return `host=${conn.host} port=${conn.port} user=${conn.user} database=${conn.database} url=${legacyRedactPostgresURL(url)}`; -} - -/** Object counts extracted from a pg-delta catalog JSON blob. */ -export interface LegacyCatalogSummary { - readonly totalObjects: number; - readonly bySchema: Record; -} - -/** - * Best-effort counts catalog objects grouped by schema name: a node counts when - * it has a `schema` string or a `schema.name`, and children are always recursed - * (so nested catalogs can contribute multiple counts). - */ -export function legacySummarizeCatalogJson(catalogJson: string): LegacyCatalogSummary { - const bySchema: Record = {}; - let total = 0; - if (catalogJson.trim().length === 0) return { totalObjects: 0, bySchema }; - let root: unknown; - try { - root = JSON.parse(catalogJson); - } catch { - return { totalObjects: 0, bySchema }; - } - const schemaName = (node: Record): string | undefined => { - const schema = node["schema"]; - if (typeof schema === "string" && schema.length > 0) return schema; - if (typeof schema === "object" && schema !== null && !Array.isArray(schema)) { - const name = (schema as Record)["name"]; - if (typeof name === "string" && name.length > 0) return name; - } - return undefined; - }; - const walk = (node: unknown): void => { - if (Array.isArray(node)) { - for (const child of node) walk(child); - return; - } - if (typeof node === "object" && node !== null) { - const record = node as Record; - const schema = schemaName(record); - if (schema !== undefined) { - total += 1; - bySchema[schema] = (bySchema[schema] ?? 0) + 1; - } - for (const child of Object.values(record)) walk(child); - } - }; - walk(root); - return { totalObjects: total, bySchema }; -} - -/** Formats a catalog summary line. */ -export function legacyFormatCatalogSummary(label: string, summary: LegacyCatalogSummary): string { - if (summary.totalObjects === 0) return `${label} catalog: no objects detected`; - const parts = Object.entries(summary.bySchema).map(([schema, count]) => `${schema}=${count}`); - return `${label} catalog: ${summary.totalObjects} objects (${parts.join(", ")})`; -} - -/** Formats a byte size as `%.1f MB` / `%.1f KB` / `%d B`. */ -export function legacyFormatByteSize(size: number): string { - if (size >= 1 << 20) return `${(size / (1 << 20)).toFixed(1)} MB`; - if (size >= 1 << 10) return `${(size / (1 << 10)).toFixed(1)} KB`; - return `${size} B`; -} - -/** - * Builds the stderr summary block printed before the issue-report message. - */ -export function legacyFormatEmptyPgDeltaPullSummary( - debugDir: string, - sourceCatalog: string, - targetCatalog: string, -): string { - const lines = [ - "pg-delta returned 0 statements.", - `Debug bundle saved to ${legacyBold(debugDir)}`, - ]; - if (sourceCatalog.trim().length > 0) { - lines.push( - `${legacyFormatCatalogSummary("Shadow", legacySummarizeCatalogJson(sourceCatalog))} (${legacyFormatByteSize(byteLength(sourceCatalog))})`, - ); - } - if (targetCatalog.trim().length > 0) { - lines.push( - `${legacyFormatCatalogSummary("Remote", legacySummarizeCatalogJson(targetCatalog))} (${legacyFormatByteSize(byteLength(targetCatalog))})`, - ); - } else { - lines.push( - "Remote catalog: export failed or empty (inspect connection.txt and pgdelta-stderr.txt)", - ); - } - return `${lines.join("\n")}\n`; -} - -/** - * Saves the pg-delta empty-diff debug bundle and returns its directory: export - * the remote/target catalog (warn and continue on failure), write the bundle - * (source/target catalog, stderr, connection.txt, error.txt), then print the - * summary + issue-report message. The shadow source catalog and pg-delta - * stderr are captured during the diff run and passed in. - */ -export const legacySaveEmptyPgDeltaPullDebug = Effect.fnUntraced(function* (params: { - readonly ctx: LegacyPgDeltaContext; - readonly conn: { - readonly host: string; - readonly port: number; - readonly user: string; - readonly database: string; - }; - readonly targetUrl: string; - readonly sourceCatalog: string | undefined; - readonly pgDeltaStderr: string | undefined; - readonly id: string; - readonly fs: FileSystem.FileSystem; - readonly path: Path.Path; - readonly workdir: string; -}) { - const output = yield* Output; - // Export the remote catalog at debug time (connects to the remote directly - // here, not the shadow); a failure only warns — the bundle is still written - // with the catalogs/stderr captured during the diff. - const targetCatalog = yield* legacyExportCatalogPgDelta(params.ctx, { - targetRef: params.targetUrl, - role: "postgres", - }).pipe( - Effect.catch((error) => - output - .raw(`Warning: failed to export remote pg-delta catalog: ${error.message}\n`, "stderr") - .pipe(Effect.as("")), - ), - ); - - const bundle: LegacyDebugBundle = { - id: params.id, - connectionInfo: legacyFormatConnectionInfo(params.conn, params.targetUrl), - error: ERR_IN_SYNC, - ...(params.sourceCatalog !== undefined && params.sourceCatalog.length > 0 - ? { sourceCatalog: params.sourceCatalog } - : {}), - ...(targetCatalog.length > 0 ? { targetCatalog } : {}), - ...(params.pgDeltaStderr !== undefined && params.pgDeltaStderr.length > 0 - ? { pgDeltaStderr: params.pgDeltaStderr } - : {}), - }; - const tempDir = legacyPgDeltaTempPath(params.path, params.workdir); - const migrationsDir = params.path.join(params.workdir, "supabase", "migrations"); - const debugDir = yield* legacySaveDebugBundle( - params.fs, - params.path, - params.workdir, - tempDir, - migrationsDir, - bundle, - ); - yield* output.raw( - legacyFormatEmptyPgDeltaPullSummary(debugDir, params.sourceCatalog ?? "", targetCatalog), - "stderr", - ); - yield* output.raw(legacyDebugBundleMessage(debugDir), "stderr"); - return debugDir; -}); diff --git a/apps/cli/src/legacy/commands/db/pull/pull.debug.unit.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.debug.unit.test.ts deleted file mode 100644 index 5c4c5a36f5..0000000000 --- a/apps/cli/src/legacy/commands/db/pull/pull.debug.unit.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { stripAnsi } from "../../../../../tests/helpers/ansi.ts"; -import { - legacyFormatByteSize, - legacyFormatCatalogSummary, - legacyFormatConnectionInfo, - legacyFormatEmptyPgDeltaPullSummary, - legacyRedactPostgresURL, - legacySummarizeCatalogJson, -} from "./pull.debug.ts"; - -describe("legacyRedactPostgresURL", () => { - it("replaces the password but keeps the username", () => { - expect(legacyRedactPostgresURL("postgresql://postgres:secret@db.host:5432/postgres")).toBe( - "postgresql://postgres:xxxxx@db.host:5432/postgres", - ); - }); - - it("uses 'redacted' as the username when only a password is present", () => { - expect(legacyRedactPostgresURL("postgresql://:secret@db.host:5432/postgres")).toBe( - "postgresql://redacted:xxxxx@db.host:5432/postgres", - ); - }); - - it("leaves a URL without userinfo unchanged", () => { - expect(legacyRedactPostgresURL("postgresql://db.host:5432/postgres")).toBe( - "postgresql://db.host:5432/postgres", - ); - }); - - it("returns on a parse failure", () => { - expect(legacyRedactPostgresURL("not a url")).toBe(""); - }); -}); - -describe("legacyFormatConnectionInfo", () => { - it("renders a single redacted line and never leaks the password", () => { - const info = legacyFormatConnectionInfo( - { host: "db.host", port: 5432, user: "postgres", database: "postgres" }, - "postgresql://postgres:secret@db.host:5432/postgres", - ); - expect(info).toBe( - "host=db.host port=5432 user=postgres database=postgres url=postgresql://postgres:xxxxx@db.host:5432/postgres", - ); - expect(info).not.toContain("secret"); - }); -}); - -describe("legacySummarizeCatalogJson", () => { - it("counts objects grouped by schema name (string and nested forms)", () => { - const catalog = JSON.stringify({ - tables: [ - { schema: "public", name: "t1" }, - { schema: "public", name: "t2" }, - { schema: { name: "auth" }, name: "users" }, - ], - }); - const summary = legacySummarizeCatalogJson(catalog); - expect(summary.totalObjects).toBe(3); - expect(summary.bySchema).toEqual({ public: 2, auth: 1 }); - }); - - it("returns an empty summary for blank or invalid JSON", () => { - expect(legacySummarizeCatalogJson("")).toEqual({ totalObjects: 0, bySchema: {} }); - expect(legacySummarizeCatalogJson("{not json")).toEqual({ totalObjects: 0, bySchema: {} }); - }); -}); - -describe("legacyFormatCatalogSummary", () => { - it("reports no objects detected for an empty catalog", () => { - expect(legacyFormatCatalogSummary("Shadow", { totalObjects: 0, bySchema: {} })).toBe( - "Shadow catalog: no objects detected", - ); - }); - - it("lists object counts per schema", () => { - expect(legacyFormatCatalogSummary("Remote", { totalObjects: 2, bySchema: { public: 2 } })).toBe( - "Remote catalog: 2 objects (public=2)", - ); - }); -}); - -describe("legacyFormatByteSize", () => { - it("formats B / KB / MB like Go", () => { - expect(legacyFormatByteSize(512)).toBe("512 B"); - expect(legacyFormatByteSize(2048)).toBe("2.0 KB"); - expect(legacyFormatByteSize(3 * 1024 * 1024)).toBe("3.0 MB"); - }); -}); - -describe("legacyFormatEmptyPgDeltaPullSummary", () => { - it("includes both catalog summaries when present", () => { - const out = stripAnsi( - legacyFormatEmptyPgDeltaPullSummary( - "supabase/.temp/pgdelta/debug/20240101-000000", - JSON.stringify({ t: [{ schema: "public", name: "a" }] }), - JSON.stringify({ t: [{ schema: "public", name: "a" }] }), - ), - ); - expect(out).toContain("pg-delta returned 0 statements."); - expect(out).toContain("Debug bundle saved to supabase/.temp/pgdelta/debug/20240101-000000"); - expect(out).toContain("Shadow catalog: 1 objects (public=1)"); - expect(out).toContain("Remote catalog: 1 objects (public=1)"); - }); - - it("notes a failed/empty remote catalog export", () => { - const out = stripAnsi(legacyFormatEmptyPgDeltaPullSummary("d", "", "")); - expect(out).toContain( - "Remote catalog: export failed or empty (inspect connection.txt and pgdelta-stderr.txt)", - ); - expect(out).not.toContain("Shadow catalog:"); - }); -}); 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 7c7de4fd56..75179e9e0e 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -41,12 +41,7 @@ import { type LegacyLocalDbContainerInputs, } from "../../../shared/db-bootstrap/local-container-inputs.ts"; import { legacyWithShadowDatabase } from "../../../shared/db-bootstrap/shadow-cache.ts"; -import { - legacyCreateShadowDatabase, - legacyPrepareRawShadow, - legacyRemoveShadowDatabase, - legacyShadowRunInputFromLocalContainerInputs, -} from "../../../shared/db-bootstrap/shadow-database.ts"; +import { 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"; import { @@ -78,7 +73,7 @@ import { legacyFormatMigrationTimestamp, legacyGetMigrationPath, } from "../../../shared/legacy-migration-file.ts"; -import { legacyDebugBundleMessage, legacyFormatDebugId } from "../shared/legacy-debug-bundle.ts"; +import { legacyDebugBundleMessage } from "../shared/legacy-debug-bundle.ts"; import { LegacyPgDeltaEngine, type LegacyPgDeltaDatabaseEndpoint, @@ -88,7 +83,6 @@ import { legacyIsPgDeltaDebugEnabled, legacyResolvePgDeltaProjectId, } from "../../../shared/legacy-pgdelta.ts"; -import { legacySaveEmptyPgDeltaPullDebug } from "./pull.debug.ts"; import { legacyPrepareShadowSource } from "../shared/legacy-shadow-source.ts"; import type { LegacyDbPullFlags } from "./pull.command.ts"; import { @@ -381,7 +375,6 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // that helper's own doc comment, and `diff.handler.ts`'s identical call site. projectId: legacyResolvePgDeltaProjectId(cliSettings.projectId, toml, cliSettings.workdir), cwd: cliSettings.workdir, - npmVersion: Option.getOrUndefined(toml.pgDelta.npmVersion), denoVersion: toml.denoVersion, projectEnv: toml.projectEnv, }; @@ -445,7 +438,6 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy envEnabled: legacyParseBoolEnv(toml.envLookup("SUPABASE_EXPERIMENTAL_PG_DELTA")), }), }); - const usesPgDeltaNext = usePgDeltaDiff && pgDeltaEngine.implementation === "next"; // Runs the Go-delegated `--experimental` structured dump (still delegated, see // `EXPERIMENTAL_STRUCTURED_DUMP_DEPRECATION_LINE` above for why). In machine-output @@ -503,13 +495,9 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy yield* output.raw("Preparing declarative schema export using pg-delta...\n", "stderr"); const declarativeDirRel = legacyResolveDeclarativeDir(path, toml.pgDelta); const declarativeDir = path.resolve(cliSettings.workdir, declarativeDirRel); - const exportSchema = ( - target: LegacyPgDeltaDatabaseEndpoint, - source?: LegacyPgDeltaDatabaseEndpoint, - ) => + const exportSchema = (target: LegacyPgDeltaDatabaseEndpoint) => pgDeltaEngine.exportDeclarativeSchema({ context: ctx, - ...(source !== undefined ? { source } : {}), target, schema: flags.schema, formatOptions, @@ -518,44 +506,10 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy : {}), debug: legacyIsPgDeltaDebugEnabled(), strictCoverage: flags.strictCoverage, - noCache: false, }); - // Legacy export owns an interrupt-safe empty-shadow lifecycle; next reads the target. - const exported = - pgDeltaEngine.implementation === "next" - ? yield* withPoolerFallback(targetEndpoint, (target) => exportSchema(target)) - : yield* Effect.gen(function* () { - const declLocalInputs = Option.getOrThrow(localInputs); - const resolvedDeclShadowImage = yield* declLocalInputs.resolvePostgresImage; - // The legacy exporter still needs the historical empty baseline. Keep it - // native and workflow-owned; the bundled next exporter reads only target. - const rawShadowInput = legacyShadowRunInputFromLocalContainerInputs( - declLocalInputs, - resolvedDeclShadowImage, - toml, - fs, - path, - ); - return yield* Effect.acquireUseRelease( - legacyCreateShadowDatabase(spawner, rawShadowInput), - (handle) => - Effect.gen(function* () { - const shadow = yield* legacyPrepareRawShadow( - spawner, - handle, - rawShadowInput, - ); - return yield* withPoolerFallback(targetEndpoint, (target) => - exportSchema(target, { - kind: "database", - ref: shadow.sourceUrl, - connectOptions: { isLocal: true, dnsResolver: "native" }, - }), - ); - }), - (handle) => legacyRemoveShadowDatabase(spawner, handle.containerId), - ); - }); + const exported = yield* withPoolerFallback(targetEndpoint, (target) => + exportSchema(target), + ); const written = yield* legacyWriteDeclarativeSchemas( fs, path, @@ -597,10 +551,10 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy return; } - // Only next ignores schema_paths in favor of the migrations baseline. + // pg-delta ignores schema_paths in favor of the migrations baseline. if ( !delegatesExperimentalPull && - usesPgDeltaNext && + usePgDeltaDiff && toml.schemaPaths !== undefined && toml.schemaPaths.length > 0 ) { @@ -764,8 +718,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy Effect.gen(function* () { yield* output.raw("Creating shadow database...\n", "stderr"); const resolvedPullShadowImage = yield* pullLocalInputs.resolvePostgresImage; - // Legacy may substitute a declarative target; next always uses the live target. - const migrationMode: "legacy" | "pgdelta-next" = usesPgDeltaNext + const migrationMode: "legacy" | "pgdelta-next" = usePgDeltaDiff ? "pgdelta-next" : "legacy"; const shadowInput = { @@ -777,7 +730,6 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy path, ), targetLocal: resolved.isLocal, - usePgDelta: usePgDeltaDiff, migrationMode, // `toml.schemaPathPatterns`, NOT `pullLocalInputs.context.config.db.migrations. // schema_paths`: the latter is the raw `@supabase/config` field, which never @@ -785,7 +737,6 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // (`legacyReadDbToml`) already resolves that env override. schemaPaths: toml.schemaPathPatterns, pgDelta: toml.pgDelta, - ctx, }; // `legacyWithShadowDatabase` (`shadow-cache.ts`) owns the interrupt-safe lifecycle // and the cache seam. Each pooler-retry attempt still acquires and releases its own @@ -856,41 +807,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // initial-migra path seeded the file with a pg_dump above, so its empty second // pass is swallowed and falls through to the shared tail below. if (diffEmpty && !seededFromDump) { - // Preserve the legacy empty-diff debug bundle contract. - if (pgDeltaEngine.implementation === "legacy" && diffOutcome.debug !== undefined) { - const debugDir = yield* legacySaveEmptyPgDeltaPullDebug({ - ctx, - conn: resolved.conn, - targetUrl, - sourceCatalog: diffOutcome.debug.sourceSnapshot, - pgDeltaStderr: diffOutcome.debug.stderr, - id: legacyFormatDebugId(yield* Clock.currentTimeMillis), - fs, - path, - workdir: cliSettings.workdir, - }).pipe( - Effect.catch((error) => - output - .raw( - `Warning: failed to save pg-delta debug bundle: ${error.message}\n`, - "stderr", - ) - .pipe(Effect.as(undefined)), - ), - ); - if (debugDir !== undefined) { - return yield* Effect.fail( - new LegacyDbPullInSyncError({ - message: `No schema changes found (debug bundle: ${debugDir})`, - suggestion: IN_SYNC_SUGGESTION, - }), - ); - } - } - if ( - pgDeltaEngine.implementation === "next" && - diffOutcome.debug?.directory !== undefined - ) { + if (diffOutcome.debug?.directory !== undefined) { yield* output.raw(legacyDebugBundleMessage(diffOutcome.debug.directory), "stderr"); return yield* Effect.fail( new LegacyDbPullInSyncError({ 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 7705fa27e4..8b767e58aa 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 @@ -90,7 +90,6 @@ const pgDeltaDiffEnvelope = ( }); interface SetupOpts { - readonly engineImplementation?: "next" | "legacy"; readonly nextDebugDirectory?: string; readonly format?: OutputFormat; readonly remoteVersions?: ReadonlyArray; @@ -167,7 +166,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { const pgDeltaEngine = Layer.succeed( LegacyPgDeltaEngine, LegacyPgDeltaEngine.of({ - implementation: opts.engineImplementation ?? "legacy", diffExplicit: () => Effect.die("diffExplicit unused"), diffDatabase: (input) => { engineCalls.push({ @@ -194,15 +192,12 @@ function setup(workdir: string, opts: SetupOpts = {}) { files: [], ...(process.env["PGDELTA_DEBUG"] !== undefined ? { - debug: - opts.engineImplementation === "next" - ? { - sourceSnapshot: opts.catalogStdout ?? "", - ...(opts.nextDebugDirectory !== undefined - ? { directory: opts.nextDebugDirectory } - : {}), - } - : { sourceSnapshot: opts.catalogStdout ?? "", stderr: "" }, + debug: { + sourceSnapshot: opts.catalogStdout ?? "", + ...(opts.nextDebugDirectory !== undefined + ? { directory: opts.nextDebugDirectory } + : {}), + }, } : {}), }); @@ -787,7 +782,6 @@ describe("legacy db pull", () => { ); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], - engineImplementation: "next", // The next engine's mock parses `edgeStdout` as a rendered-file envelope. edgeStdout: JSON.stringify({ files: [ @@ -901,23 +895,17 @@ describe("legacy db pull", () => { scope: "database", files: ["public/t.sql"], }); - // Declarative mode's bare shadow (`legacyPrepareRawShadow`) never connects to set - // up a platform baseline or `contrib_regression` template. The only connects are - // the top-level target connect (`resolved.conn`, port 5432, database "postgres") - // and the shadow's own readiness probe on the shadow port — a single short-lived - // connect that is now the provisioning gate (`legacyWaitForShadowReady`) in place - // of waiting on the shadow container's 10s-interval Docker healthcheck. - expect(s.connectTargets).toEqual([ - { database: "postgres", port: 5432 }, - { database: "postgres", port: 54320 }, - ]); - expect(s.shadowSpawned.filter((call) => call.args[0] === "create")).toHaveLength(1); - expect(s.shadowSpawned.filter((call) => call.args[0] === "rm")).toHaveLength(1); + // Declarative export reads only the live target: the sole connect is the + // top-level target connect (`resolved.conn`, port 5432, database "postgres"), + // and no shadow database is ever provisioned. + expect(s.connectTargets).toEqual([{ database: "postgres", port: 5432 }]); + expect(s.shadowSpawned.filter((call) => call.args[0] === "create")).toHaveLength(0); + expect(s.shadowSpawned.filter((call) => call.args[0] === "rm")).toHaveLength(0); }).pipe(Effect.provide(s.layer)); }); - it.effect("next declarative export does not provision a baseline shadow", () => { - const s = setup(tmp.current, { engineImplementation: "next" }); + it.effect("declarative export does not provision a baseline shadow", () => { + const s = setup(tmp.current, {}); return Effect.gen(function* () { yield* legacyDbPull(flags({ declarative: Option.some(true) })); expect(s.engineCalls[0]?.operation).toBe("export"); @@ -1321,51 +1309,6 @@ describe("legacy db pull", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect( - "an empty pg-delta diff under PGDELTA_DEBUG saves a debug bundle and reports it", - () => { - // A debug bundle is saved and its path embedded in the in-sync error when - // PGDELTA_DEBUG is set on an empty pg-delta diff. - seedMigration(tmp.current, "20240101000000"); - const catalog = JSON.stringify({ tables: [{ schema: "public", name: "t" }] }); - const s = setup(tmp.current, { - remoteVersions: ["20240101000000"], - edgeStdout: "", // empty diff - catalogStdout: catalog, // shadow + remote catalog exports succeed - yes: true, - }); - return Effect.gen(function* () { - const prev = process.env["PGDELTA_DEBUG"]; - process.env["PGDELTA_DEBUG"] = "1"; - try { - const error = yield* legacyDbPull(flags({ diffEngine: Option.some("pg-delta") })).pipe( - Effect.flip, - ); - expect(error.message).toContain("No schema changes found (debug bundle:"); - } finally { - if (prev === undefined) delete process.env["PGDELTA_DEBUG"]; - else process.env["PGDELTA_DEBUG"] = prev; - } - const debugRoot = join(tmp.current, "supabase", ".temp", "pgdelta", "debug"); - const ids = existsSync(debugRoot) ? readdirSync(debugRoot) : []; - expect(ids).toHaveLength(1); - const bundleDir = join(debugRoot, ids[0] ?? ""); - const files = readdirSync(bundleDir); - expect(files).toContain("source-catalog.json"); - expect(files).toContain("target-catalog.json"); - expect(files).toContain("connection.txt"); - expect(files).toContain("error.txt"); - expect(readFileSync(join(bundleDir, "error.txt"), "utf8")).toBe("No schema changes found"); - // connection.txt is password-redacted (→ xxxxx). - expect(readFileSync(join(bundleDir, "connection.txt"), "utf8")).toContain( - "url=postgresql://postgres:xxxxx@", - ); - expect(streamText(s.out, "stderr")).toContain("pg-delta returned 0 statements."); - expect(streamText(s.out, "stderr")).toContain("Debug bundle saved to"); - }).pipe(Effect.provide(s.layer)); - }, - ); - it.effect("an empty pg-delta diff without PGDELTA_DEBUG writes no debug bundle", () => { seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "", yes: true }); @@ -1393,7 +1336,6 @@ describe("legacy db pull", () => { const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "", - engineImplementation: "next", nextDebugDirectory: debugDir, }); return Effect.gen(function* () { @@ -1986,7 +1928,6 @@ describe("legacy db pull", () => { mkdirSync(join(tmp.current, "supabase", "schemas"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "schemas", "public.sql"), "select 1;\n"); const s = setup(tmp.current, { - engineImplementation: "next", remoteVersions: ["20240101000000"], edgeStdout: pgDeltaDiffEnvelope([{ name: "schema_changes", sql: "create table remote ();" }]), yes: true, @@ -2243,11 +2184,8 @@ describe("legacy db pull", () => { it.effect("retries the declarative export through the IPv4 pooler on an IPv6 error", () => { // The declarative export retries through the pooler in the same IPv6 - // scenario, but unlike the migration-style diff it prepares the raw shadow - // ONCE before the retry and only re-runs the export against the same shadow — - // a deliberate asymmetry, not a gap to close. Assert the single-shadow-reuse - // shape so a future change doesn't accidentally "fix" this path to - // double-provision like the migration-style diff path correctly does. + // scenario. The export reads only the live target, so no shadow database is + // ever provisioned on this path. const s = setup(tmp.current, { edgeFailFirstWith: "error exporting declarative schema:\nnetwork is unreachable", edgeStdout: EXPORT_JSON, @@ -2260,7 +2198,7 @@ describe("legacy db pull", () => { expect(streamText(s.out, "stderr")).toContain( `Declarative schema written to ${join("supabase", "schemas")}\n`, ); - expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(0); }).pipe(Effect.provide(s.layer)); }); @@ -2329,8 +2267,8 @@ describe("legacy db pull", () => { * the second run's behaviour — the cache key is global and deliberately workdir-independent, * so two worktrees with identical settings still collide on the same tar. */ - const runCached = (implementation: "legacy" | "next") => { - const workdir = join(tmp.current, `${implementation}-worktree`); + const runCached = (engine: "migra" | "pg-delta") => { + const workdir = join(tmp.current, `${engine}-worktree`); seedMigration(workdir, "20240101000000"); writeFileSync( join(workdir, "supabase", "config.toml"), @@ -2338,9 +2276,11 @@ describe("legacy db pull", () => { ); const s = setup(workdir, { statefulDocker: true, - engineImplementation: implementation, remoteVersions: ["20240101000000"], - edgeStdout: pgDeltaDiffEnvelope([{ name: "schema_changes", sql: "create table t ();" }]), + edgeStdout: + engine === "pg-delta" + ? pgDeltaDiffEnvelope([{ name: "schema_changes", sql: "create table t ();" }]) + : "create table t ();\n", yes: true, }); return legacyWithEnv( @@ -2349,31 +2289,34 @@ describe("legacy db pull", () => { legacyWithEnv( "SUPABASE_SHADOW_CACHE", "1", - legacyDbPull(flags()).pipe(Effect.provide(s.layer)), + legacyDbPull(flags(engine === "migra" ? { diffEngine: Option.some("migra") } : {})).pipe( + Effect.provide(s.layer), + ), ), ).pipe(Effect.as(s)); }; // Regression: both migrate paths used to pass a hardcoded `{ webhooks: "enabled" }`, so the - // legacy run's forced-`pg_net` baseline and the next run's config-following baseline keyed + // migra run's forced-`pg_net` baseline and the pg-delta run's config-following baseline keyed // to the SAME tar and silently restored each other's cluster. The handler now forks the // policy on `migrationMode`; `shadow-cache.integration.test.ts` covers the cache's half of // the contract, this covers `db pull`'s call site. - it.live("a legacy-engine baseline is never restored into a pg-delta-next run", () => { + it.live("a migra-engine baseline is never restored into a pg-delta run", () => { return Effect.gen(function* () { - // Legacy migrate forces `pg_net` on regardless of config, and publishes that baseline. - const legacyRun = yield* runCached("legacy"); - expect(legacyRun.dockerDaemon?.stepCalls("cp-out")).toHaveLength(1); - const legacyTars = publishedTars(); - expect(legacyTars).toHaveLength(1); - - // pg-delta next follows the config (webhooks are off here), so it must cold-provision + // Migra's migrate path forces `pg_net` on regardless of config, and publishes + // that baseline. + const migraRun = yield* runCached("migra"); + expect(migraRun.dockerDaemon?.stepCalls("cp-out")).toHaveLength(1); + const migraTars = publishedTars(); + expect(migraTars).toHaveLength(1); + + // pg-delta follows the config (webhooks are off here), so it must cold-provision // and publish its OWN baseline rather than restore the forced-on one above. - const nextRun = yield* runCached("next"); - expect(nextRun.dockerDaemon?.stepCalls("cp-in")).toHaveLength(0); - expect(nextRun.dockerDaemon?.stepCalls("cp-out")).toHaveLength(1); + const pgDeltaRun = yield* runCached("pg-delta"); + expect(pgDeltaRun.dockerDaemon?.stepCalls("cp-in")).toHaveLength(0); + expect(pgDeltaRun.dockerDaemon?.stepCalls("cp-out")).toHaveLength(1); expect(publishedTars()).toHaveLength(2); - expect(publishedTars()).toEqual(expect.arrayContaining(legacyTars)); + expect(publishedTars()).toEqual(expect.arrayContaining(migraTars)); }); }); }); diff --git a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md index fdf1627eb5..5eeae24b21 100644 --- a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md @@ -18,12 +18,10 @@ before migrations unless `--skip-vault` is set. ## Files Written -| Path | Format | When | -| ------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `~/.supabase//linked-project.json` | JSON | on the `--linked` path (post-run cache) | -| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | -| `/supabase/.temp/pgdelta/catalog--migrations--.json` | JSON | best-effort, after a successful migration apply, when pg-delta is enabled (`[experimental.pgdelta] enabled` or `SUPABASE_EXPERIMENTAL_PG_DELTA`) AND the legacy engine is selected (`SUPABASE_USE_PG_DELTA_NEXT=false`); the default next engine skips this warmup entirely; a failure only warns on stderr and never fails the push | -| `/supabase/.temp/pgdelta/pgdelta-target-ca.crt` | PEM | same gate as above, when the target requires SSL (`legacyPreparePgDeltaRef`) | +| Path | Format | When | +| ------------------------------------------------ | ------ | --------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | on the `--linked` path (post-run cache) | +| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | ## Database Mutations @@ -44,17 +42,13 @@ before migrations unless `--skip-vault` is set. ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no (`--password`/`-p` takes precedence) | -| `SUPABASE_YES` | auto-confirm prompts | no (also `--yes`) | -| `SUPABASE_PROJECT_ID` | linked-ref resolution override, superseded by `--project-ref` when set (same precedence position); also independently feeds the pg-delta migrations-catalog cache's project id, which `--project-ref` does NOT affect — see Notes | no | -| `DOTENV_PRIVATE_KEY*` | decrypts `encrypted:` config secrets; `[db.vault]` values are not decrypted with `--skip-vault` | no | -| `SUPABASE_EXPERIMENTAL_PG_DELTA` | enables the migrations-catalog cache when `[experimental.pgdelta].enabled` is unset | no (project `.env` or shell) | -| `SUPABASE_USE_PG_DELTA_NEXT` | selects the pg-delta implementation; `false` selects the legacy edge-runtime engine and thereby restores the migrations-catalog cache warmup (unset/unrecognized defaults to the next engine, which skips it); shell presence wins over project `.env`, even an empty shell value | no (project `.env` or shell) | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the pg-delta edge-runtime image registry for the cache export | no (project `.env` or shell) | -| `PGDELTA_NPM_REGISTRY` | overrides the pg-delta edge-runtime npm registry (`.npmrc` + `NPM_CONFIG_REGISTRY` forward) for the cache export | no (project `.env` or shell) | +| Variable | Purpose | Required? | +| ----------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no (`--password`/`-p` takes precedence) | +| `SUPABASE_YES` | auto-confirm prompts | no (also `--yes`) | +| `SUPABASE_PROJECT_ID` | linked-ref resolution override, superseded by `--project-ref` when set (same precedence position) — see Notes | no | +| `DOTENV_PRIVATE_KEY*` | decrypts `encrypted:` config secrets; `[db.vault]` values are not decrypted with `--skip-vault` | no | ## Exit Codes @@ -102,9 +96,7 @@ stdout is payload-only. A single `result` object is emitted: exclusive; with no flag the target defaults to linked. - **`--project-ref`** (TS-only, no Go equivalent on any user-facing `db` command) overrides ONLY the linked-ref resolution `LegacyProjectRefResolver` - performs (flag > `SUPABASE_PROJECT_ID` > `~/.supabase//project-ref`) — - it does not affect the pg-delta migrations-catalog cache's project id, which - still derives from `SUPABASE_PROJECT_ID`/config.toml/workdir basename only. + performs (flag > `SUPABASE_PROJECT_ID` > `~/.supabase//project-ref`). It never implies `--linked`: passing it with a resolved `--local`/`--db-url` target is a hard error rather than a silently discarded flag (deliberately stricter than `SUPABASE_PROJECT_ID`, which simply goes unused on a @@ -129,14 +121,3 @@ stdout is payload-only. A single `result` object is emitted: Prefer idempotent forms (`CREATE INDEX CONCURRENTLY IF NOT EXISTS …`) and isolating such statements in their own migration file. Intentional fix for supabase/cli#5139, adopted into TS in PR supabase/cli#5671 (landed on develop as `b48fad60`). -- **Migrations catalog cache**: after a successful migration apply, when pg-delta - is enabled AND the legacy engine is selected (`SUPABASE_USE_PG_DELTA_NEXT=false` - — the default next engine skips this warmup entirely), exports the target's - pg-delta catalog via the edge-runtime stack - and writes it under `supabase/.temp/pgdelta/`, pruning older snapshots for the - same prefix (retains 2). A failure only warns on stderr - (`Warning: failed to cache migrations catalog: …`) and never fails the push. - Reuses `legacyExportCatalogPgDelta` (the same pg-delta export path - `db pull`/`db diff` use, which always mounts the project root at `/workspace`) - rather than a second copy, so an ENOENT bug present in an earlier - implementation (supabase/cli#5921) has no equivalent here. diff --git a/apps/cli/src/legacy/commands/db/push/push.integration.test.ts b/apps/cli/src/legacy/commands/db/push/push.integration.test.ts index 3527e07c83..729b5293d7 100644 --- a/apps/cli/src/legacy/commands/db/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/push/push.integration.test.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { basename, dirname, join } from "node:path"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; @@ -31,11 +31,7 @@ import { type LegacyPgConnInput, type LegacyDbSession, } from "../../../shared/legacy-db-connection.service.ts"; -import { LegacyEdgeRuntimeScriptError } from "../../../shared/legacy-edge-runtime-script.errors.ts"; -import { - LegacyEdgeRuntimeScript, - type LegacyEdgeRuntimeRunOpts, -} from "../../../shared/legacy-edge-runtime-script.service.ts"; +import { LegacyEdgeRuntimeScript } from "../../../shared/legacy-edge-runtime-script.service.ts"; import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; import { legacyDbPush } from "./push.handler.ts"; import type { LegacyDbPushFlags } from "./push.command.ts"; @@ -182,8 +178,6 @@ function setup( noSeedTable?: boolean; failExec?: string; failExecWith?: { message: string; code?: string; detail?: string; position?: number }; - catalogStdout?: string; - catalogExportFailWith?: string; noProjectId?: boolean; // Simulates the real `LegacyDbConfigResolver`'s own "Initialising login // role..." stderr line (`legacy-db-config.layer.ts`'s `initLoginRole`), @@ -209,19 +203,8 @@ function setup( const telemetry = mockLegacyTelemetryStateTracked(); const linkedCache = mockLegacyLinkedProjectCacheTracked(); - const edgeRunCalls: Array = []; - const registryEnvAtRunTime: Array = []; const edge = Layer.succeed(LegacyEdgeRuntimeScript, { - run: (runOpts: LegacyEdgeRuntimeRunOpts) => { - edgeRunCalls.push(runOpts); - registryEnvAtRunTime.push(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]); - if (opts.catalogExportFailWith !== undefined) { - return Effect.fail( - new LegacyEdgeRuntimeScriptError({ message: opts.catalogExportFailWith }), - ); - } - return Effect.succeed({ stdout: opts.catalogStdout ?? '{"version":1}', stderr: "" }); - }, + run: () => Effect.succeed({ stdout: '{"version":1}', stderr: "" }), }); const sslProbe = Layer.succeed(LegacyPgDeltaSslProbe, { requireSsl: () => Effect.succeed(false), @@ -286,8 +269,6 @@ function setup( telemetry, linkedCache, resolver, - edgeRunCalls, - registryEnvAtRunTime, }; } @@ -394,269 +375,6 @@ describe("legacy db push", () => { }); }); - it.live("does not attempt to cache the migrations catalog when pg-delta is disabled", () => { - const { layer, out, edgeRunCalls } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: migrationFile("20240101000000"), - confirm: [true], - }); - return Effect.gen(function* () { - yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(edgeRunCalls).toHaveLength(0); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - expect(existsSync(join(tmp.current, "supabase", ".temp", "pgdelta"))).toBe(false); - }); - }); - - it.live("does not start edge-runtime for the obsolete catalog warmup under default next", () => { - const { layer, edgeRunCalls } = setup(tmp.current, { - toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', - files: migrationFile("20240101000000"), - confirm: [true], - }); - return Effect.gen(function* () { - yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(edgeRunCalls).toHaveLength(0); - expect(existsSync(join(tmp.current, "supabase", ".temp", "pgdelta"))).toBe(false); - }); - }); - - it.live("caches the migrations catalog when project .env enables pg-delta", () => { - const { layer, out, edgeRunCalls } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: { - ...migrationFile("20240101000000"), - "supabase/.env": "SUPABASE_EXPERIMENTAL_PG_DELTA=true\nSUPABASE_USE_PG_DELTA_NEXT=false\n", - }, - confirm: [true], - catalogStdout: '{"snapshot":"ok"}', - }); - return Effect.gen(function* () { - yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - expect(edgeRunCalls).toHaveLength(1); - const tempDir = join(tmp.current, "supabase", ".temp", "pgdelta"); - const catalogFiles = readdirSync(tempDir).filter((name) => - name.startsWith("catalog-local-migrations-"), - ); - expect(catalogFiles).toHaveLength(1); - }); - }); - - it.live( - "skips the legacy catalog when an empty shell value shadows a project .env false (godotenv parity)", - () => { - // godotenv.Load never replaces a shell value, including an empty one, so - // an empty `SUPABASE_USE_PG_DELTA_NEXT` in the shell must suppress the - // `supabase/.env` fallback below and resolve to the next implementation — - // matching the engine-selector layer's own precedence rather than - // `toml.envLookup`'s (which treats an empty shell value as unset). - const prev = process.env["SUPABASE_USE_PG_DELTA_NEXT"]; - process.env["SUPABASE_USE_PG_DELTA_NEXT"] = ""; - const { layer, out, edgeRunCalls } = setup(tmp.current, { - toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', - files: { - ...migrationFile("20240101000000"), - "supabase/.env": "SUPABASE_USE_PG_DELTA_NEXT=false\n", - }, - confirm: [true], - }); - return Effect.gen(function* () { - yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - expect(edgeRunCalls).toHaveLength(0); - expect(existsSync(join(tmp.current, "supabase", ".temp", "pgdelta"))).toBe(false); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_USE_PG_DELTA_NEXT"]; - else process.env["SUPABASE_USE_PG_DELTA_NEXT"] = prev; - }), - ), - ); - }, - ); - - it.live("caches the migrations catalog after a successful push when pg-delta is enabled", () => { - const { layer, out, edgeRunCalls } = setup(tmp.current, { - toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', - files: { - ...migrationFile("20240101000000"), - "supabase/.env": "SUPABASE_USE_PG_DELTA_NEXT=false\n", - }, - confirm: [true], - catalogStdout: '{"snapshot":"ok"}', - }); - return Effect.gen(function* () { - yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - expect(edgeRunCalls).toHaveLength(1); - const tempDir = join(tmp.current, "supabase", ".temp", "pgdelta"); - const catalogFiles = readdirSync(tempDir).filter((name) => - name.startsWith("catalog-local-migrations-"), - ); - expect(catalogFiles).toHaveLength(1); - expect(readFileSync(join(tempDir, catalogFiles[0]!), "utf8")).toBe('{"snapshot":"ok"}'); - }); - }); - - it.live( - "falls back to config.toml's project_id for the pg-delta volume when SUPABASE_PROJECT_ID is unset", - () => { - const { layer, out, edgeRunCalls } = setup(tmp.current, { - toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', - files: { - ...migrationFile("20240101000000"), - "supabase/.env": "SUPABASE_USE_PG_DELTA_NEXT=false\n", - }, - confirm: [true], - catalogStdout: '{"snapshot":"ok"}', - noProjectId: true, - }); - return Effect.gen(function* () { - yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - expect(edgeRunCalls).toHaveLength(1); - // `project_id` resolves from config.toml (here "test") once no - // `SUPABASE_PROJECT_ID` env override wins — the pg-delta Deno-cache volume - // must key off that same id, not fall through to an empty/shared name. - expect(edgeRunCalls[0]?.binds).toContain("supabase_edge_runtime_test:/root/.cache/deno:rw"); - }); - }, - ); - - it.live( - "falls back to the workdir basename for the pg-delta volume when config.toml has no project_id", - () => { - const { layer, out, edgeRunCalls } = setup(tmp.current, { - toml: "[experimental.pgdelta]\nenabled = true\n", - files: { - ...migrationFile("20240101000000"), - "supabase/.env": "SUPABASE_USE_PG_DELTA_NEXT=false\n", - }, - confirm: [true], - catalogStdout: '{"snapshot":"ok"}', - noProjectId: true, - }); - return Effect.gen(function* () { - yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - expect(edgeRunCalls).toHaveLength(1); - const expectedId = basename(tmp.current); - expect(edgeRunCalls[0]?.binds).toContain( - `supabase_edge_runtime_${expectedId}:/root/.cache/deno:rw`, - ); - }); - }, - ); - - it.live( - "falls back to the linked project ref for the pg-delta volume when config.toml has no project_id", - () => { - // The linked ref seeds `project_id` before config load runs, so on the - // linked path (the default target here — no `--local`/`--db-url`) an - // absent `project_id` retains the linked ref rather than falling to the - // workdir basename; only `--local`/`--db-url` (the previous test, where - // the ref is never seeded) fall through to the basename. - const { layer, out, edgeRunCalls } = setup(tmp.current, { - toml: "[experimental.pgdelta]\nenabled = true\n", - args: ["db", "push", "--linked"], - isLocal: false, - projectRef: LEGACY_VALID_REF, - files: { - ...migrationFile("20240101000000"), - "supabase/.env": "SUPABASE_USE_PG_DELTA_NEXT=false\n", - }, - confirm: [true], - catalogStdout: '{"snapshot":"ok"}', - noProjectId: true, - }); - return Effect.gen(function* () { - yield* legacyDbPush({ ...DEFAULT_FLAGS, local: false, linked: true }).pipe( - Effect.provide(layer), - ); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - expect(edgeRunCalls).toHaveLength(1); - expect(edgeRunCalls[0]?.binds).toContain( - `supabase_edge_runtime_${LEGACY_VALID_REF}:/root/.cache/deno:rw`, - ); - }); - }, - ); - - it.live("sanitizes an invalid config.toml project_id before naming the pg-delta volume", () => { - const { layer, out, edgeRunCalls } = setup(tmp.current, { - toml: 'project_id = "my app"\n[experimental.pgdelta]\nenabled = true\n', - files: { - ...migrationFile("20240101000000"), - "supabase/.env": "SUPABASE_USE_PG_DELTA_NEXT=false\n", - }, - confirm: [true], - catalogStdout: '{"snapshot":"ok"}', - noProjectId: true, - }); - return Effect.gen(function* () { - yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - expect(edgeRunCalls).toHaveLength(1); - // Config validation sanitizes an invalid `project_id` (replacing the - // disallowed run with `_`) once at config-load time, so every later - // reader — including `EdgeRuntimeId` — sees the sanitized form, never - // the raw `"my app"`. - expect(edgeRunCalls[0]?.binds).toContain("supabase_edge_runtime_my_app:/root/.cache/deno:rw"); - }); - }); - - it.live("warns without failing the push when the catalog export fails", () => { - const { layer, out } = setup(tmp.current, { - toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', - files: { - ...migrationFile("20240101000000"), - "supabase/.env": "SUPABASE_USE_PG_DELTA_NEXT=false\n", - }, - confirm: [true], - catalogExportFailWith: "edge-runtime script produced no output", - }); - return Effect.gen(function* () { - const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - expect(out.stderrText).toContain( - "Warning: failed to cache migrations catalog: edge-runtime script produced no output", - ); - expect(out.stdoutText).toContain("Finished"); - }); - }); - - it.live( - "resolves the pg-delta cache export image via SUPABASE_INTERNAL_IMAGE_REGISTRY from supabase/.env", - () => { - const prev = process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; - delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; - const { layer, registryEnvAtRunTime } = setup(tmp.current, { - toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', - files: { - ...migrationFile("20240101000000"), - "supabase/.env": - "SUPABASE_INTERNAL_IMAGE_REGISTRY=my-mirror.example.com\nSUPABASE_USE_PG_DELTA_NEXT=false\n", - }, - confirm: [true], - catalogStdout: '{"snapshot":"ok"}', - }); - return Effect.gen(function* () { - yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(registryEnvAtRunTime).toEqual(["my-mirror.example.com"]); - 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; - }), - ), - ); - }, - ); - it.live("returns context canceled when the migration prompt is declined", () => { const { layer, conn } = setup(tmp.current, { toml: 'project_id = "test"\n', diff --git a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md index 5b2dfb6b5e..5f7b9b47e7 100644 --- a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md @@ -46,11 +46,10 @@ removed `LegacyDeclarativeSeam.execInherit` seam — see those commands' own ## Files Written -| Path | Format | When | -| ------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | -| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | -| `/supabase/.temp/pgdelta/catalog--migrations--.json` | JSON | best-effort, after migrations/seeding succeed, when no `--version`/`--last` resolved a version AND pg-delta is enabled (`[experimental.pgdelta].enabled` or `SUPABASE_EXPERIMENTAL_PG_DELTA`) AND the legacy engine is selected (`SUPABASE_USE_PG_DELTA_NEXT=false`); the default next engine skips this warmup entirely; a failure only warns on stderr and never fails the reset — see Notes. Native TS on both targets: **remote path** (`` = the project ref/URL hash) after either apply branch (schema-files or migrations); **local path** (`` = `"local"`) PG15 only, via the reused `legacyStartSetupLocalDatabase` pipeline (`db-setup.ts`) after `MigrateAndSeed` — the PG≤14 branch never calls this at all, so a PG≤14 local project never writes this file regardless of pg-delta config | +| Path | Format | When | +| ------------------------------------------------ | ------ | --------------------------------- | +| `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | +| `~/.supabase/telemetry.json` | JSON | always (post-run telemetry flush) | On the local path, the native recreate additionally recreates the `supabase_db_` container/volume (PG15) or the `postgres`/`_supabase` @@ -128,21 +127,18 @@ the whole reset** (not just "skip buckets"). ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no | -| `SUPABASE_YES` | auto-confirm the reset prompt | no (also `--yes`) | -| `SUPABASE_EXPERIMENTAL` | selects the schema-files apply branch on either target | no (also `--experimental`) | -| `SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED` | overrides `[experimental.pgdelta].enabled`; a truthy value flips the reset gate (`experimental && resolvedVersion === "" && !toml.pgDelta.enabled`) back to timestamped migrations even with `--experimental` set — switches between two different destructive code paths | no | -| `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS` | overrides `[db.migrations].schema_paths` (viper `AutomaticEnv`, beats the config-file value) for the schema-files apply branch — genuinely effective on both targets now | no (no dedicated flag — config-file-only otherwise) | -| `SUPABASE_PROJECT_ID` | overrides the local container id; ALSO the linked-ref resolution fallback `--project-ref` supersedes — see Notes for the narrower scope of the flag | no | -| `SUPABASE_EXPERIMENTAL_PG_DELTA` | enables the post-reset migrations-catalog cache (see Files Written) when `[experimental.pgdelta].enabled` is unset — distinct from `SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED` above, which switches the reset's own apply branch instead | no (project `.env` or shell) | -| `SUPABASE_USE_PG_DELTA_NEXT` | selects the pg-delta implementation; `false` selects the legacy edge-runtime engine and thereby restores the migrations-catalog cache (unset/unrecognized defaults to the next engine, which skips it); shell presence wins over project `.env`, even an empty shell value | no (project `.env` or shell) | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the pg-delta edge-runtime image registry for the migrations-catalog cache export (scoped for the whole run via `legacyApplyProjectEnv`, matching `db push`) | no (project `.env` or shell) | -| `PGDELTA_NPM_REGISTRY` | overrides the pg-delta edge-runtime npm registry (`.npmrc` + `NPM_CONFIG_REGISTRY` forward) for the migrations-catalog cache export (scoped for the whole run via `legacyApplyProjectEnv`, matching `db push`) | no (project `.env` or shell) | -| `SUPABASE_DB_PORT` / `SUPABASE_DB_MAJOR_VERSION` / `SUPABASE_DB_HEALTH_TIMEOUT` / `SUPABASE_DB_SETTINGS_*` | local-path container-recreate config overrides, same as `db start` | no | -| `SUPABASE_NETWORK_ID` (`--network-id`) | forces the recreated container/network onto an existing Docker network | no | +| Variable | Purpose | Required? | +| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for the `--linked` resolver path | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_DB_PASSWORD` | password for the linked/remote connection | no | +| `SUPABASE_YES` | auto-confirm the reset prompt | no (also `--yes`) | +| `SUPABASE_EXPERIMENTAL` | selects the schema-files apply branch on either target | no (also `--experimental`) | +| `SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED` | overrides `[experimental.pgdelta].enabled`; a truthy value flips the reset gate (`experimental && resolvedVersion === "" && !toml.pgDelta.enabled`) back to timestamped migrations even with `--experimental` set — switches between two different destructive code paths | no | +| `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS` | overrides `[db.migrations].schema_paths` (viper `AutomaticEnv`, beats the config-file value) for the schema-files apply branch — genuinely effective on both targets now | no (no dedicated flag — config-file-only otherwise) | +| `SUPABASE_PROJECT_ID` | overrides the local container id; ALSO the linked-ref resolution fallback `--project-ref` supersedes — see Notes for the narrower scope of the flag | no | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the image registry used to resolve the local path's container images (scoped for the whole run via `legacyApplyProjectEnv`) | no (project `.env` or shell) | +| `SUPABASE_DB_PORT` / `SUPABASE_DB_MAJOR_VERSION` / `SUPABASE_DB_HEALTH_TIMEOUT` / `SUPABASE_DB_SETTINGS_*` | local-path container-recreate config overrides, same as `db start` | no | +| `SUPABASE_NETWORK_ID` (`--network-id`) | forces the recreated container/network onto an existing Docker network | no | ## Connection loss during migration apply @@ -258,31 +254,6 @@ path has no confirmation prompt. decrypts them into `toml.vault`, and `legacyUpsertVaultSecrets` upserts the decrypted values unconditionally, before either branch (schema-files or migrations) runs. -- **Migrations catalog cache**: gated on no `--version`/`--last` having resolved - a version, pg-delta being enabled (`[experimental.pgdelta].enabled` or - `SUPABASE_EXPERIMENTAL_PG_DELTA` — see Environment Variables), AND the legacy engine - being selected (`SUPABASE_USE_PG_DELTA_NEXT=false`; the default next engine skips - this warmup entirely); a versioned reset - never refreshes the cache. A failure only warns on stderr and never fails - the reset. Writes under `supabase/.temp/pgdelta/` (see Files - Written), pruning older snapshots for the same prefix (retains 2). Native TS on - BOTH paths now, on different call chains: - - **Remote path** (ported CLI-1958): after either apply branch - (schema-files or migrations) and seeding complete. Exports the target's pg-delta - catalog via the edge-runtime stack. Reuses `legacyExportCatalogPgDelta` and - `legacyTryCacheMigrationsCatalog` — the same helpers `db push` uses for its own - post-apply cache (see that command's SIDE_EFFECTS Notes) — rather than a second - copy. - - **Local path** (native since CLI-1955/2062, no Go child involved): the reused - `legacyStartSetupLocalDatabase` pipeline (`db-setup.ts`) calls the same - `legacyTryCacheMigrationsCatalog` (with prefix `"local"`) right after - `MigrateAndSeed` succeeds, warning the same way on failure. `reset.layers.ts` - composes `legacyEdgeRuntimeScriptLayer`/`legacyPgDeltaSslProbeLayer` for this — - the same pair `db start`/`db push` already compose for their own calls into the - same function. This only happens on the **PG15** recreate branch — the - **PG≤14** branch returns immediately after `MigrateAndSeed` and never calls - `legacyTryCacheMigrationsCatalog` at all, so a PG≤14 local project never writes - this file, no matter how pg-delta is configured. - `db schema declarative`/`db schema sync`'s own local-reset paths now call `legacyResetLocalDatabase` in-process too (CLI-2062) — the previous scope boundary (those two commands shelling out to a second `supabase-go` child via the now-removed diff --git a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts index c821dca15d..db0b1fd91d 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts @@ -14,8 +14,6 @@ import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.ser import { legacyAqua, legacyYellow } from "../../../shared/legacy-colors.ts"; import { legacyResolveResetSeedConfig } from "../../../shared/db-bootstrap/db-setup.ts"; import { legacyResetLocalDatabase } from "../../../shared/db-bootstrap/reset-local-database.ts"; -import { legacyParseBoolEnv } from "../../../shared/legacy-diff-engine.ts"; -import { redactLegacyConnectionString } from "../../../shared/legacy-db-config.parse.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import { legacyApplyProjectEnv, @@ -23,22 +21,13 @@ import { legacyLoadProjectEnv, } from "../../../shared/legacy-db-config.toml-read.ts"; import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; -import { - legacyResolveLocalProjectId, - legacySanitizeProjectId, -} from "../../../shared/legacy-docker-ids.ts"; import { legacyApplyMigrations, legacyApplySchemaFiles, } from "../../../shared/legacy-migration-apply.ts"; import { legacyParseMigrationVersion } from "../../../shared/legacy-migration-timestamp.format.ts"; -import { - legacyListLocalMigrations, - legacyTryCacheMigrationsCatalog, -} from "../../../shared/legacy-pgdelta.cache.ts"; -import { type LegacyPgDeltaContext } from "../../../shared/legacy-pgdelta.ts"; +import { legacyListLocalMigrations } from "../../../shared/legacy-migration-list.ts"; import { legacyPathMatch } from "../../../shared/legacy-path-match.ts"; -import { legacyToPostgresURL } from "../../../shared/legacy-postgres-url.ts"; import { resolveLegacyDbTargetFlags } from "../../../shared/legacy-db-target-flags.ts"; import { legacyGetPendingSeeds, legacySeedData } from "../../../shared/legacy-seed-ops.ts"; import { legacyUpsertVaultSecrets } from "../../../shared/legacy-vault.ts"; @@ -109,9 +98,9 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega const body = Effect.gen(function* () { // The project `.env` is applied to make every key visible to the WHOLE // reset run, not just the flag-gate reads above — in particular - // `legacyGetRegistryImageUrl` / `legacyPgDeltaNpmRegistryOption` read - // `SUPABASE_INTERNAL_IMAGE_REGISTRY` / `PGDELTA_NPM_REGISTRY` straight from - // `process.env` for the pg-delta catalog export below (review CLI-1958). `db push` + // `legacyGetRegistryImageUrl` reads + // `SUPABASE_INTERNAL_IMAGE_REGISTRY` straight from + // `process.env` for the container image resolution below (review CLI-1958). `db push` // (`push.handler.ts`) scopes this the same way, as the first statement of its own // `body` — mirror that exactly so a private/air-gapped registry configured only in // `supabase/.env` reaches the catalog export instead of silently falling back to the @@ -375,46 +364,6 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega ); yield* legacySeedData(session, fs, workdir, path, seeds, applyError); } - - // Best-effort caches the migrations catalog for pg-delta right after - // the migrate-and-seed step succeeds, warning (never failing the - // reset) on error. The cache call itself no-ops when `resolvedVersion` - // is non-empty — a versioned reset (`--version`/`--last`) never - // refreshes the cache — so gate the call the same way rather than - // threading that check into the shared native helper (already used - // by `db push`, which has no version concept). - const cacheEnabled = - resolvedVersion === "" && - (toml.pgDelta.enabled || - legacyParseBoolEnv(toml.envLookup("SUPABASE_EXPERIMENTAL_PG_DELTA"))); - const pgDeltaCtx: LegacyPgDeltaContext = { - projectId: legacySanitizeProjectId( - legacyResolveLocalProjectId( - Option.getOrUndefined(cliSettings.projectId), - Option.getOrUndefined(toml.projectId) ?? - (linkedRef !== undefined && linkedRef !== "" ? linkedRef : undefined), - workdir, - ), - ), - cwd: workdir, - npmVersion: Option.getOrUndefined(toml.pgDelta.npmVersion), - denoVersion: toml.denoVersion, - projectEnv: toml.projectEnv, - }; - yield* legacyTryCacheMigrationsCatalog(fs, path, pgDeltaCtx, { - enabled: cacheEnabled, - targetUrl: legacyToPostgresURL(cfg.conn), - conn: cfg.conn, - isLocal: false, - migrationsDir, - }).pipe( - Effect.catch((error) => - output.raw( - `Warning: failed to cache migrations catalog: ${redactLegacyConnectionString(error.message)}\n`, - "stderr", - ), - ), - ); }), ); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts index a72192b775..5d15c71660 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts @@ -41,11 +41,7 @@ import { } from "../../../../shared/legacy/global-flags.ts"; import type { OutputFormat } from "../../../../shared/output/types.ts"; import { legacyDockerRunLayer } from "../../../shared/legacy-docker-run.layer.ts"; -import { LegacyEdgeRuntimeScriptError } from "../../../shared/legacy-edge-runtime-script.errors.ts"; -import { - LegacyEdgeRuntimeScript, - type LegacyEdgeRuntimeRunOpts, -} from "../../../shared/legacy-edge-runtime-script.service.ts"; +import { LegacyEdgeRuntimeScript } from "../../../shared/legacy-edge-runtime-script.service.ts"; import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import type { @@ -440,8 +436,6 @@ function setup( failStatement?: { readonly sql: string; readonly code?: string; readonly message: string }; // pg-delta migrations-catalog cache, wired into the remote-reset path // after a successful migrate/schema-files + seed. - catalogStdout?: string; - catalogExportFailWith?: string; // Simulates a genuinely unlinked workdir: `loadProjectRef` fails with // `LegacyProjectNotLinkedError` absent an explicit `--project-ref` flag, // instead of silently falling back to `opts.ref ?? LEGACY_VALID_REF`. @@ -473,24 +467,8 @@ function setup( }); const route = opts.route ?? defaultLocalResetRoute(opts.routeOpts); const child = mockContainerCliSpawner(route); - // Backs both the local recreate's post-setup pg-delta migrations-catalog warmup - // (`db-setup.ts`'s `legacyTryCacheMigrationsCatalog`) and the remote path's own - // post-reset catalog-cache call — tracked so tests can assert on it directly - // (`edgeRunCalls`/`registryEnvAtRunTime`), same as `db push`'s own integration - // tests (`push.integration.test.ts`). - const edgeRunCalls: Array = []; - const registryEnvAtRunTime: Array = []; const edgeRuntime = Layer.succeed(LegacyEdgeRuntimeScript, { - run: (runOpts: LegacyEdgeRuntimeRunOpts) => { - edgeRunCalls.push(runOpts); - registryEnvAtRunTime.push(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]); - if (opts.catalogExportFailWith !== undefined) { - return Effect.fail( - new LegacyEdgeRuntimeScriptError({ message: opts.catalogExportFailWith }), - ); - } - return Effect.succeed({ stdout: opts.catalogStdout ?? '{"version":1}', stderr: "" }); - }, + run: () => Effect.succeed({ stdout: '{"version":1}', stderr: "" }), }); const pgDeltaSslProbe = Layer.succeed(LegacyPgDeltaSslProbe, { requireSsl: () => Effect.succeed(false), @@ -555,8 +533,6 @@ function setup( linkedCache, resolver, child, - edgeRunCalls, - registryEnvAtRunTime, }; } @@ -1694,132 +1670,6 @@ describe("legacy db reset", () => { }); }); - it.live( - "caches the migrations catalog after a successful remote reset with SUPABASE_EXPERIMENTAL_PG_DELTA set", - () => { - // Best-effort caches the pg-delta migrations catalog right after the - // migrate-and-seed step succeeds — gated on - // `experimental.pgdelta.enabled` OR the legacy - // `SUPABASE_EXPERIMENTAL_PG_DELTA` env switch, independent of - // `--experimental`'s own schema-files gate. - const { layer, out, edgeRunCalls } = setup(tmp.current, { - toml: 'project_id = "test"\n', - files: { - ...migrationFile("20240101000000"), - "supabase/.env": "SUPABASE_EXPERIMENTAL_PG_DELTA=true\n", - }, - confirm: [true], - }); - return Effect.gen(function* () { - yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - expect(edgeRunCalls).toHaveLength(1); - }); - }, - ); - - it.live( - "resolves the pg-delta cache export image via SUPABASE_INTERNAL_IMAGE_REGISTRY from supabase/.env", - () => { - // The project `.env` is applied to make a `supabase/.env`-only - // `SUPABASE_INTERNAL_IMAGE_REGISTRY` visible to the WHOLE reset run, - // including the pg-delta catalog export the reset handler triggers - // after a successful remote reset (review CLI-1958 round 18) — - // mirroring `db push`'s own `legacyApplyProjectEnv(projectEnv)` - // scoping (same-named test in `push.integration.test.ts`). Without - // that scoping, this reads only real `process.env` and falls back to - // the default registry instead. - const prev = process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; - delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; - const { layer, registryEnvAtRunTime } = setup(tmp.current, { - toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', - files: { - ...migrationFile("20240101000000"), - "supabase/.env": "SUPABASE_INTERNAL_IMAGE_REGISTRY=my-mirror.example.com\n", - }, - confirm: [true], - }); - return Effect.gen(function* () { - yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - expect(registryEnvAtRunTime).toEqual(["my-mirror.example.com"]); - // The finalizer reverted it — never leaks into the surrounding 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; - }), - ), - ); - }, - ); - - it.live("warns without failing the reset when the migrations-catalog cache write fails", () => { - const { layer, out } = setup(tmp.current, { - toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', - files: migrationFile("20240101000000"), - confirm: [true], - catalogExportFailWith: "edge-runtime script produced no output", - }); - return Effect.gen(function* () { - const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( - Effect.provide(layer), - Effect.exit, - ); - expect(Exit.isSuccess(exit)).toBe(true); - expect(out.stderrText).toContain( - "Warning: failed to cache migrations catalog: edge-runtime script produced no output", - ); - }); - }); - - it.live( - "falls back to the linked project ref for the pg-delta cache when config.toml has no project_id", - () => { - // The project id seeds from the ref BEFORE the config loads, so on - // the linked remote path an absent `project_id` retains the linked - // ref rather than falling to the workdir basename. - const { layer, out, edgeRunCalls } = setup(tmp.current, { - toml: "[experimental.pgdelta]\nenabled = true\n", - ref: LEGACY_VALID_REF, - files: migrationFile("20240101000000"), - confirm: [true], - }); - return Effect.gen(function* () { - yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - expect(edgeRunCalls).toHaveLength(1); - }); - }, - ); - - it.live( - "skips the migrations-catalog cache for a versioned remote reset even with pg-delta caching enabled", - () => { - // `pgcache.TryCacheMigrationsCatalog` no-ops on any non-empty `version` - // (`pgcache/cache.go:73`, `len(version) > 0`) — a `--version`/`--last` reset - // never refreshes the cache, unlike a full (versionless) reset. - const { layer, out, edgeRunCalls } = setup(tmp.current, { - toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', - files: { - ...migrationFile("20240101000000"), - ...migrationFile("20240202000000"), - }, - confirm: [true], - }); - return Effect.gen(function* () { - yield* legacyDbReset({ - ...DEFAULT_FLAGS, - linked: true, - version: Option.some("20240101000000"), - }).pipe(Effect.provide(layer)); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - expect(edgeRunCalls).toHaveLength(0); - }); - }, - ); - it.live( "applies configured schema files instead of replaying migrations on an experimental remote reset", () => { diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.flow.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.flow.ts index bdf22189e6..7755553e0f 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.flow.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.flow.ts @@ -1,4 +1,3 @@ -import type { LegacyPgDeltaImplementation } from "../../../../shared/legacy-pgdelta-next-flag.ts"; import { legacySchemaToCsvField } from "../../../../shared/legacy-schema-flags.ts"; import { legacyDeclaredSqlExtensions, @@ -73,13 +72,12 @@ const emptyCompatibilityGap = (): LegacyDeclarativeCompatibilityGap => ({ recommendedAction: "none", }); -/** Classifies manifest-less pg-delta next removals without performing any I/O. */ +/** Classifies manifest-less pg-delta removals without performing any I/O. */ export function legacyClassifyDeclarativeCompatibilityGap(opts: { - readonly implementation: LegacyPgDeltaImplementation; readonly manifestPresent: boolean; readonly removals: LegacyPgDeltaRemovalSummary; }): LegacyDeclarativeCompatibilityGap { - if (opts.implementation !== "next" || opts.manifestPresent) return emptyCompatibilityGap(); + if (opts.manifestPresent) return emptyCompatibilityGap(); const extensions = [...new Set(opts.removals.extensions)].sort(); const repairableExtensions = extensions.filter((extension) => @@ -199,15 +197,14 @@ function locateSignature( /** * Classifies known legacy implicit-extension misses that prevent a manifestless - * declarative tree from loading on pg-delta next's isolated desired shadow. + * declarative tree from loading on pg-delta's isolated desired shadow. */ export function legacyClassifyDeclarativeLoadCompatibility(opts: { - readonly implementation: LegacyPgDeltaImplementation; readonly manifestPresent: boolean; readonly diagnostics: readonly LegacyDeclarativeLoadDiagnostic[]; readonly files: readonly LegacyDeclarativeSqlFile[]; }): ReadonlyArray { - if (opts.implementation !== "next" || opts.manifestPresent) return []; + if (opts.manifestPresent) return []; const declared = declaredImplicitExtensions(opts.files); const findings: LegacyDeclarativeLoadCompatibilityFinding[] = []; diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.flow.unit.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.flow.unit.test.ts index 198f9459e2..a3b41457d8 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.flow.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.flow.unit.test.ts @@ -30,7 +30,6 @@ const classifyGap = ( overrides: Partial[0]> = {}, ) => legacyClassifyDeclarativeCompatibilityGap({ - implementation: "next", manifestPresent: false, removals, ...overrides, @@ -40,7 +39,6 @@ const classifyLoad = ( overrides: Partial[0]>, ) => legacyClassifyDeclarativeLoadCompatibility({ - implementation: "next", manifestPresent: false, diagnostics: [], files: [], @@ -81,11 +79,6 @@ describe("legacyClassifyDeclarativeCompatibilityGap", () => { overrides: { manifestPresent: true }, expected: { recommendedAction: "none" }, }, - { - name: "leaves legacy behavior unchanged", - overrides: { implementation: "legacy" as const }, - expected: { recommendedAction: "none" }, - }, { name: "ignores an empty removal set", overrides: { removals: { extensions: [], extensionIntents: [] } }, @@ -325,28 +318,23 @@ describe("legacyClassifyDeclarativeLoadCompatibility", () => { }); }); - it("requires next, no manifest, and an error-level non-converging diagnostic", () => { + it("requires no manifest and an error-level non-converging diagnostic", () => { const files = [{ name: "members.sql", sql: "select extensions.uuid_generate_v4();" }]; const diagnostic = stuck("members.sql: function extensions.uuid_generate_v4() does not exist"); const classify = ( - implementation: "legacy" | "next", manifestPresent: boolean, diagnostics: ReadonlyArray<{ code: string; severity: string; message: string }>, ) => classifyLoad({ - implementation, manifestPresent, diagnostics, files, }); - expect(classify("legacy", false, [diagnostic])).toEqual([]); - expect(classify("next", true, [diagnostic])).toEqual([]); - expect(classify("next", false, [{ ...diagnostic, severity: "warning" }])).toEqual([]); - expect(classify("next", false, [{ ...diagnostic, code: "invalid_routine_body" }])).toEqual([]); - expect(classify("next", false, [{ ...diagnostic, code: "max_rounds_exceeded" }])).toHaveLength( - 1, - ); + expect(classify(true, [diagnostic])).toEqual([]); + expect(classify(false, [{ ...diagnostic, severity: "warning" }])).toEqual([]); + expect(classify(false, [{ ...diagnostic, code: "invalid_routine_body" }])).toEqual([]); + expect(classify(false, [{ ...diagnostic, code: "max_rounds_exceeded" }])).toHaveLength(1); }); it("does not classify an extension already declared anywhere in the tree", () => { diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts index 0fda4fa2a3..6b1e177b2c 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts @@ -1,162 +1,25 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; -import { - mockLegacyShadowContainerCliSpawner, - useLegacyShadowCacheDisabled, -} from "../../../../../../tests/helpers/legacy-mocks.ts"; -import { alwaysReadyHttpClientLayer } from "../../../../../../tests/helpers/legacy-local-reset.ts"; -import { mockOutput, mockRuntimeInfo } from "../../../../../../tests/helpers/mocks.ts"; -import { CliArgs } from "../../../../../shared/cli/cli-args.service.ts"; -import { - LegacyDebugFlag, - LegacyExperimentalFlag, - LegacyNetworkIdFlag, -} from "../../../../../shared/legacy/global-flags.ts"; import type { LegacyDbTomlValues } from "../../../../shared/legacy-db-config.toml-read.ts"; -import { - LegacyDbConnection, - type LegacyDbSession, - type LegacyPgConnInput, -} from "../../../../shared/legacy-db-connection.service.ts"; -import { LegacyDockerRun } from "../../../../shared/legacy-docker-run.service.ts"; -import { - type LegacyEdgeRuntimeRunOpts, - LegacyEdgeRuntimeScript, -} from "../../../../shared/legacy-edge-runtime-script.service.ts"; -import { LegacyPgDeltaSslProbe } from "../../../../shared/legacy-pgdelta-ssl-probe.service.ts"; -import { legacyPgDeltaLegacyEngineLayer } from "../../shared/legacy-pgdelta-engine.legacy.layer.ts"; import { LegacyPgDeltaEngine, type LegacyPgDeltaDeclarativePlanInput, } from "../../shared/legacy-pgdelta-engine.service.ts"; -import { - legacyBaselineCatalogFileName, - legacyBaselineCatalogKey, - legacyHashMigrations, - legacyMigrationCatalogFileName, - legacyMigrationsCatalogCacheKey, - legacySetupInputsToken, - type LegacySetupInputs, -} from "../../../../shared/legacy-pgdelta.cache.ts"; -import { - type LegacyCatalogMode, - LegacyDeclarativeSeam, -} from "../../shared/legacy-pgdelta.seam.service.ts"; import { type LegacyDeclarativeRunContext, legacyDiffDeclarativeToMigrations, legacyGenerateDeclarativeOutput, } from "./declarative.orchestrate.ts"; -function mockSeam(paths: Record) { - const calls: Array<{ mode: LegacyCatalogMode; noCache: boolean }> = []; - const layer = Layer.succeed(LegacyDeclarativeSeam, { - exportCatalog: ({ mode, noCache }) => { - calls.push({ mode, noCache }); - return Effect.succeed(paths[mode]); - }, - ensureLocalDatabaseStarted: () => Effect.void, - ensureLocalPostgresImageCurrent: () => Effect.void, - }); - return { layer, calls }; -} - -/** - * The native shadow-provisioning stack `legacyGetMigrationsCatalogRef`'s - * cache-miss path needs (CLI-1956): the SAME `legacyCreateShadowDatabase`/ - * `legacyPrepareShadowSource`/`legacyRemoveShadowDatabase` primitives `db diff`/ - * `db pull` use for their own shadow, not the retired `db __shadow` seam — see - * `legacy-pgdelta.cache.ts`'s `exportViaShadowCatalog` doc comment. Mirrors - * `diff.integration.test.ts`'s own shadow mocks (`mockLegacyShadowContainerCliSpawner` - * + a fake `LegacyDbConnection`/`LegacyDockerRun`), scoped down to this file's - * lower-level, seam-free tests. - */ -function mockShadowInfra() { - const spawner = mockLegacyShadowContainerCliSpawner(); - const connectedDatabases: Array = []; - const dbConnection = Layer.succeed(LegacyDbConnection, { - connect: (cfg: LegacyPgConnInput) => - Effect.sync(() => { - connectedDatabases.push(cfg.database); - const session: LegacyDbSession = { - exec: () => Effect.void, - execBatch: () => Effect.void, - query: () => Effect.succeed([]), - extensionExists: () => Effect.succeed(false), - copyToCsv: () => Effect.succeed(new Uint8Array()), - queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), - }; - return session; - }), - }); - // The shadow's own PG15+ one-shot platform-baseline job(s) — Go's `initSchema15`. - const docker = Layer.succeed(LegacyDockerRun, { - run: () => Effect.die("run unused"), - runCapture: () => Effect.die("runCapture unused"), - runStream: () => Effect.succeed({ exitCode: 0, stderr: "" }), - }); - const layer = Layer.mergeAll( - spawner.layer, - dbConnection, - docker, - mockRuntimeInfo(), - Layer.succeed(LegacyNetworkIdFlag, Option.none()), - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(LegacyExperimentalFlag, false), - Layer.succeed(CliArgs, { args: [] }), - alwaysReadyHttpClientLayer, - ); - return { layer, spawned: spawner.spawned, connectedDatabases }; -} - -function mockEdge(stdout: string) { - const calls: LegacyEdgeRuntimeRunOpts[] = []; - const layer = Layer.succeed(LegacyEdgeRuntimeScript, { - run: (opts: LegacyEdgeRuntimeRunOpts) => { - calls.push(opts); - // The catalog-export script (uniquely identified by its errPrefix) backs the - // native migrations-catalog resolution's shadow export — return a fixed, - // non-empty snapshot so it never trips `legacyExportCatalogPgDelta`'s - // empty-output check regardless of what `stdout` the diff/export scripts use. - if (opts.errPrefix === "error exporting pg-delta catalog") { - return Effect.succeed({ stdout: '{"schemas":[]}', stderr: "" }); - } - // The pg-delta diff script (uniquely identified by `renderPlanFiles`) prints a - // JSON envelope with one file per plan unit; wrap the test's raw SQL into a - // single-unit envelope so `legacyDiffPgDelta` parses it. Other scripts - // (declarative export) return their stdout unchanged. - const wrapped = - opts.script.includes("renderPlanFiles") && stdout.length > 0 - ? JSON.stringify({ - version: 1, - files: [ - { order: 1, name: "schema_changes", transactionMode: "transactional", sql: stdout }, - ], - }) - : stdout; - return Effect.succeed({ stdout: wrapped, stderr: "" }); - }, - }); - return { layer, calls }; -} - -// Remote refs in these tests are non-Supabase hosts that refuse TLS → probe -// reports "not required", so no CA bundle/SSL env is injected. -const probe = Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), -}); - const ctx = (cwd: string, declarativeDir: string): LegacyDeclarativeRunContext => ({ pgDelta: { projectId: "cferry", cwd, - npmVersion: undefined, denoVersion: 2, projectEnv: {}, }, @@ -170,15 +33,43 @@ const ctx = (cwd: string, declarativeDir: string): LegacyDeclarativeRunContext = dnsResolver: "native", }); -const engineLayer = ( - seam: Layer.Layer, - edge: Layer.Layer, - output: ReturnType["layer"], - runtime: ReturnType["layer"], -) => - legacyPgDeltaLegacyEngineLayer.pipe( - Layer.provide(Layer.mergeAll(seam, edge, probe, output, BunServices.layer, runtime)), - ); +// A minimal, valid `LegacyDbTomlValues` — matches `legacy-db-config.toml-read.ts`'s +// own unconfigured defaults so this fixture doesn't silently drift from what +// `legacyReadDbToml` would resolve for these tests' bare temp dirs (none of them +// write a `config.toml`). +const toml: LegacyDbTomlValues = { + projectEnv: {}, + envLookup: () => undefined, + apiSchemas: ["public", "graphql_public"], + port: 54322, + shadowPort: 54320, + password: "postgres", + poolerConnectionString: Option.none(), + projectId: Option.none(), + majorVersion: 17, + orioledbVersion: Option.none(), + denoVersion: 2, + pgDelta: { + enabled: false, + declarativeSchemaPath: Option.none(), + formatOptions: Option.none(), + }, + webhooksEnabled: false, + baseline: { + authEnabled: true, + storageEnabled: true, + realtimeEnabled: true, + apiAutoExposeNewTables: Option.none(), + vaultNames: [], + }, + migrationsEnabled: true, + schemaPaths: [], + schemaPathPatterns: [], + seed: { enabled: true, sqlPaths: [] }, + vault: [], + appliedRemote: undefined, + remoteOverrideKeys: new Set(), +}; describe("legacyDiffDeclarativeToMigrations", () => { it.effect("loads nested SQL and its manifest in stable order for the engine", () => { @@ -196,7 +87,6 @@ describe("legacyDiffDeclarativeToMigrations", () => { const engine = Layer.succeed( LegacyPgDeltaEngine, LegacyPgDeltaEngine.of({ - implementation: "next", diffExplicit: () => Effect.die("diffExplicit not used"), diffDatabase: () => Effect.die("diffDatabase not used"), exportDeclarativeSchema: () => Effect.die("exportDeclarativeSchema not used"), @@ -232,7 +122,6 @@ describe("legacyDiffDeclarativeToMigrations", () => { return legacyDiffDeclarativeToMigrations( { ...ctx(dir, declDir), debug: true, noCache: true, strictCoverage: true }, toml, - setupInputs, ).pipe( Effect.tap((result) => Effect.sync(() => { @@ -259,18 +148,10 @@ describe("legacyDiffDeclarativeToMigrations", () => { ); }); - // The legacy engine's `planDeclarativeSchema` never looks at `input.manifest`, so - // validating the manifest for it turned a stale/hand-edited `.pgdelta-export.json` - // into a hard failure of the documented `SUPABASE_USE_PG_DELTA_NEXT=false` escape - // hatch. The next engine, which does consume it, must still reject it. - const stubEngine = ( - implementation: "legacy" | "next", - calls: LegacyPgDeltaDeclarativePlanInput[], - ) => + const stubEngine = (calls: LegacyPgDeltaDeclarativePlanInput[]) => Layer.succeed( LegacyPgDeltaEngine, LegacyPgDeltaEngine.of({ - implementation, diffExplicit: () => Effect.die("diffExplicit not used"), diffDatabase: () => Effect.die("diffDatabase not used"), exportDeclarativeSchema: () => Effect.die("exportDeclarativeSchema not used"), @@ -287,38 +168,14 @@ describe("legacyDiffDeclarativeToMigrations", () => { }), ); - const withCorruptManifest = () => { + it.effect("rejects a corrupt export manifest before planning", () => { const dir = mkdtempSync(join(tmpdir(), "legacy-decl-orch-")); const declDir = join(dir, "supabase", "database"); mkdirSync(declDir, { recursive: true }); writeFileSync(join(declDir, "public.sql"), "create table public.accounts();"); writeFileSync(join(declDir, ".pgdelta-export.json"), "{ not json at all"); - return { dir, declDir }; - }; - - it.effect("ignores a corrupt export manifest under the legacy engine opt-out", () => { - const { dir, declDir } = withCorruptManifest(); const calls: LegacyPgDeltaDeclarativePlanInput[] = []; - return legacyDiffDeclarativeToMigrations(ctx(dir, declDir), toml, setupInputs).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(calls[0]?.files).toEqual([ - { name: "public.sql", sql: "create table public.accounts();" }, - ]); - expect(calls[0]?.manifest).toBeUndefined(); - expect(result.manifestPresent).toBe(false); - expect(result.diffSQL).toBe("create table public.accounts();"); - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide(Layer.mergeAll(stubEngine("legacy", calls), BunServices.layer)), - ); - }); - - it.effect("still rejects a corrupt export manifest under the next engine", () => { - const { dir, declDir } = withCorruptManifest(); - const calls: LegacyPgDeltaDeclarativePlanInput[] = []; - return legacyDiffDeclarativeToMigrations(ctx(dir, declDir), toml, setupInputs).pipe( + return legacyDiffDeclarativeToMigrations(ctx(dir, declDir), toml).pipe( Effect.exit, Effect.tap((exit) => Effect.sync(() => { @@ -333,479 +190,14 @@ describe("legacyDiffDeclarativeToMigrations", () => { rmSync(dir, { recursive: true, force: true }); }), ), - Effect.provide(Layer.mergeAll(stubEngine("next", calls), BunServices.layer)), + Effect.provide(Layer.mergeAll(stubEngine(calls), BunServices.layer)), ); }); -}); - -// A minimal, valid `LegacySetupInputs` — the exact field values don't matter to -// these tests (they only exercise the cache-miss/shadow-provision path), only -// that a real cache key can be derived from them. -const setupInputs: LegacySetupInputs = { - image: "supabase/postgres:17.6.1.135", - majorVersion: 17, - authEnabled: true, - storageEnabled: true, - realtimeEnabled: true, - autoExpose: true, - vaultNames: [], - rolesSql: "", -}; -// A minimal, valid `LegacyDbTomlValues` — threaded into `legacyGetMigrationsCatalogRef` -// for the migrations-catalog shadow's own container spec (CLI-1956). Matches -// `legacy-db-config.toml-read.ts`'s own unconfigured defaults so this fixture -// doesn't silently drift from what `legacyReadDbToml` would resolve for these -// tests' bare temp dirs (none of them write a `config.toml`). -const toml: LegacyDbTomlValues = { - projectEnv: {}, - envLookup: () => undefined, - apiSchemas: ["public", "graphql_public"], - port: 54322, - shadowPort: 54320, - password: "postgres", - poolerConnectionString: Option.none(), - projectId: Option.none(), - majorVersion: 17, - orioledbVersion: Option.none(), - denoVersion: 2, - pgDelta: { - enabled: false, - declarativeSchemaPath: Option.none(), - formatOptions: Option.none(), - npmVersion: Option.none(), - }, - webhooksEnabled: false, - baseline: { - authEnabled: true, - storageEnabled: true, - realtimeEnabled: true, - apiAutoExposeNewTables: Option.none(), - vaultNames: [], - }, - migrationsEnabled: true, - schemaPaths: [], - schemaPathPatterns: [], - seed: { enabled: true, sqlPaths: [] }, - vault: [], - appliedRemote: undefined, - remoteOverrideKeys: new Set(), -}; - -describe("legacyDiffDeclarativeToMigrations", () => { - useLegacyShadowCacheDisabled(); - it.effect( - "resolves the migrations catalog natively and diffs it against the seam-provisioned declarative catalog", - () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-decl-orch-")); - const declDir = join(dir, "supabase", "database"); - mkdirSync(declDir, { recursive: true }); - const seam = mockSeam({ - declarative: "supabase/.temp/pgdelta/decl.json", - baseline: "supabase/.temp/pgdelta/base.json", - }); - const edge = mockEdge("ALTER TABLE x ADD COLUMN y int;\nDROP TABLE z;\n"); - const out = mockOutput(); - const shadow = mockShadowInfra(); - return legacyDiffDeclarativeToMigrations(ctx(dir, declDir), toml, setupInputs).pipe( - Effect.tap((result) => - Effect.sync(() => { - // "declarative" still resolves via the seam; "migrations" no longer does - // (it resolves natively, provisioning its shadow the same way `db diff`/ - // `db pull` do — CLI-1956). - expect(seam.calls.map((c) => c.mode)).toEqual(["declarative"]); - expect(shadow.spawned.filter((c) => c.args[0] === "create")).toHaveLength(1); - expect(shadow.spawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); - // No local migrations in the fresh temp dir → the zero-migrations branch - // writes (and returns) the platform-baseline catalog, workdir-relative. - expect(result.sourceRef).toMatch( - /^supabase[/\\]\.temp[/\\]pgdelta[/\\]catalog-baseline-.*\.json$/, - ); - expect(readFileSync(join(dir, result.sourceRef), "utf8")).toBe('{"schemas":[]}'); - expect(result.targetRef).toBe("supabase/.temp/pgdelta/decl.json"); - expect(result.diffSQL).toContain("ALTER TABLE x"); - expect(result.dropWarnings).toEqual(["DROP TABLE z"]); - // The edge-runtime diff received the migrations ref (workdir-relative, - // mapped to /workspace) and the seam's declarative ref as SOURCE/TARGET. - const diffCall = edge.calls.find((c) => c.script.includes("renderPlanFiles")); - expect(diffCall?.env["SOURCE"]).toBe(`/workspace/${result.sourceRef}`); - expect(diffCall?.env["TARGET"]).toBe("/workspace/supabase/.temp/pgdelta/decl.json"); - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide( - Layer.mergeAll( - seam.layer, - edge.layer, - probe, - out.layer, - engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), - BunServices.layer, - shadow.layer, - ), - ), - ); - }, - ); - - // `--strict-coverage` is enforced entirely by the next engine's diagnostic report; - // the legacy engine has no coverage diagnostics, so the flag silently did nothing - // under `SUPABASE_USE_PG_DELTA_NEXT=false`. It must say so instead. - const runWithStrictCoverageOnLegacyEngine = () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-decl-orch-")); - const declDir = join(dir, "supabase", "database"); - mkdirSync(declDir, { recursive: true }); - const seam = mockSeam({ - declarative: "supabase/.temp/pgdelta/decl.json", - baseline: "supabase/.temp/pgdelta/base.json", - }); - const edge = mockEdge("ALTER TABLE x ADD COLUMN y int;\n"); - const out = mockOutput(); - const shadow = mockShadowInfra(); - return { - dir, - out, - effect: legacyDiffDeclarativeToMigrations( - { ...ctx(dir, declDir), strictCoverage: true }, - toml, - setupInputs, - ).pipe( - Effect.provide( - Layer.mergeAll( - seam.layer, - edge.layer, - probe, - out.layer, - engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), - BunServices.layer, - shadow.layer, - ), - ), - ), - }; - }; - - it.effect("warns that --strict-coverage does nothing on the legacy engine", () => { - const { dir, out, effect } = runWithStrictCoverageOnLegacyEngine(); - return effect.pipe( - Effect.tap(() => - Effect.sync(() => { - expect(out.stderrText).toContain( - '"--strict-coverage" has no effect with the legacy pg-delta engine.', - ); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); - - it.effect( - "reuses an already-warmed platform-baseline catalog without provisioning a shadow", - () => { - // A baseline catalog pre-warmed by a prior generate/sync run (same setup - // inputs, still zero local migrations) must be reused as-is — this is the - // whole point of the zero-migrations special case in - // `legacyGetMigrationsCatalogRef` (mirrors Go's `getMigrationsCatalogRef`, - // `declarative.go:380-392`). - const dir = mkdtempSync(join(tmpdir(), "legacy-decl-orch-")); - const declDir = join(dir, "supabase", "database"); - mkdirSync(declDir, { recursive: true }); - const tempDir = join(dir, "supabase", ".temp", "pgdelta"); - mkdirSync(tempDir, { recursive: true }); - const baselineKey = legacyBaselineCatalogKey(setupInputs); - const baselinePath = join(tempDir, legacyBaselineCatalogFileName(baselineKey)); - writeFileSync(baselinePath, '{"warmed":true}'); - const seam = mockSeam({ - declarative: "supabase/.temp/pgdelta/decl.json", - baseline: "supabase/.temp/pgdelta/base.json", - }); - const edge = mockEdge("ALTER TABLE x;\n"); - const out = mockOutput(); - const shadow = mockShadowInfra(); - return legacyDiffDeclarativeToMigrations(ctx(dir, declDir), toml, setupInputs).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(shadow.spawned).toEqual([]); - expect(result.sourceRef).toBe( - join("supabase", ".temp", "pgdelta", `catalog-baseline-${baselineKey}.json`), - ); - expect(readFileSync(baselinePath, "utf8")).toBe('{"warmed":true}'); - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide( - Layer.mergeAll( - seam.layer, - edge.layer, - probe, - out.layer, - engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), - BunServices.layer, - shadow.layer, - ), - ), - ); - }, - ); - - it.effect( - "fails when the zero-migrations baseline cache probe itself fails, before any shadow work", - () => { - // A probe failure that isn't not-found (permissions, I/O under `.temp/pgdelta`) must - // propagate — matching Go's `getMigrationsCatalogRef` returning the `afero.Exists` - // error immediately — instead of being converted into a cache miss that provisions a - // Docker shadow and only surfaces the filesystem problem at the eventual write to the - // same location (codex review, PR #6162). - const dir = mkdtempSync(join(tmpdir(), "legacy-decl-orch-")); - const declDir = join(dir, "supabase", "database"); - mkdirSync(declDir, { recursive: true }); - const baselineFileName = legacyBaselineCatalogFileName(legacyBaselineCatalogKey(setupInputs)); - const seam = mockSeam({ - declarative: "supabase/.temp/pgdelta/decl.json", - baseline: "supabase/.temp/pgdelta/base.json", - }); - const edge = mockEdge("ALTER TABLE x;\n"); - const out = mockOutput(); - const shadow = mockShadowInfra(); - // Wraps the real Bun `FileSystem` so only the baseline probe fails, with a genuine - // `PlatformError` (same construction as the cache unit tests' failing-fs fakes). - // Merged LAST so it overrides `BunServices.layer`'s own `FileSystem`. - const failingFsLayer = Layer.effect( - FileSystem.FileSystem, - Effect.gen(function* () { - const real = yield* FileSystem.FileSystem; - const err = yield* real.readDirectory(join(dir, "does-not-exist")).pipe(Effect.flip); - const failing: FileSystem.FileSystem = { - ...real, - exists: (p) => (p.endsWith(baselineFileName) ? Effect.fail(err) : real.exists(p)), - }; - return failing; - }), - ).pipe(Layer.provide(BunServices.layer)); - return legacyDiffDeclarativeToMigrations(ctx(dir, declDir), toml, setupInputs).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - // The whole point: the failure surfaces BEFORE any Docker side effect. - expect(shadow.spawned).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide( - Layer.mergeAll( - BunServices.layer, - seam.layer, - edge.layer, - probe, - out.layer, - shadow.layer, - legacyPgDeltaLegacyEngineLayer.pipe( - Layer.provide( - Layer.mergeAll( - seam.layer, - edge.layer, - probe, - out.layer, - BunServices.layer, - shadow.layer, - failingFsLayer, - ), - ), - ), - failingFsLayer, - ), - ), - ); - }, - ); - - it.effect( - "with local migrations present and cache enabled, provisions a shadow and caches the resulting catalog", - () => { - // The dominant real-world code path (a project WITH local migrations, cache - // enabled) — `legacyGetMigrationsCatalogRef`'s cache-miss/non-zero-migrations - // branch (declarative.go:393-430) — was previously never exercised by any - // test; every other test here uses a fresh temp dir with zero migrations. - const dir = mkdtempSync(join(tmpdir(), "legacy-decl-orch-")); - const declDir = join(dir, "supabase", "database"); - mkdirSync(declDir, { recursive: true }); - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - writeFileSync(join(migrationsDir, "20240101000000_init.sql"), "create table a();\n"); - const seam = mockSeam({ - declarative: "supabase/.temp/pgdelta/decl.json", - baseline: "supabase/.temp/pgdelta/base.json", - }); - const edge = mockEdge("ALTER TABLE x ADD COLUMN y int;\n"); - const out = mockOutput(); - const shadow = mockShadowInfra(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const migrationsHash = yield* legacyHashMigrations(fs, path, dir, migrationsDir); - const key = legacyMigrationsCatalogCacheKey( - legacySetupInputsToken(setupInputs), - migrationsHash, - ); - const result = yield* legacyDiffDeclarativeToMigrations( - ctx(dir, declDir), - toml, - setupInputs, - ); - expect(result.sourceRef).toMatch( - new RegExp( - `^supabase[/\\\\]\\.temp[/\\\\]pgdelta[/\\\\]catalog-local-migrations-${key}-\\d+\\.json$`, - ), - ); - expect(readFileSync(join(dir, result.sourceRef), "utf8")).toBe('{"schemas":[]}'); - expect(out.stderrText).toContain("Creating shadow database...\n"); - expect(shadow.spawned.filter((c) => c.args[0] === "create")).toHaveLength(1); - expect(shadow.spawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - seam.layer, - edge.layer, - probe, - out.layer, - engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), - BunServices.layer, - shadow.layer, - ), - ), - ); - }, - ); - - it.effect( - "reuses an already-cached migrations catalog for local migrations without provisioning a new shadow", - () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-decl-orch-")); - const declDir = join(dir, "supabase", "database"); - mkdirSync(declDir, { recursive: true }); - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - writeFileSync(join(migrationsDir, "20240101000000_init.sql"), "create table a();\n"); - const tempDir = join(dir, "supabase", ".temp", "pgdelta"); - mkdirSync(tempDir, { recursive: true }); - const seam = mockSeam({ - declarative: "supabase/.temp/pgdelta/decl.json", - baseline: "supabase/.temp/pgdelta/base.json", - }); - const edge = mockEdge("ALTER TABLE x;\n"); - const out = mockOutput(); - const shadow = mockShadowInfra(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const migrationsHash = yield* legacyHashMigrations(fs, path, dir, migrationsDir); - const key = legacyMigrationsCatalogCacheKey( - legacySetupInputsToken(setupInputs), - migrationsHash, - ); - const cachedPath = join( - tempDir, - legacyMigrationCatalogFileName("local", key, 1_700_000_000_000), - ); - writeFileSync(cachedPath, '{"cached":true}'); - const result = yield* legacyDiffDeclarativeToMigrations( - ctx(dir, declDir), - toml, - setupInputs, - ); - expect(result.sourceRef).toBe(path.relative(dir, cachedPath)); - expect(readFileSync(cachedPath, "utf8")).toBe('{"cached":true}'); - expect(shadow.spawned).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - seam.layer, - edge.layer, - probe, - out.layer, - engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), - BunServices.layer, - shadow.layer, - ), - ), - ); - }, - ); - - it.effect( - "--no-cache ignores an already-cached migrations catalog, provisions a fresh shadow, and writes catalog-nocache-migrations.json", - () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-decl-orch-")); - const declDir = join(dir, "supabase", "database"); - mkdirSync(declDir, { recursive: true }); - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - writeFileSync(join(migrationsDir, "20240101000000_init.sql"), "create table a();\n"); - const tempDir = join(dir, "supabase", ".temp", "pgdelta"); - mkdirSync(tempDir, { recursive: true }); - const seam = mockSeam({ - declarative: "supabase/.temp/pgdelta/decl.json", - baseline: "supabase/.temp/pgdelta/base.json", - }); - const edge = mockEdge("ALTER TABLE x;\n"); - const out = mockOutput(); - const shadow = mockShadowInfra(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - // Pre-warm the cache entry that a cache-enabled run would hit, proving - // --no-cache really skips the lookup rather than merely never having - // written that entry. - const migrationsHash = yield* legacyHashMigrations(fs, path, dir, migrationsDir); - const key = legacyMigrationsCatalogCacheKey( - legacySetupInputsToken(setupInputs), - migrationsHash, - ); - const cachedPath = join( - tempDir, - legacyMigrationCatalogFileName("local", key, 1_700_000_000_000), - ); - writeFileSync(cachedPath, '{"cached":true}'); - const result = yield* legacyDiffDeclarativeToMigrations( - { ...ctx(dir, declDir), noCache: true }, - toml, - setupInputs, - ); - expect(result.sourceRef).toBe( - join("supabase", ".temp", "pgdelta", "catalog-nocache-migrations.json"), - ); - expect(readFileSync(join(dir, result.sourceRef), "utf8")).toBe('{"schemas":[]}'); - expect(shadow.spawned.filter((c) => c.args[0] === "create")).toHaveLength(1); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - seam.layer, - edge.layer, - probe, - out.layer, - engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), - BunServices.layer, - shadow.layer, - ), - ), - ); - }, - ); it.effect("fails when the declarative dir is absent", () => { const dir = mkdtempSync(join(tmpdir(), "legacy-decl-orch-")); - const seam = mockSeam({ declarative: "d", baseline: "b" }); - const edge = mockEdge(""); - const out = mockOutput(); - const shadow = mockShadowInfra(); - return legacyDiffDeclarativeToMigrations( - ctx(dir, join(dir, "missing")), - toml, - setupInputs, - ).pipe( + const calls: LegacyPgDeltaDeclarativePlanInput[] = []; + return legacyDiffDeclarativeToMigrations(ctx(dir, join(dir, "missing")), toml).pipe( Effect.exit, Effect.tap((exit) => Effect.sync(() => { @@ -816,45 +208,29 @@ describe("legacyDiffDeclarativeToMigrations", () => { "No declarative schema directory found", ); } - expect(seam.calls).toEqual([]); - expect(shadow.spawned).toEqual([]); + expect(calls).toEqual([]); rmSync(dir, { recursive: true, force: true }); }), ), - Effect.provide( - Layer.mergeAll( - seam.layer, - edge.layer, - probe, - out.layer, - engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), - BunServices.layer, - shadow.layer, - ), - ), + Effect.provide(Layer.mergeAll(stubEngine(calls), BunServices.layer)), ); }); }); describe("legacyGenerateDeclarativeOutput", () => { - it.effect("propagates debug, no-cache, and strict coverage to the selected engine", () => { + it.effect("propagates debug and strict coverage to the engine", () => { const calls: Array<{ readonly debug: boolean; - readonly noCache: boolean; - readonly sourceRef: string | undefined; readonly strictCoverage: boolean; }> = []; const engine = Layer.succeed( LegacyPgDeltaEngine, LegacyPgDeltaEngine.of({ - implementation: "next", diffExplicit: () => Effect.die("diffExplicit not used"), diffDatabase: () => Effect.die("diffDatabase not used"), exportDeclarativeSchema: (input) => { calls.push({ debug: input.debug, - noCache: input.noCache, - sourceRef: input.source?.ref, strictCoverage: input.strictCoverage, }); return Effect.succeed({ files: [] }); @@ -863,8 +239,6 @@ describe("legacyGenerateDeclarativeOutput", () => { }), ); const dir = mkdtempSync(join(tmpdir(), "legacy-decl-export-")); - const shadow = mockShadowInfra(); - const out = mockOutput(); return legacyGenerateDeclarativeOutput( { ...ctx(dir, join(dir, "supabase", "database")), @@ -872,7 +246,6 @@ describe("legacyGenerateDeclarativeOutput", () => { noCache: true, strictCoverage: true, }, - toml, { kind: "database", ref: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", @@ -884,64 +257,13 @@ describe("legacyGenerateDeclarativeOutput", () => { expect(calls).toEqual([ { debug: true, - noCache: true, - sourceRef: undefined, strictCoverage: true, }, ]); - expect(shadow.spawned).toEqual([]); rmSync(dir, { recursive: true, force: true }); }), ), - Effect.provide(Layer.mergeAll(engine, out.layer, BunServices.layer, shadow.layer)), - ); - }); - - it.effect("diffs a native raw shadow against the live DB and returns files", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-decl-export-")); - const seam = mockSeam({ - declarative: "d", - baseline: "supabase/.temp/pgdelta/base.json", - }); - const payload = { - version: 1, - mode: "declarative", - files: [{ path: "public.sql", order: 0, statements: 1, sql: "create table a();" }], - }; - const edge = mockEdge(JSON.stringify(payload)); - const out = mockOutput(); - const shadow = mockShadowInfra(); - return legacyGenerateDeclarativeOutput(ctx(dir, join(dir, "supabase", "database")), toml, { - kind: "database", - ref: "postgresql://postgres:postgres@127.0.0.1:54322/postgres?connect_timeout=10", - connectOptions: { isLocal: true, dnsResolver: "native" }, - }).pipe( - Effect.tap((output) => - Effect.sync(() => { - expect(seam.calls).toEqual([]); - expect(output.files[0]?.name).toBe("public.sql"); - expect(edge.calls[0]!.env["SOURCE"]).toBe( - "postgresql://postgres:postgres@127.0.0.1:54320/postgres?connect_timeout=10", - ); - expect(edge.calls[0]!.env["TARGET"]).toBe( - "postgresql://postgres:postgres@127.0.0.1:54322/postgres?connect_timeout=10", - ); - expect(shadow.spawned.filter((call) => call.args[0] === "create")).toHaveLength(1); - expect(shadow.spawned.filter((call) => call.args[0] === "rm")).toHaveLength(1); - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide( - Layer.mergeAll( - seam.layer, - edge.layer, - probe, - out.layer, - engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), - BunServices.layer, - shadow.layer, - ), - ), + Effect.provide(Layer.mergeAll(engine, BunServices.layer)), ); }); }); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.ts index d065b1bf5a..64b994bc3b 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.ts @@ -1,20 +1,6 @@ import { Effect, FileSystem, Path } from "effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import { - LegacyNetworkIdFlag, - legacyResolveDebugWithProjectEnv, -} from "../../../../../shared/legacy/global-flags.ts"; -import { RuntimeInfo } from "../../../../../shared/runtime/runtime-info.service.ts"; -import { legacyBuildLocalDbContainerInputs } from "../../../../shared/db-bootstrap/local-container-inputs.ts"; -import { - legacyCreateShadowDatabase, - legacyPrepareRawShadow, - legacyRemoveShadowDatabase, - legacyShadowRunInputFromLocalContainerInputs, -} from "../../../../shared/db-bootstrap/shadow-database.ts"; import type { LegacyPgDeltaContext } from "../../../../shared/legacy-pgdelta.ts"; -import type { LegacySetupInputs } from "../../../../shared/legacy-pgdelta.cache.ts"; import type { LegacyDbTomlValues } from "../../../../shared/legacy-db-config.toml-read.ts"; import { legacyFindDropStatements } from "../../../../shared/legacy-sql-split.ts"; import { @@ -88,15 +74,12 @@ const formatImplicitExtensionLoadFailure = ( /** * Computes the diff between local migrations state and the declarative schema. - * Mirrors Go's `DiffDeclarativeToMigrations` (`declarative.go:170`): the - * selected pg-delta engine owns both sides of the plan. The legacy engine - * resolves migrations natively via `legacyGetMigrationsCatalogRef` (CLI-1959), - * while pg-delta next plans against its scoped migrations/declarative shadows. + * The pg-delta engine owns both sides of the plan, planning against its scoped + * migrations/declarative shadows. */ export const legacyDiffDeclarativeToMigrations = Effect.fnUntraced(function* ( run: LegacyDeclarativeRunContext, toml: LegacyDbTomlValues, - setupInputs: LegacySetupInputs, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -112,20 +95,10 @@ export const legacyDiffDeclarativeToMigrations = Effect.fnUntraced(function* ( const files = yield* LegacyLoadPgDeltaSqlFiles(fs, path, run.declarativeDir).pipe( Effect.mapError((error) => declarativeError(error.message)), ); - // Only the next engine consumes the export manifest (its planner reads ownership - // metadata from it); the legacy engine's `planDeclarativeSchema` ignores - // `input.manifest` entirely. Reading it unconditionally made the strict manifest - // validation (`LegacyReadPgDeltaExportManifest` fails on malformed JSON or missing - // policy metadata) fail a legacy-engine sync over a file the legacy planner never - // looks at, defeating the `SUPABASE_USE_PG_DELTA_NEXT=false` escape hatch. Under - // the legacy engine the manifest is treated as absent, exactly as if the file did - // not exist. - const manifest = - engine.implementation === "next" - ? yield* LegacyReadPgDeltaExportManifest(fs, path, run.declarativeDir).pipe( - Effect.mapError((error) => declarativeError(error.message)), - ) - : undefined; + // The planner reads ownership metadata from the export manifest when present. + const manifest = yield* LegacyReadPgDeltaExportManifest(fs, path, run.declarativeDir).pipe( + Effect.mapError((error) => declarativeError(error.message)), + ); const result = yield* engine .planDeclarativeSchema({ context: run.pgDelta, @@ -136,14 +109,12 @@ export const legacyDiffDeclarativeToMigrations = Effect.fnUntraced(function* ( files, noCache: run.noCache, toml, - setupInputs, ...(run.linkedProjectRef !== undefined ? { projectRef: run.linkedProjectRef } : {}), ...(manifest !== undefined ? { manifest } : {}), }) .pipe( Effect.mapError((error) => { const findings = legacyClassifyDeclarativeLoadCompatibility({ - implementation: engine.implementation, manifestPresent: manifest !== undefined, diagnostics: error.diagnostics ?? [], files, @@ -163,7 +134,7 @@ export const legacyDiffDeclarativeToMigrations = Effect.fnUntraced(function* ( sourceRef: result.sourceRef, targetRef: result.targetRef, dropWarnings: - engine.implementation === "next" && result.hazards !== undefined + result.hazards !== undefined ? result.hazards.dataLoss.map((action) => action.sql) : legacyFindDropStatements(result.sql), manifestPresent: manifest !== undefined, @@ -173,61 +144,16 @@ export const legacyDiffDeclarativeToMigrations = Effect.fnUntraced(function* ( export const legacyGenerateDeclarativeOutput = Effect.fnUntraced(function* ( run: LegacyDeclarativeRunContext, - toml: LegacyDbTomlValues, target: LegacyPgDeltaDatabaseEndpoint, ) { const engine = yield* LegacyPgDeltaEngine; - const exportInput = { + return yield* engine.exportDeclarativeSchema({ context: run.pgDelta, target, schema: run.schema, formatOptions: run.formatOptions, debug: run.debug, strictCoverage: run.strictCoverage, - noCache: run.noCache, ...(run.linkedProjectRef !== undefined ? { projectRef: run.linkedProjectRef } : {}), - }; - if (engine.implementation === "next") { - return yield* engine.exportDeclarativeSchema(exportInput); - } - - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const runtimeInfo = yield* RuntimeInfo; - const networkIdFlag = yield* LegacyNetworkIdFlag; - const debug = yield* legacyResolveDebugWithProjectEnv(toml.projectEnv); - const localInputs = yield* legacyBuildLocalDbContainerInputs( - spawner, - run.pgDelta.cwd, - networkIdFlag, - runtimeInfo.platform, - debug, - run.linkedProjectRef, - toml.remoteOverrideKeys, - ); - const resolvedImage = yield* localInputs.resolvePostgresImage; - const rawShadowInput = legacyShadowRunInputFromLocalContainerInputs( - localInputs, - resolvedImage, - toml, - fs, - path, - ); - return yield* Effect.acquireUseRelease( - legacyCreateShadowDatabase(spawner, rawShadowInput), - (handle) => - Effect.gen(function* () { - const shadow = yield* legacyPrepareRawShadow(spawner, handle, rawShadowInput); - return yield* engine.exportDeclarativeSchema({ - ...exportInput, - source: { - kind: "database", - ref: shadow.sourceUrl, - connectOptions: { isLocal: true, dnsResolver: "native" }, - }, - }); - }), - (handle) => legacyRemoveShadowDatabase(spawner, handle.containerId), - ); + }); }); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md index 6d827f6882..4e13d172b4 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md @@ -3,62 +3,48 @@ Generates declarative schema files from a database using pg-delta's managed platform view. -Pg-delta runs in-process by default. Set `SUPABASE_USE_PG_DELTA_NEXT=false` for -the legacy catalog/edge-runtime implementation; there is no automatic fallback. +Pg-delta runs in-process. Coverage gaps warn; `--strict-coverage` makes them fatal, and `PGDELTA_DEBUG` writes diagnostic JSON under `supabase/.temp/pgdelta/v2/debug//`. -`--no-cache` affects only the legacy opt-out (its catalog cache and the shadow -baseline snapshot those catalog exports use). The bundled formatter defaults to +`--no-cache` (a flag shared across the `declarative` group) has no effect on +`generate` — the export connects directly to the target and provisions no +shadow. The bundled formatter defaults to lowercase SQL at width 180; config overrides it, and JSON `null` disables formatting without disabling safe compaction. ## Files Read -| Path | Format | When | -| --------------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always — pg-delta gate, ports, format options | -| `/supabase/.temp/pgdelta-version` | plain text | loaded for compatibility; legacy opt-out only | -| `/supabase/.temp/edge-runtime-version` | plain text | legacy opt-out's edge-runtime image tag | -| `/supabase/.temp/postgres-version` | plain text | legacy opt-out's shadow-DB image resolution | -| `/supabase/migrations/*.sql` | SQL | smart mode — detect whether migrations exist | -| `/supabase/roles.sql` | SQL | legacy opt-out — hashed into the catalog cache key, and on a catalog miss also into the shadow-baseline cache key (on warm hits too, not just cold ones) and applied to a cold shadow's baseline; missing file tolerated (hashed as empty) | -| `/supabase/.temp/pgdelta/*.json` | JSON | legacy opt-out's catalog cache | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | legacy opt-out catalog miss, warm shadow-cache hit — the matching snapshot is streamed into the fresh shadow; every cache-eligible acquire also enumerates and `stat`s every `shadow-baseline-*.tar` for LRU/TTL (`SUPABASE_HOME` overrides the root) | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | legacy opt-out catalog miss — abandoned-partial sweep on every cache-eligible acquire; removed when older than 5 minutes | -| `~/.supabase/access-token` | plain text | `--linked` (token resolution) | +| Path | Format | When | +| ------------------------------------------- | ---------- | ------------------------------------------------------------------------------------ | +| `/supabase/config.toml` | TOML | always — pg-delta gate, ports, format options | +| `/supabase/.temp/postgres-version` | plain text | smart-mode Local flow — the local Postgres image-currency check's version-pin lookup | +| `/supabase/migrations/*.sql` | SQL | smart mode — detect whether migrations exist | +| `~/.supabase/access-token` | plain text | `--linked` (token resolution) | ## Files Written -| Path | Format | When | -| ----------------------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/schemas/**/*.sql` (default declarative dir, or invocation-local `--output`) | SQL | selected destination is wiped + rewritten after confirmation | -| `/.pgdelta-export.json` | JSON | bundled-engine export metadata | -| `/supabase/.temp/pgdelta/catalog-*.json` | JSON | legacy opt-out's catalog cache | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | legacy opt-out catalog miss, cache-enabled COLD shadow provision creates the current key's snapshot; a warm hit `touch`es its mtime; LRU/TTL may delete other keys (`SUPABASE_HOME` overrides the root; `--no-cache` neither reads nor writes) | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | legacy opt-out catalog miss, 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 5 minutes | -| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | bundled engine with `PGDELTA_DEBUG` | +| Path | Format | When | +| ----------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------ | +| `/supabase/schemas/**/*.sql` (default declarative dir, or invocation-local `--output`) | SQL | selected destination is wiped + rewritten after confirmation | +| `/.pgdelta-export.json` | JSON | export metadata | +| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | with `PGDELTA_DEBUG` | ## Subprocesses / Containers -| What | When | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| Natively-provisioned shadow Postgres container (create, health-wait, platform-baseline setup via one-shot auth/storage/realtime migrate jobs, then remove) — the same primitives `db diff`/`db pull` use for their own shadow, exports the baseline catalog | legacy opt-out only | -| Edge-runtime container (`supabase/edge-runtime`) running the pg-delta declarative-export Deno script (host network, deno-cache volume `supabase_edge_runtime_`) | legacy opt-out only | -| `docker`/`podman` container recreate for the local `db` (+ satellite restarts, Kong reload) — the same primitives `db start`/`db reset` use, via `legacyResetLocalDatabase` | smart-mode Local choice when reset is confirmed (or `--reset`) | +| What | When | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| `docker`/`podman` container recreate for the local `db` (+ satellite restarts, Kong reload) — the same primitives `db start`/`db reset` use, via `legacyResetLocalDatabase` | smart-mode Local choice when reset is confirmed (or `--reset`) | ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------- | ---------------------------------------------------------------------------------------- | --------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` | no | -| `DB_PASSWORD` | password for `--linked` / `--db-url` | no | -| `SUPABASE_HOME` | overrides the `~/.supabase` root used for the legacy opt-out's shadow baseline cache | no | -| `SUPABASE_SHADOW_CACHE` | shadow baseline cache for the legacy opt-out's catalog-miss shadows; opt-in (`1`/`true`) | no | -| `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` for legacy edge-runtime pg-delta | no | -| `PGDELTA_NPM_REGISTRY` | legacy opt-out's private npm registry | no | -| `PGDELTA_DEBUG` | bundled-engine debug artifacts | no | -| `SUPABASE_SERVICES_HOSTNAME` | local DB host for `--local` | no | -| `DOCKER_HOST` | tcp daemon host used as the local DB host fallback | no | +| Variable | Purpose | Required? | +| ---------------------------- | ------------------------------------------------------------------- | --------- | +| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` | no | +| `DB_PASSWORD` | password for `--linked` / `--db-url` | no | +| `SUPABASE_HOME` | overrides the `~/.supabase` root (access token and other CLI state) | no | +| `PGDELTA_DEBUG` | bundled-engine debug artifacts | no | +| `SUPABASE_SERVICES_HOSTNAME` | local DB host for `--local` | no | +| `DOCKER_HOST` | tcp daemon host used as the local DB host fallback | no | ## Exit Codes @@ -68,7 +54,7 @@ formatting without disabling safe compaction. | `1` | pg-delta not enabled (no `--experimental` / `[experimental.pgdelta]`) | | `1` | conflicting `--db-url`/`--linked`/`--local` (mutually exclusive) | | `1` | non-interactive mode with no explicit target | -| `1` | shadow-database / selected pg-delta engine / export failure | +| `1` | local-database bring-up / pg-delta engine / export failure | The pg-delta gate and the mutex check are both raised before any side effects run, but the gate wins when both conditions apply simultaneously: the gate check runs @@ -101,11 +87,5 @@ always go to stderr, in every `--output-format`. On success: or an export manifest, a WARNING on stderr explains the default move and how to keep the existing tree. Read-only probe; never changes behavior or exit codes. -- Under the legacy opt-out, remote Supabase targets get the embedded pg-delta CA - bundle written under `supabase/.temp/pgdelta/` and the URL rewritten to - `sslmode=verify-ca`; the bundled engine uses the shared connection/TLS behavior. -- **Architecture:** the bundled engine extracts and renders the target in-process. - Under the legacy opt-out, the shadow-database platform baseline is provisioned - in-process (create the shadow container, wait for health, run the - auth/storage/realtime one-shot migrate jobs, export the catalog, remove the - container) using the same primitives as `db diff` and `db pull`. +- Remote Supabase targets use the shared connection/TLS behavior. +- **Architecture:** the engine extracts and renders the target in-process. diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts index 0ddd9bf033..fad2d43305 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts @@ -18,15 +18,12 @@ import { } from "../../../../../shared/legacy-db-config.toml-read.ts"; import { LegacyLinkedProjectCache } from "../../../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../../../telemetry/legacy-telemetry-state.service.ts"; -import { legacyListLocalMigrations } from "../../../../../shared/legacy-pgdelta.cache.ts"; +import { legacyListLocalMigrations } from "../../../../../shared/legacy-migration-list.ts"; import { legacyIsPgDeltaDebugEnabled, legacyResolvePgDeltaProjectId, } from "../../../../../shared/legacy-pgdelta.ts"; -import { - LegacyPgDeltaEngine, - type LegacyPgDeltaDatabaseEndpoint, -} from "../../../shared/legacy-pgdelta-engine.service.ts"; +import type { LegacyPgDeltaDatabaseEndpoint } from "../../../shared/legacy-pgdelta-engine.service.ts"; import { LegacyDeclarativeWriteError } from "../../../shared/legacy-pgdelta.errors.ts"; import { LegacyDeclarativeMutuallyExclusiveFlagsError, @@ -62,7 +59,6 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec const telemetryState = yield* LegacyTelemetryState; const linkedProjectCache = yield* LegacyLinkedProjectCache; const dnsResolver = yield* LegacyDnsResolverFlag; - const engine = yield* LegacyPgDeltaEngine; // Go's `dbDeclarativeCmd.PersistentPreRunE` calls `flags.LoadConfig` — which runs // `loadNestedEnv` and `os.Setenv`s each project-.env key — BEFORE reading // `viper.GetBool("EXPERIMENTAL")` for the gate below (`apps/cli-go/cmd/ @@ -174,7 +170,6 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec cliSettings.workdir, ), cwd: cliSettings.workdir, - npmVersion: Option.getOrUndefined(toml.pgDelta.npmVersion), // Merged config's deno_version (re-loaded with the linked ref above on // `--linked`), so pg-delta runs under the remote-configured Deno image. denoVersion: toml.denoVersion, @@ -279,7 +274,7 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec overwrite = true; } - const result = yield* legacyGenerateDeclarativeOutput(run, toml, target); + const result = yield* legacyGenerateDeclarativeOutput(run, target); if (!overwrite && (yield* confirmOverwriteHasFiles(fs, declarativeDir))) { // Go's confirmOverwrite goes through Console.PromptYesNo (`internal/db/ @@ -304,31 +299,6 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec // next writer only prunes what an export manifest claimed — say so when a // manifest-less directory kept files the export did not replace. yield* legacyWarnPreservedUnmanagedDeclarativeFiles(declarativeDirRel, written); - - // Warm the declarative catalog cache after writing the files and before the - // success message, gated on `!--no-cache` — Go's `Generate` - // (`apps/cli-go/internal/db/declarative/declarative.go:133-157`). This applies - // the generated schema to the shadow DB and caches the catalog under the - // `local` key a subsequent `sync` reuses; a schema that cannot be applied makes - // `generate` fail here rather than succeeding and forcing `sync` to reprovision. - // - // On explicit `--linked`, thread the resolved ref into the legacy cache-warm seam, - // so it loads the `[remotes.]`-merged config and its own `GetDeclarativeDir()` - // resolves the remote-overridden `declarative_schema_path` — i.e. the warm builds - // from the same merged config and targets the same dir the handler wrote to (also - // computed from the merged `toml`). Go warms against the in-process merged config - // identically (`declarative.go:138-154`), so this always runs when `!--no-cache`. - // A command-local --output-dir is deliberately not activated in config. The - // legacy catalog seam resolves the configured declarative path itself, so - // warming here would inspect the wrong tree. Skip that optional legacy-only - // cache warm; the generated output remains complete and usable on its own. - if (!flags.noCache && engine.implementation === "legacy" && Option.isNone(flags.outputDir)) { - yield* (yield* LegacyDeclarativeSeam).exportCatalog({ - mode: "declarative", - noCache: flags.noCache, - ...(linkedProjectRef !== undefined ? { projectRef: linkedProjectRef } : {}), - }); - } yield* output.raw(legacyDeclarativeSchemaWrittenLine(declarativeDirRel), "stderr"); }).pipe( // Go's `ensureProjectGroupsCached` PersistentPostRun (`cmd/root.go:176,214-234`) diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts index b6b1bfa8d6..3e78e954ea 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts @@ -45,34 +45,16 @@ import { type LegacyDbSession, LegacyDbConnection, } from "../../../../../shared/legacy-db-connection.service.ts"; -import { - type LegacyEdgeRuntimeRunOpts, - LegacyEdgeRuntimeScript, -} from "../../../../../shared/legacy-edge-runtime-script.service.ts"; import { LegacyPgDeltaSslProbe } from "../../../../../shared/legacy-pgdelta-ssl-probe.service.ts"; -import { legacyPgDeltaLegacyEngineLayer } from "../../../shared/legacy-pgdelta-engine.legacy.layer.ts"; -import { LegacyPgDeltaEngine } from "../../../shared/legacy-pgdelta-engine.service.ts"; -import { LegacyDeclarativeShadowDbError } from "../../../shared/legacy-pgdelta.errors.ts"; import { - type LegacyCatalogMode, - LegacyDeclarativeSeam, -} from "../../../shared/legacy-pgdelta.seam.service.ts"; + LegacyPgDeltaEngine, + LegacyPgDeltaEngineError, +} from "../../../shared/legacy-pgdelta-engine.service.ts"; +import { LegacyDeclarativeShadowDbError } from "../../../shared/legacy-pgdelta.errors.ts"; +import { LegacyDeclarativeSeam } from "../../../shared/legacy-pgdelta.seam.service.ts"; import type { LegacyDbSchemaDeclarativeGenerateFlags } from "./generate.command.ts"; import { legacyDbSchemaDeclarativeGenerate } from "./generate.handler.ts"; -const EXPORT_JSON = JSON.stringify({ - version: 1, - mode: "declarative", - files: [ - { - path: "schemas/public/tables/players.sql", - order: 0, - statements: 1, - sql: "create table players ();", - }, - ], -}); - interface SetupOpts { experimental?: boolean; args?: ReadonlyArray; @@ -81,7 +63,6 @@ interface SetupOpts { promptConfirmResponses?: ReadonlyArray; promptSelectResponses?: ReadonlyArray; promptTextResponses?: ReadonlyArray; - exportJson?: string; /** * Makes the local-reset prompt's `legacyResetLocalDatabase` fail immediately * with `LegacyResetLocalDbNotRunningError` (the local `db` container reports as @@ -90,9 +71,16 @@ interface SetupOpts { resetShouldFail?: boolean; networkId?: Option.Option; projectId?: Option.Option; - exportFailsForMode?: LegacyCatalogMode; + /** Makes the engine's `exportDeclarativeSchema` fail after recording the call. */ + exportFails?: boolean; staleLocalImage?: boolean; - engineImplementation?: "legacy" | "next"; +} + +/** What the handler handed the engine for one `exportDeclarativeSchema` call. */ +interface EngineExportCall { + readonly targetRef: string; + readonly projectRef: string | undefined; + readonly strictCoverage: boolean; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -103,8 +91,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); const telemetry = mockLegacyTelemetryStateTracked(); const cache = mockLegacyLinkedProjectCacheTracked(); - const seamCalls: LegacyCatalogMode[] = []; - const seamExportCalls: Array<{ mode: LegacyCatalogMode; projectRef?: string }> = []; const localPostgresImageChecks: Array = []; let ensureStartedCalls = 0; const platformApi = mockLegacyPlatformApiService({}); @@ -138,13 +124,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { }, }); const seam = Layer.succeed(LegacyDeclarativeSeam, { - exportCatalog: ({ mode, projectRef }) => { - seamCalls.push(mode); - seamExportCalls.push({ mode, projectRef }); - return opts.exportFailsForMode === mode - ? Effect.fail(new LegacyDeclarativeShadowDbError({ message: `export failed for ${mode}` })) - : Effect.succeed("supabase/.temp/pgdelta/base.json"); - }, ensureLocalDatabaseStarted: () => Effect.sync(() => { ensureStartedCalls += 1; @@ -164,13 +143,34 @@ function setup(workdir: string, opts: SetupOpts = {}) { ), ), }); - const edgeCalls: LegacyEdgeRuntimeRunOpts[] = []; - const edge = Layer.succeed(LegacyEdgeRuntimeScript, { - run: (runOpts: LegacyEdgeRuntimeRunOpts) => { - edgeCalls.push(runOpts); - return Effect.succeed({ stdout: opts.exportJson ?? EXPORT_JSON, stderr: "" }); - }, - }); + const engineExportCalls: EngineExportCall[] = []; + const engine = Layer.succeed( + LegacyPgDeltaEngine, + LegacyPgDeltaEngine.of({ + diffExplicit: () => Effect.die("diffExplicit not used in generate tests"), + diffDatabase: () => Effect.die("diffDatabase not used in generate tests"), + planDeclarativeSchema: () => Effect.die("planDeclarativeSchema not used in generate tests"), + exportDeclarativeSchema: (input) => + Effect.suspend(() => { + engineExportCalls.push({ + targetRef: input.target.ref, + projectRef: input.projectRef, + strictCoverage: input.strictCoverage, + }); + return opts.exportFails === true + ? Effect.fail( + new LegacyPgDeltaEngineError({ + message: "declarative export failed", + cause: undefined, + }), + ) + : Effect.succeed({ + files: [{ name: "public/tables/players.sql", sql: "create table players ();" }], + manifest: { redactSecrets: true, scope: "database", profile: "supabase" }, + }); + }), + }), + ); const resolverCalls: unknown[] = []; const resolver = Layer.succeed(LegacyDbConfigResolver, { resolve: (flags) => { @@ -209,47 +209,11 @@ function setup(workdir: string, opts: SetupOpts = {}) { Layer.provide(child.layer), Layer.provide(processControl.layer), ); - const engineRuntime = Layer.mergeAll( - seam, - edge, - sslProbe, - out.layer, - dbConn, - runtimeInfo, - experimentalFlag, - cliArgs, - networkIdFlag, - debugFlag, - processControl.layer, - alwaysReadyHttpClientLayer, - dockerRun, - BunServices.layer, - child.layer, - ); - const engine = - opts.engineImplementation === "next" - ? Layer.succeed( - LegacyPgDeltaEngine, - LegacyPgDeltaEngine.of({ - implementation: "next", - diffExplicit: () => Effect.die("diffExplicit not used in generate tests"), - diffDatabase: () => Effect.die("diffDatabase not used in generate tests"), - planDeclarativeSchema: () => - Effect.die("planDeclarativeSchema not used in generate tests"), - exportDeclarativeSchema: () => - Effect.succeed({ - files: [{ name: "public/tables/players.sql", sql: "create table players ();" }], - manifest: { redactSecrets: true, scope: "database", profile: "supabase" }, - }), - }), - ) - : legacyPgDeltaLegacyEngineLayer.pipe(Layer.provide(engineRuntime)); const layer = Layer.mergeAll( out.layer, telemetry.layer, cache.layer, seam, - edge, engine, resolver, proxy, @@ -287,9 +251,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { telemetry, child, dbExec, - seamCalls, - seamExportCalls, - edgeCalls, + engineExportCalls, resolverCalls, proxyCalls, localPostgresImageChecks, @@ -480,22 +442,17 @@ describe("legacy db schema declarative generate integration", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("explicit --local: provisions a raw shadow, exports, and writes files", () => { + it.effect("explicit --local: exports from the local database and writes files", () => { const s = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })); - // Only the optional legacy post-write warm remains seam-backed. The export - // source is a workflow-owned native raw shadow. - expect(s.seamCalls).toEqual(["declarative"]); - expect(s.edgeCalls[0]!.env["SOURCE"]).toContain( - "postgresql://postgres:postgres@127.0.0.1:54320", - ); - expect(s.edgeCalls[0]!.env["TARGET"]).toContain( + // The engine receives the local database endpoint as the export target. + expect(s.engineExportCalls[0]!.targetRef).toContain( "postgresql://postgres:postgres@127.0.0.1:54322", ); const written = yield* Effect.promise(async () => (await import("node:fs")).readFileSync( - join(tmp.current, "supabase", "schemas", "schemas", "public", "tables", "players.sql"), + join(tmp.current, "supabase", "schemas", "public", "tables", "players.sql"), "utf8", ), ); @@ -515,7 +472,7 @@ describe("legacy db schema declarative generate integration", () => { }); it.effect( - "--output-dir writes a complete next export relative to the project without activating it", + "--output-dir writes a complete export relative to the project without activating it", () => { mkdirSync(join(tmp.current, "supabase", "database"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "database", "configured.sql"), "select 1;"); @@ -528,7 +485,7 @@ describe("legacy db schema declarative generate integration", () => { ].join("\n"); writeFileSync(configPath, config); const destination = join("supabase", "database-next"); - const s = setup(tmp.current, { experimental: true, engineImplementation: "next" }); + const s = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeGenerate( flags({ local: Option.some(true), outputDir: Option.some(destination) }), @@ -564,7 +521,6 @@ describe("legacy db schema declarative generate integration", () => { writeFileSync(join(destination, "keep.sql"), "select 'keep';"); const s = setup(tmp.current, { experimental: true, - engineImplementation: "next", promptConfirmResponses: [false], }); return Effect.gen(function* () { @@ -582,7 +538,7 @@ describe("legacy db schema declarative generate integration", () => { mkdirSync(projectDir, { recursive: true }); const sentinel = join(projectDir, "project-sentinel.txt"); writeFileSync(sentinel, "keep"); - const s = setup(projectDir, { experimental: true, engineImplementation: "next" }); + const s = setup(projectDir, { experimental: true }); return Effect.gen(function* () { for (const output of ["", ".", "..", dirname(projectDir)]) { const exit = yield* legacyDbSchemaDeclarativeGenerate( @@ -600,17 +556,14 @@ describe("legacy db schema declarative generate integration", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("--output-dir does not warm the configured legacy declarative tree", () => { + it.effect("--output-dir leaves the configured declarative tree untouched", () => { const s = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeGenerate( flags({ local: Option.some(true), outputDir: Option.some("staged-schema") }), ); - expect(s.seamCalls).toEqual([]); expect( - existsSync( - join(tmp.current, "staged-schema", "schemas", "public", "tables", "players.sql"), - ), + existsSync(join(tmp.current, "staged-schema", "public", "tables", "players.sql")), ).toBe(true); expect(existsSync(join(tmp.current, "supabase", "schemas"))).toBe(false); }).pipe(Effect.provide(s.layer)); @@ -629,7 +582,7 @@ describe("legacy db schema declarative generate integration", () => { }); expect(s.localPostgresImageChecks).toHaveLength(1); expect(s.ensureStartedCalls).toBe(0); - expect(s.edgeCalls).toEqual([]); + expect(s.engineExportCalls).toEqual([]); }).pipe(Effect.provide(s.layer)); }); @@ -645,7 +598,7 @@ describe("legacy db schema declarative generate integration", () => { yield* legacyDbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })); const written = yield* Effect.promise(async () => (await import("node:fs")).readFileSync( - join(tmp.current, "supabase", "schemas", "schemas", "public", "tables", "players.sql"), + join(tmp.current, "supabase", "schemas", "public", "tables", "players.sql"), "utf8", ), ); @@ -685,7 +638,7 @@ describe("legacy db schema declarative generate integration", () => { flags({ dbUrl: Option.some("postgres://remote/db") }), ); expect(s.resolverCalls.length).toBe(1); - expect(s.edgeCalls[0]!.env["TARGET"]).toContain("@db.remote:5432"); + expect(s.engineExportCalls[0]!.targetRef).toContain("@db.remote:5432"); // Remote target → the local stack is never started. expect(s.ensureStartedCalls).toBe(0); }).pipe(Effect.provide(s.layer)); @@ -709,10 +662,10 @@ describe("legacy db schema declarative generate integration", () => { return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })); // File lands under the absolute path, NOT tmp.current/. - expect(existsSync(join(absSchema, "schemas", "public", "tables", "players.sql"))).toBe(true); - expect( - readFileSync(join(absSchema, "schemas", "public", "tables", "players.sql"), "utf8"), - ).toBe("create table players ();"); + expect(existsSync(join(absSchema, "public", "tables", "players.sql"))).toBe(true); + expect(readFileSync(join(absSchema, "public", "tables", "players.sql"), "utf8")).toBe( + "create table players ();", + ); // Go prints the configured value verbatim — absolute here, never workdir-prefixed. expect( s.out.rawChunks.map((c) => ({ text: stripAnsi(c.text), stream: c.stream })), @@ -748,25 +701,14 @@ describe("legacy db schema declarative generate integration", () => { yield* legacyDbSchemaDeclarativeGenerate(flags({ linked: Option.some(true) })); const written = yield* Effect.promise(async () => (await import("node:fs")).readFileSync( - join( - tmp.current, - "supabase", - "remote_schema", - "schemas", - "public", - "tables", - "players.sql", - ), + join(tmp.current, "supabase", "remote_schema", "public", "tables", "players.sql"), "utf8", ), ); expect(written).toBe("create table players ();"); - // The post-write cache warm now RUNS and is threaded the resolved ref as - // SUPABASE_PROJECT_ID, so the __catalog subprocess loads the [remotes.]-merged - // config and resolves the remote-overridden declarative dir — matching Go's - // in-process merged warm (declarative.go:138-154) rather than skipping. - const declWarm = s.seamExportCalls.find((c) => c.mode === "declarative"); - expect(declWarm?.projectRef).toBe(ref); + // The resolved linked ref is threaded into the engine export as projectRef, so + // the export's platform setup uses the [remotes.]-merged config. + expect(s.engineExportCalls[0]!.projectRef).toBe(ref); }).pipe(Effect.provide(s.layer)); }); @@ -800,8 +742,8 @@ describe("legacy db schema declarative generate integration", () => { const s = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeGenerate(flags({ local: Option.some(false) })); - // Took the explicit local target and completed the optional legacy warm ... - expect(s.seamCalls).toContain("declarative"); + // Took the explicit local target and ran the export ... + expect(s.engineExportCalls).toHaveLength(1); // ... but did NOT auto-start (value is false). expect(s.ensureStartedCalls).toBe(0); expect(s.localPostgresImageChecks).toHaveLength(1); @@ -860,7 +802,7 @@ describe("legacy db schema declarative generate integration", () => { }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeGenerate(flags()); - expect(s.seamCalls).toEqual([]); + expect(s.engineExportCalls).toEqual([]); expect( s.out.rawChunks.some((c) => c.text.includes("Skipped generating declarative schema")), ).toBe(true); @@ -878,7 +820,7 @@ describe("legacy db schema declarative generate integration", () => { const s = setup(tmp.current, { experimental: true, stdinIsTty: false, yes: true }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeGenerate(flags()); - expect(s.seamCalls).toEqual(["declarative"]); + expect(s.engineExportCalls).toHaveLength(1); // Go's PromptYesNo echoes the auto-accepted question to stderr under the // global YES flag (`console.go:70-72`) — the echo must not be skipped, and // the prompt renders the relative dir (`db_schema_declarative.go:268`). @@ -902,7 +844,7 @@ describe("legacy db schema declarative generate integration", () => { const s = setup(tmp.current, { experimental: true, stdinIsTty: false, yes: false }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeGenerate(flags()); - expect(s.seamCalls).toEqual(["declarative"]); + expect(s.engineExportCalls).toHaveLength(1); expect(stripAnsi(s.out.stderrText)).toContain( `Declarative schema already exists at ${join("supabase", "schemas")}. Regenerate from database? This will overwrite existing files. [y/N] y\n`, ); @@ -917,24 +859,27 @@ describe("legacy db schema declarative generate integration", () => { ); }); - it.effect("warms the declarative catalog cache after writing (skipped with --no-cache)", () => { + it.effect("passes --strict-coverage through to the engine export", () => { const s = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { - yield* legacyDbSchemaDeclarativeGenerate(flags({ local: Option.some(true), noCache: true })); - // --no-cache skips the post-write warm; the raw source never uses the seam. - expect(s.seamCalls).toEqual([]); + yield* legacyDbSchemaDeclarativeGenerate( + flags({ local: Option.some(true), noCache: true, strictCoverage: true }), + ); + expect(s.engineExportCalls).toEqual([expect.objectContaining({ strictCoverage: true })]); }).pipe(Effect.provide(s.layer)); }); - it.effect("fails generate when the post-write catalog warm cannot apply to the shadow", () => { - // Go returns the warm error from Generate (declarative.go:144-153), so a schema that - // can't apply to the shadow DB fails generate rather than reporting success. - const s = setup(tmp.current, { experimental: true, exportFailsForMode: "declarative" }); + it.effect("fails generate when the engine export fails", () => { + const s = setup(tmp.current, { experimental: true, exportFails: true }); return Effect.gen(function* () { const exit = yield* legacyDbSchemaDeclarativeGenerate( flags({ local: Option.some(true) }), ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)).toMatchObject({ + _tag: "LegacyPgDeltaEngineError", + message: "declarative export failed", + }); expect(s.out.rawChunks.some((c) => c.text.includes("Declarative schema written to"))).toBe( false, ); @@ -1003,7 +948,7 @@ describe("legacy db schema declarative generate integration", () => { message: "local Postgres container image is stale", }); expect(s.localPostgresImageChecks).toHaveLength(1); - expect(s.edgeCalls).toEqual([]); + expect(s.engineExportCalls).toEqual([]); }).pipe(Effect.provide(s.layer)); }); @@ -1198,12 +1143,14 @@ describe("legacy db schema declarative generate integration", () => { return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeGenerate(flags()); // Normalized via ToPostgresURL → connect_timeout appended, like Go. - expect(s.edgeCalls[0]!.env["TARGET"]).toContain("@db.example.com:5432/app?connect_timeout="); + expect(s.engineExportCalls[0]!.targetRef).toContain( + "@db.example.com:5432/app?connect_timeout=", + ); }).pipe(Effect.provide(s.layer)); }); - it.effect("next engine writes its manifest and skips legacy catalog warming", () => { - const s = setup(tmp.current, { experimental: true, engineImplementation: "next" }); + it.effect("writes the engine's export manifest alongside the declarative tree", () => { + const s = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })); const manifest = JSON.parse( @@ -1215,7 +1162,6 @@ describe("legacy db schema declarative generate integration", () => { scope: "database", files: ["public/tables/players.sql"], }); - expect(s.seamCalls).toEqual([]); }).pipe(Effect.provide(s.layer)); }); }); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md index 6159e934bf..2fd11d15be 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md @@ -3,59 +3,49 @@ Diffs local migrations state against declarative schema files and writes the delta as a new timestamped migration. -Pg-delta runs in-process by default and uses two scoped shadow databases. Set -`SUPABASE_USE_PG_DELTA_NEXT=false` for the legacy catalog/edge-runtime path; -there is no automatic fallback. Coverage gaps warn; `--strict-coverage` makes +Pg-delta runs in-process and uses two scoped shadow databases. Coverage gaps +warn; `--strict-coverage` makes them fatal, while `PGDELTA_DEBUG` writes diagnostic JSON under -`supabase/.temp/pgdelta/v2/debug//`. Bundled output may use different SQL -and ordered transaction-aware files but must apply and converge. `--no-cache` -bypasses the bundled engine's shadow baseline cache and the legacy opt-out's -catalog + snapshot caches. The bundled formatter defaults to lowercase SQL +`supabase/.temp/pgdelta/v2/debug//`. The engine may emit ordered +transaction-aware files; applicable, convergent SQL is the contract. `--no-cache` +bypasses the engine's shadow baseline cache. The bundled formatter defaults to +lowercase SQL at width 180; config overrides it, and JSON `null` disables formatting without disabling safe compaction. ## Files Read -| Path | Format | When | -| --------------------------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always — pg-delta gate, format options | -| `/supabase/.temp/pgdelta-version` | plain text | loaded for compatibility; legacy opt-out only | -| `/supabase/.temp/edge-runtime-version` | plain text | legacy opt-out's edge-runtime image tag | -| `/supabase/schemas/**/*.sql` (default declarative dir) | SQL | always — must exist (else error) | -| `/supabase/migrations/*.sql` | SQL | bundled engine applies them to a live shadow; legacy opt-out resolves a migrations catalog | -| `/supabase/roles.sql` | SQL | legacy migrations-catalog cache key (empty when absent); separately hashed into the shadow-baseline cache key on every cache-eligible acquire — bundled-engine shadows and the legacy opt-out's catalog miss alike, warm hits included — and applied to a cold shadow's baseline | -| `/supabase/schemas/.pgdelta-export.json` | JSON | bundled export metadata, when present | -| `/supabase/.temp/pgdelta/*.json` | JSON | legacy opt-out's migrations/declarative catalog cache | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | warm shadow-cache hit — bundled-engine migrations/declarative shadows, and the legacy opt-out's catalog miss; every cache-eligible acquire (warm hit and successful cold export) also enumerates and `stat`s every `shadow-baseline-*.tar` for LRU keep-3 + 2-day mtime TTL and may delete other keys (`SUPABASE_HOME` overrides the `~/.supabase` root) | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | abandoned-partial sweep on every cache-eligible acquire (warm hit and cold export) — enumerated and `stat`ed, and removed when older than 5 minutes (a crashed/SIGKILLed earlier export's leftover) | +| Path | Format | When | +| --------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always — pg-delta gate, format options | +| `/supabase/schemas/**/*.sql` (default declarative dir) | SQL | always — must exist (else error) | +| `/supabase/migrations/*.sql` | SQL | applied to the live migrations shadow | +| `/supabase/roles.sql` | SQL | hashed into the shadow-baseline cache key on every cache-eligible acquire, warm hits included, and applied to a cold shadow's baseline; missing file tolerated (hashed as empty) | +| `/supabase/schemas/.pgdelta-export.json` | JSON | export metadata, when present | +| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | warm shadow-cache hit (migrations/declarative shadows); every cache-eligible acquire (warm hit and successful cold export) also enumerates and `stat`s every `shadow-baseline-*.tar` for LRU keep-3 + 2-day mtime TTL and may delete other keys (`SUPABASE_HOME` overrides the `~/.supabase` root) | +| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | abandoned-partial sweep on every cache-eligible acquire (warm hit and cold export) — enumerated and `stat`ed, and removed when older than 5 minutes (a crashed/SIGKILLed earlier export's leftover) | ## Files Written -| Path | Format | When | -| --------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/migrations/_[_].sql` | SQL | changes; bundled engine may emit ordered segments | -| `/supabase/schemas/extension.sql` | SQL | accepted legacy-extension repair | -| `/supabase/.temp/pgdelta/catalog-*.json` | JSON | legacy opt-out's catalog cache | -| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | bundled engine with `PGDELTA_DEBUG` | -| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | cache-enabled COLD shadow provision creates the current key's snapshot — bundled-engine migrations/declarative shadows, and the legacy opt-out's catalog miss (a catalog hit provisions no shadow; `--no-cache` bypasses the snapshot cache entirely — neither read nor written); a warm hit `touch`es its mtime (LRU); every cache-eligible acquire may delete other keys under LRU keep-3 + 2-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 5 minutes | +| Path | Format | When | +| --------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/migrations/_[_].sql` | SQL | changes; bundled engine may emit ordered segments | +| `/supabase/schemas/extension.sql` | SQL | accepted legacy-extension repair | +| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | bundled engine with `PGDELTA_DEBUG` | +| `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | cache-enabled COLD shadow provision creates the current key's snapshot — migrations/declarative shadows (`--no-cache` bypasses the snapshot cache entirely — neither read nor written); a warm hit `touch`es its mtime (LRU); every cache-eligible acquire may delete other keys under LRU keep-3 + 2-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 5 minutes | ## Subprocesses / Containers -| What | When | -| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -| Two natively-provisioned shadows (migrated source + declarative target) via `legacyAcquireShadowDatabase` — ephemeral host ports, settings-keyed global baseline cache | bundled engine | -| Natively-provisioned shadow Postgres container (`legacyCreateShadowDatabase`/`legacyPrepareShadowSource`) + native migrate; the catalog itself is exported via edge-runtime | legacy opt-out, migrations-catalog cache miss | -| Natively-provisioned shadow Postgres container (platform-baseline setup via one-shot auth/storage/realtime migrate jobs, then the declarative directory applied via the pg-delta edge-runtime apply script) → catalog export | legacy opt-out, declarative-catalog cache miss | -| Edge-runtime container running the pg-delta diff and, on a catalog cache miss, catalog-export/declarative-apply scripts | legacy opt-out | -| `docker`/`podman` container recreate for the local `db` (+ satellite restarts, Kong reload) — the same primitives `db start`/`db reset` use, via `legacyResetLocalDatabase` — only on the failed-apply recovery path | TTY only, apply failed, and the user confirms "reset and reapply" | +| What | When | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| Two natively-provisioned shadows (migrated source + declarative target) via `legacyAcquireShadowDatabase` — ephemeral host ports, settings-keyed global baseline cache | always | +| `docker`/`podman` container recreate for the local `db` (+ satellite restarts, Kong reload) — the same primitives `db start`/`db reset` use, via `legacyResetLocalDatabase` — only on the failed-apply recovery path | TTY only, apply failed, and the user confirms "reset and reapply" | ## Environment Variables | Variable | Purpose | Required? | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------- | -| `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` for legacy edge-runtime pg-delta | no | -| `PGDELTA_NPM_REGISTRY` | legacy opt-out's private npm registry | no | | `SUPABASE_HOME` | overrides the `~/.supabase` root used for the shadow baseline cache (and other CLI state) | no | | `SUPABASE_SHADOW_CACHE` | shadow baseline cache; opt-in (`1`/`true`); the shadow's post-baseline PGDATA is snapshotted to a tar and restored into the next run's fresh container (see Notes) | no | | `PGDELTA_DEBUG` | bundled-engine debug artifacts | no | @@ -84,8 +74,7 @@ first, so a closed gate (missing `--experimental`) surfaces before an Text mode only. The generated SQL, the created-migration path, drop-statement warnings, and apply status are written to stderr. The no-files bootstrap also prints `Declarative schema written to ` (the relative declarative dir) to -stderr after generating and writing (and, under the -legacy opt-out, warming the catalog cache) — on both interactive and `--yes` paths. +stderr after generating and writing — on both interactive and `--yes` paths. `--no-apply` writes the migration only (never prompts/applies); `--apply` applies without prompting; both override the global `--yes`. `--no-apply` and `--apply` are mutually exclusive. @@ -133,10 +122,8 @@ existing SQL or creates an export manifest. (the reset itself is native too — `legacyResetLocalDatabase` — run in-process, sharing this command's own telemetry/linked-project-cache finalizer cycle rather than firing a second one from a child process). -- **Architecture:** the bundled engine plans and renders in-process from two live - shadows. Under the legacy opt-out, both catalog shadows are provisioned - in-process using the same primitives as `db diff`; catalog export, - declarative apply, and diff run through the edge-runtime pg-delta scripts. +- **Architecture:** the engine plans and renders in-process from two live + shadows. ### Shadow baseline cache (`SUPABASE_SHADOW_CACHE`, default OFF) @@ -163,10 +150,3 @@ Session-semantics caveat on the cached paths: migrations run on a session opened platform baseline, so role-level defaults installed by `supabase/roles.sql` (`ALTER ROLE … SET …`) apply to migration execution; with the cache off, the single-session flow runs migrations before those defaults take effect. - -Under the legacy opt-out, every catalog-miss shadow (migrations, baseline, declarative) goes -through `exportViaShadowCatalog` (`legacy-pgdelta.cache.ts`), the same -`legacyWithShadowDatabase` seam `db diff`/`db pull` use. `--no-cache` bypasses that snapshot -cache along with the catalog cache. Catalog provisioners wait with `legacyWaitForShadowReady` -and thread baseline state through `legacySetupShadowDatabase` so a warm hit does not -double-apply the baseline. diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.e2e.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.e2e.test.ts index 947664f7fc..afe241e2b5 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.e2e.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.e2e.test.ts @@ -18,7 +18,6 @@ const CLEANUP_HOOK_TIMEOUT_MS = CLEANUP_TIMEOUT_MS + LIFECYCLE_MARGIN_MS; const SCENARIO_COMMAND_TIMEOUT_MS = 280_000; const BEFORE_ALL_TIMEOUT_MS = CLI_COMMAND_TIMEOUT_MS + STACK_START_TIMEOUT_MS + LIFECYCLE_MARGIN_MS; const SCENARIO_TIMEOUT_MS = 900_000; -const NEXT_ENV = { SUPABASE_USE_PG_DELTA_NEXT: "true" }; const initialDesiredSchema = `create type public.account_state as enum ('pending', 'active'); @@ -132,7 +131,6 @@ describe("db schema declarative sync (e2e)", () => { { entrypoint: "legacy", cwd: projectDir, - env: NEXT_ENV, exitTimeoutMs: SCENARIO_COMMAND_TIMEOUT_MS, }, ); @@ -163,7 +161,6 @@ describe("db schema declarative sync (e2e)", () => { { entrypoint: "legacy", cwd: projectDir, - env: NEXT_ENV, exitTimeoutMs: SCENARIO_COMMAND_TIMEOUT_MS, }, ); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts index 74d73d93a0..e373313666 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts @@ -24,12 +24,8 @@ import { LEGACY_ENABLE_LOCAL_WEBHOOKS_SUGGESTION } from "../../../../../shared/l import { legacyReadProjectRefFile } from "../../../../../shared/legacy-temp-paths.ts"; import { LegacyLinkedProjectCache } from "../../../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../../../telemetry/legacy-telemetry-state.service.ts"; -import { - legacyListLocalMigrations, - legacyResolveSetupInputs, -} from "../../../../../shared/legacy-pgdelta.cache.ts"; +import { legacyListLocalMigrations } from "../../../../../shared/legacy-migration-list.ts"; import { legacyPgDeltaTempPath } from "../../../../../shared/legacy-pgdelta.paths.ts"; -import { LegacyPgDeltaEngine } from "../../../shared/legacy-pgdelta-engine.service.ts"; import { legacyIsPgDeltaDebugEnabled, legacyResolvePgDeltaProjectId, @@ -113,7 +109,6 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); const dnsResolver = yield* LegacyDnsResolverFlag; const seam = yield* LegacyDeclarativeSeam; - const engine = yield* LegacyPgDeltaEngine; const linkedProjectCache = yield* LegacyLinkedProjectCache; // Go's sync bootstrap delegates to `runDeclarativeGenerate`, whose @@ -181,7 +176,6 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara cliSettings.workdir, ), cwd: cliSettings.workdir, - npmVersion: Option.getOrUndefined(toml.pgDelta.npmVersion), denoVersion: toml.denoVersion, projectEnv: toml.projectEnv, }, @@ -278,7 +272,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara linkedRef, ensureLocalPostgresImageCurrent, ); - const generated = yield* legacyGenerateDeclarativeOutput(run, toml, target); + const generated = yield* legacyGenerateDeclarativeOutput(run, target); const written = yield* legacyWriteDeclarativeSchemas(fs, path, declarativeDir, generated); // A manifest-less directory keeps files the export did not replace, and those // files go straight into the plan below — warn before diffing against them. @@ -290,16 +284,6 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara }), ); } - // Go's bootstrap delegates to the full `declarative.Generate`, which warms the - // declarative catalog cache when --no-cache is unset (`declarative.go:133-157`, - // `cmd/db_schema_declarative.go:321`) — applying the just-generated schema to a - // shadow DB so an unappliable schema fails HERE, before building the migrations - // catalog / emitting a diff debug bundle, and warming the catalog the following - // diff reuses. (sync is target-less and writes to the single toml-resolved dir, - // so the generate handler's remote-override dir guard isn't needed here.) - if (!run.noCache && engine.implementation === "legacy") { - yield* seam.exportCatalog({ mode: "declarative", noCache: run.noCache }); - } // Go's delegated `declarative.Generate` prints the written-to line to stderr // after the write and the catalog warm (`declarative.go:133→138-155→156`), on // both the interactive-accept and --yes/SUPABASE_YES bootstrap paths, and @@ -310,17 +294,6 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara } // Step 2: diff migrations state vs declarative; on error, save a debug bundle. - // `setupInputs` is the cache-key/baseline-setup subset of `toml` that the now- - // native migrations-catalog resolution needs (CLI-1959) — see - // `legacyResolveSetupInputs`'s doc comment. - const setupInputs = yield* legacyResolveSetupInputs( - fs, - path, - cliSettings.workdir, - toml.majorVersion, - Option.getOrUndefined(toml.orioledbVersion), - toml.baseline, - ); const stageNextExport = Effect.fnUntraced(function* () { const stagedDir = path.resolve(cliSettings.workdir, stagedDirRel); // Reject the active directory itself AND anything nested under it: a @@ -383,7 +356,6 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara } const generated = yield* legacyGenerateDeclarativeOutput( { ...run, declarativeDir: stagedDir }, - toml, legacyLocalEndpoint({ port: toml.port, password: toml.password }, dnsResolver), ); const written = yield* legacyWriteDeclarativeSchemas(fs, path, stagedDir, generated); @@ -403,7 +375,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara }); const planDeclarativeSync = () => - legacyDiffDeclarativeToMigrations(run, toml, setupInputs).pipe( + legacyDiffDeclarativeToMigrations(run, toml).pipe( Effect.tapError((error) => error instanceof LegacyDeclarativeCompatibilityError ? Effect.void @@ -505,7 +477,6 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara // migration writing after the first missing extension is declared. while (true) { if ( - engine.implementation === "next" && !result.manifestPresent && !toml.webhooksEnabled && result.removals.extensions.includes("pg_net") @@ -521,7 +492,6 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara ); } const compatibility = legacyClassifyDeclarativeCompatibilityGap({ - implementation: engine.implementation, manifestPresent: result.manifestPresent, removals: result.removals, }); @@ -617,7 +587,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara // Step 5: write the timestamped migration file. const nowMillis = yield* Clock.currentTimeMillis; let migrationPaths: ReadonlyArray; - if (engine.implementation === "next" && result.files.length > 1) { + if (result.files.length > 1) { const written = yield* legacyWritePgDeltaMigrations(fs, path, { workdir: cliSettings.workdir, baseMillis: nowMillis, @@ -642,9 +612,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara if (result.dropWarnings.length > 0) { yield* output.raw( `${legacyYellow( - engine.implementation === "next" - ? "Found destructive changes in schema diff. Please double check if these are expected:" - : "Found drop statements in schema diff. Please double check if these are expected:", + "Found destructive changes in schema diff. Please double check if these are expected:", )}\n`, "stderr", ); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts index 267a9c6b69..cc7aed7ecc 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts @@ -44,12 +44,8 @@ import { LegacyDbConnection, type LegacyPgConnInput, } from "../../../../../shared/legacy-db-connection.service.ts"; -import { - type LegacyEdgeRuntimeRunOpts, - LegacyEdgeRuntimeScript, -} from "../../../../../shared/legacy-edge-runtime-script.service.ts"; +import { LegacyEdgeRuntimeScript } from "../../../../../shared/legacy-edge-runtime-script.service.ts"; import { LegacyPgDeltaSslProbe } from "../../../../../shared/legacy-pgdelta-ssl-probe.service.ts"; -import { legacyPgDeltaLegacyEngineLayer } from "../../../shared/legacy-pgdelta-engine.legacy.layer.ts"; import { LegacyPgDeltaEngine, LegacyPgDeltaEngineError, @@ -61,19 +57,6 @@ import { LegacyDeclarativeSeam } from "../../../shared/legacy-pgdelta.seam.servi import type { LegacyDbSchemaDeclarativeSyncFlags } from "./sync.command.ts"; import { legacyDbSchemaDeclarativeSync } from "./sync.handler.ts"; -const EXPORT_JSON = JSON.stringify({ - version: 1, - mode: "declarative", - files: [ - { - path: "schemas/public/tables/players.sql", - order: 0, - statements: 1, - sql: "create table players ();", - }, - ], -}); - interface SetupOpts { experimental?: boolean; args?: ReadonlyArray; @@ -94,8 +77,6 @@ interface SetupOpts { networkId?: string; projectId?: Option.Option; staleLocalImage?: boolean; - exportJson?: string; - engineImplementation?: "legacy" | "next"; renderedFiles?: ReadonlyArray; removals?: LegacyPgDeltaRemovalSummary; planErrors?: ReadonlyArray; @@ -117,25 +98,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { const child = mockContainerCliSpawner( defaultLocalResetRoute("test", { running: opts.resetShouldFail !== true }), ); - // Each catalog export records how many raw chunks had been emitted when it fired, - // so tests can assert output ordering relative to the exports (e.g. the bootstrap's - // written-to line lands after the declarative warm, before the diff's exports). - const exportCatalogCalls: Array<{ mode: string; rawChunksAt: number }> = []; - // The migrations-catalog source now resolves natively (CLI-1959 cache mechanics - // + CLI-1956 shadow provisioning) via `legacyGetMigrationsCatalogRef`, which - // provisions its shadow through the SAME `legacyCreateShadowDatabase`/ - // `legacyPrepareShadowSource`/`legacyRemoveShadowDatabase` primitives `db - // diff`/`db pull` use for their own shadow — via `child.layer`/ - // `legacyDockerRunLayer` below (the same real container-lifecycle mocks - // `legacyResetLocalDatabase`'s own recovery-reset flow already needs), not the - // retired `db __shadow` seam. "baseline"/"declarative" still go through - // `exportCatalog`. const seam = Layer.succeed(LegacyDeclarativeSeam, { - exportCatalog: ({ mode }) => - Effect.sync(() => { - exportCatalogCalls.push({ mode, rawChunksAt: out.rawChunks.length }); - return `supabase/.temp/pgdelta/${mode}.json`; - }), ensureLocalDatabaseStarted: () => Effect.void, ensureLocalPostgresImageCurrent: () => Effect.sync(() => { @@ -152,40 +115,11 @@ function setup(workdir: string, opts: SetupOpts = {}) { ), ), }); + // The engine is fully mocked below, so no pg-delta script runs through the edge + // runtime here. `legacyResetLocalDatabase`'s db-setup still statically requires + // the service, so provide an inert stub. const edge = Layer.succeed(LegacyEdgeRuntimeScript, { - run: (runOpts: LegacyEdgeRuntimeRunOpts) => { - // The native migrations-catalog resolution's shadow export — return a fixed, - // non-empty snapshot so it never trips `legacyExportCatalogPgDelta`'s - // empty-output check regardless of what `opts.diffSql` a given test sets. - if (runOpts.errPrefix === "error exporting pg-delta catalog") { - return Effect.succeed({ stdout: '{"schemas":[]}', stderr: "" }); - } - if ( - opts.exportJson !== undefined && - runOpts.errPrefix === "error exporting declarative schema" - ) { - return Effect.succeed({ stdout: opts.exportJson, stderr: "" }); - } - const diffSql = opts.diffSql ?? ""; - // The pg-delta diff script (uniquely identified by `renderPlanFiles`) prints a - // JSON envelope with one file per plan unit; wrap the test's raw SQL into a - // single-unit envelope so `legacyDiffPgDelta` parses it. - const stdout = - runOpts.script.includes("renderPlanFiles") && diffSql.length > 0 - ? JSON.stringify({ - version: 1, - files: [ - { - order: 1, - name: "schema_changes", - transactionMode: "transactional", - sql: diffSql, - }, - ], - }) - : diffSql; - return Effect.succeed({ stdout, stderr: "" }); - }, + run: () => Effect.succeed({ stdout: "", stderr: "" }), }); const dbExec: string[] = []; const dbBatches: Array> = []; @@ -271,74 +205,51 @@ function setup(workdir: string, opts: SetupOpts = {}) { Layer.provide(child.layer), Layer.provide(processControl.layer), ); - const engineRuntime = Layer.mergeAll( - seam, - edge, - sslProbe, - out.layer, - dbConn, - runtimeInfo, - experimentalFlag, - cliArgs, - networkIdFlag, - debugFlag, - processControl.layer, - alwaysReadyHttpClientLayer, - dockerRun, - BunServices.layer, - child.layer, - ); const nextFiles = opts.renderedFiles ?? []; const planErrors = [...(opts.planErrors ?? [])]; let planCalls = 0; const declarativeExportCalls: Array> = []; - const engine = - opts.engineImplementation === "next" - ? Layer.succeed( - LegacyPgDeltaEngine, - LegacyPgDeltaEngine.of({ - implementation: "next", - diffExplicit: () => Effect.die("diffExplicit not used in sync tests"), - diffDatabase: () => Effect.die("diffDatabase not used in sync tests"), - exportDeclarativeSchema: (input) => - Effect.sync(() => { - declarativeExportCalls.push(input.schema); - return { - files: [{ name: "public/tables/players.sql", sql: "create table players ();" }], - manifest: { redactSecrets: true, scope: "database", profile: "supabase" }, - }; - }), - planDeclarativeSchema: () => { - planCalls += 1; - const planError = planErrors.shift(); - if (planError !== undefined) return Effect.fail(planError); - const extensionPath = join(workdir, "supabase", "schemas", "extension.sql"); - const extensionSql = existsSync(extensionPath) - ? readFileSync(extensionPath, "utf8") - : ""; - const remainingExtensions = (opts.removals?.extensions ?? []).filter( - (extension) => !extensionSql.includes(`"${extension}"`), - ); - const extensionsRepaired = - remainingExtensions.length < (opts.removals?.extensions.length ?? 0); - return Effect.succeed({ - changes: nextFiles.length > 0, - sql: - extensionsRepaired && opts.replannedDiffSql !== undefined - ? opts.replannedDiffSql - : (opts.diffSql ?? nextFiles.map((file) => file.sql).join("\n")), - files: nextFiles, - sourceRef: "migrations", - targetRef: "declarative", - removals: - opts.removals === undefined - ? undefined - : { ...opts.removals, extensions: remainingExtensions }, - }); - }, - }), - ) - : legacyPgDeltaLegacyEngineLayer.pipe(Layer.provide(engineRuntime)); + const engine = Layer.succeed( + LegacyPgDeltaEngine, + LegacyPgDeltaEngine.of({ + diffExplicit: () => Effect.die("diffExplicit not used in sync tests"), + diffDatabase: () => Effect.die("diffDatabase not used in sync tests"), + exportDeclarativeSchema: (input) => + Effect.sync(() => { + declarativeExportCalls.push(input.schema); + return { + files: [{ name: "public/tables/players.sql", sql: "create table players ();" }], + manifest: { redactSecrets: true, scope: "database", profile: "supabase" }, + }; + }), + planDeclarativeSchema: () => { + planCalls += 1; + const planError = planErrors.shift(); + if (planError !== undefined) return Effect.fail(planError); + const extensionPath = join(workdir, "supabase", "schemas", "extension.sql"); + const extensionSql = existsSync(extensionPath) ? readFileSync(extensionPath, "utf8") : ""; + const remainingExtensions = (opts.removals?.extensions ?? []).filter( + (extension) => !extensionSql.includes(`"${extension}"`), + ); + const extensionsRepaired = + remainingExtensions.length < (opts.removals?.extensions.length ?? 0); + return Effect.succeed({ + changes: nextFiles.length > 0, + sql: + extensionsRepaired && opts.replannedDiffSql !== undefined + ? opts.replannedDiffSql + : (opts.diffSql ?? nextFiles.map((file) => file.sql).join("\n")), + files: nextFiles, + sourceRef: "migrations", + targetRef: "declarative", + removals: + opts.removals === undefined + ? undefined + : { ...opts.removals, extensions: remainingExtensions }, + }); + }, + }), + ); const layer = Layer.mergeAll( out.layer, telemetry.layer, @@ -383,7 +294,6 @@ function setup(workdir: string, opts: SetupOpts = {}) { cache, telemetry, localPostgresImageChecks, - exportCatalogCalls, declarativeExportCalls, get planCalls() { return planCalls; @@ -713,59 +623,41 @@ describe("legacy db schema declarative sync integration", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("bootstrap prints the declarative-schema-written line after the catalog warm", () => { - // Go's bootstrap delegates to `declarative.Generate`, which prints - // `Declarative schema written to ` to stderr AFTER WriteDeclarativeSchemas - // and the catalog warm (`declarative.go:133→138-155→156`), before sync's own - // diff (step 2). It prints `utils.GetDeclarativeDir()` — the relative - // `supabase/schemas` default — never the absolute resolved dir (CLI-1980). + it.effect("bootstrap prints the declarative-schema-written line after generating", () => { + // The bootstrap prints `Declarative schema written to ` to stderr after + // writing the generated files, before sync's own diff (step 2). It prints the + // relative `supabase/schemas` default — never the absolute resolved dir + // (CLI-1980). const s = setup(tmp.current, { experimental: true, stdinIsTty: true, diffSql: "", - exportJson: EXPORT_JSON, promptConfirmResponses: [true], // generate a new one? yes (no migrations → no reset prompt) }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); const line = `Declarative schema written to ${join("supabase", "schemas")}\n`; const written = s.out.rawChunks - .map((c, index) => ({ text: stripAnsi(c.text), stream: c.stream, index })) + .map((c) => ({ text: stripAnsi(c.text), stream: c.stream })) .filter((c) => c.text === line); expect(written).toHaveLength(1); expect(written[0]?.stream).toBe("stderr"); - const lineAt = written[0]?.index ?? -1; - // The warm (first declarative-mode export) fires before the line is printed… - const warm = s.exportCatalogCalls.find((c) => c.mode === "declarative"); - expect(warm?.rawChunksAt).toBeLessThanOrEqual(lineAt); - // …and the diff's migrations-catalog resolution (native, CLI-1959 cache - // mechanics + CLI-1956 native shadow provisioning — no seam `exportCatalog` - // call for it at all) fires after it, so the line sits at the end of the - // bootstrap, matching Go's ordering. `legacyGetMigrationsCatalogRef` prints - // "Creating shadow database..." right before provisioning; use that line's - // own position as the "diff's shadow started" signal. - const diffStartIndex = s.out.rawChunks.findIndex( - (c) => c.stream === "stderr" && stripAnsi(c.text) === "Creating shadow database...\n", - ); - expect(diffStartIndex).toBeGreaterThan(lineAt); // The generated files actually landed in the printed (resolved) dir. expect( - existsSync( - join(tmp.current, "supabase", "schemas", "schemas", "public", "tables", "players.sql"), - ), + existsSync(join(tmp.current, "supabase", "schemas", "public", "tables", "players.sql")), ).toBe(true); + expect(s.declarativeExportCalls).toHaveLength(1); }).pipe(Effect.provide(s.layer)); }); it.effect("--yes bootstrap prints the declarative-schema-written line too", () => { - // Go reaches the same delegated `declarative.Generate` print on the - // auto-confirmed (--yes / SUPABASE_YES) bootstrap as on the interactive accept. + // The auto-confirmed (--yes / SUPABASE_YES) bootstrap reaches the same + // written-to print as the interactive accept. const s = setup(tmp.current, { experimental: true, stdinIsTty: false, yes: true, diffSql: "", - exportJson: EXPORT_JSON, }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); @@ -778,73 +670,6 @@ describe("legacy db schema declarative sync integration", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("--no-cache bootstrap still prints the declarative-schema-written line", () => { - // Go's print sits OUTSIDE the `if !noCache` warm gate (`declarative.go:138-156`): - // skipping the catalog warm must not skip the line. - const s = setup(tmp.current, { - experimental: true, - stdinIsTty: false, - yes: true, - diffSql: "", - exportJson: EXPORT_JSON, - }); - return Effect.gen(function* () { - yield* legacyDbSchemaDeclarativeSync(flags({ noCache: true, noApply: Option.some(true) })); - const line = `Declarative schema written to ${join("supabase", "schemas")}\n`; - const written = s.out.rawChunks - .map((c, index) => ({ text: stripAnsi(c.text), stream: c.stream, index })) - .filter((c) => c.text === line); - expect(written).toHaveLength(1); - expect(written[0]?.stream).toBe("stderr"); - // The warm really was skipped: the only declarative-mode export is the diff's, - // which fires after the line — yet the line still printed. - const lineAt = written[0]?.index ?? -1; - const declarativeExports = s.exportCatalogCalls.filter((c) => c.mode === "declarative"); - expect(declarativeExports).toHaveLength(1); - expect(declarativeExports[0]?.rawChunksAt).toBeGreaterThan(lineAt); - }).pipe(Effect.provide(s.layer)); - }); - - it.effect( - "validates the migrations-catalog shadow's own local config (api.tls cert file) BEFORE printing 'Creating shadow database...'", - () => { - // `legacyGetMigrationsCatalogRef`'s own second `@supabase/config` load - // (`legacyBuildLocalDbContainerInputs`, run via `legacyBuildShadowCatalogInputs`) - // validates fields (e.g. an enabled API TLS's cert/key files) that `toml` never - // reads — Go performs this exact validation once, in the root - // `PersistentPreRunE`, strictly before `declarative.go`'s `createShadowContainer` - // ever prints "Creating shadow database..." (`declarative.go:490`). So a broken - // build must fail here without ever printing that banner. - seedDeclarative(tmp.current); - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync( - join(tmp.current, "supabase", "config.toml"), - [ - "[api]", - "enabled = true", - "[api.tls]", - "enabled = true", - 'cert_path = "missing-cert.pem"', - 'key_path = "missing-key.pem"', - "", - ].join("\n"), - ); - const s = setup(tmp.current, { experimental: true }); - return Effect.gen(function* () { - const exit = yield* Effect.exit(legacyDbSchemaDeclarativeSync(flags())); - expect(Exit.isFailure(exit)).toBe(true); - expect((failError(exit) as { message: string }).message).toContain( - "failed to read TLS cert", - ); - expect( - s.out.rawChunks.some( - (c) => c.stream === "stderr" && stripAnsi(c.text) === "Creating shadow database...\n", - ), - ).toBe(false); - }).pipe(Effect.provide(s.layer)); - }, - ); - it.effect("bootstrap with migrations offers the smart target choice (not local-only)", () => { // Go delegates the no-files bootstrap to runDeclarativeGenerate; with migrations // present it offers local/linked/custom rather than silently generating from @@ -902,7 +727,6 @@ describe("legacy db schema declarative sync integration", () => { staleLocalImage: true, projectId: Option.some("abcdefghijklmnopqrst"), diffSql: "ALTER TABLE a ADD COLUMN b int;\n", - exportJson: EXPORT_JSON, promptConfirmResponses: [true], // generate a new one? yes promptSelectResponses: ["linked"], }); @@ -966,11 +790,13 @@ describe("legacy db schema declarative sync integration", () => { const exit = yield* Effect.exit( legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })), ); - // The probe was softened: it reached generation and failed downstream on the - // empty edge-runtime output, NOT on the migrations directory read. - const msg = JSON.stringify(exit); - expect(msg).not.toContain("failed to read directory"); - expect(msg).toContain("edge-runtime script produced no output"); + // The probe was softened: it reached generation (files written, sync + // completed on the empty diff), NOT an abort on the migrations directory read. + expect(JSON.stringify(exit)).not.toContain("failed to read directory"); + expect(Exit.isSuccess(exit)).toBe(true); + expect( + existsSync(join(tmp.current, "supabase", "schemas", "public", "tables", "players.sql")), + ).toBe(true); }).pipe(Effect.provide(s.layer)); }); @@ -1003,12 +829,11 @@ describe("legacy db schema declarative sync integration", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("bootstrap caches the linked project even when a later step fails (Go PostRun)", () => { - // Go's bootstrap delegates to runDeclarativeGenerate, whose LoadProjectRef (under - // hasMigrationFiles) sets flags.ProjectRef; root ensureProjectGroupsCached then - // writes the linked-project cache on success OR failure (cmd/root.go:176,214-218). - // Here the bootstrap resolves the linked ref then fails (empty generate output), - // and the linked-project cache must still be written. + it.effect("bootstrap caches the linked project after resolving the ref", () => { + // The bootstrap resolves the linked ref (config project_id → .temp/project-ref) + // when migrations exist, and the handler's finalizer writes the linked-project + // cache whether sync succeeds or fails. Here it resolves the ref, bootstraps + // from local, and completes on the empty diff — the cache must be written. mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); const s = setup(tmp.current, { @@ -1067,7 +892,9 @@ describe("legacy db schema declarative sync integration", () => { const migrations = readdirSync(join(tmp.current, "supabase", "migrations")); expect(migrations).toHaveLength(1); expect(migrations[0]).toMatch(/^\d{14}_declarative_sync\.sql$/); - expect(s.out.rawChunks.some((c) => c.text.includes("Found drop statements"))).toBe(true); + expect( + s.out.rawChunks.some((c) => c.text.includes("Found destructive changes in schema diff")), + ).toBe(true); expect(s.dbExec).toEqual([]); // not applied }).pipe(Effect.provide(s.layer)); }, @@ -1096,7 +923,6 @@ describe("legacy db schema declarative sync integration", () => { it.effect("refuses a known implicit-extension load failure under --yes", () => { seedLegacyUuidDeclarative(tmp.current); const s = setup(tmp.current, { - engineImplementation: "next", yes: true, planErrors: [legacyUuidLoadError()], }); @@ -1125,7 +951,6 @@ describe("legacy db schema declarative sync integration", () => { it.effect("adds a missing load-time extension declaration and re-plans", () => { seedLegacyUuidDeclarative(tmp.current); const s = setup(tmp.current, { - engineImplementation: "next", stdinIsTty: true, planErrors: [legacyUuidLoadError()], promptSelectResponses: ["repair"], @@ -1152,7 +977,6 @@ describe("legacy db schema declarative sync integration", () => { ); const before = readFileSync(activeMember, "utf8"); const s = setup(tmp.current, { - engineImplementation: "next", stdinIsTty: true, planErrors: [legacyUuidLoadError()], promptSelectResponses: ["stage"], @@ -1195,7 +1019,6 @@ describe("legacy db schema declarative sync integration", () => { ); const before = readFileSync(activeMember, "utf8"); const s = setup(tmp.current, { - engineImplementation: "next", stdinIsTty: true, planErrors: [legacyUuidLoadError()], promptSelectResponses: ["stage"], @@ -1225,7 +1048,6 @@ describe("legacy db schema declarative sync integration", () => { seedDeclarative(tmp.current); const s = setup(tmp.current, { experimental: true, - engineImplementation: "next", yes: true, diffSql: "select cron.unschedule('refresh download metrics');\nDROP EXTENSION \"pgcrypto\";\n", @@ -1258,7 +1080,6 @@ describe("legacy db schema declarative sync integration", () => { it.effect("directs pg_net users to enable Database Webhooks before writing", () => { seedDeclarative(tmp.current); const s = setup(tmp.current, { - engineImplementation: "next", stdinIsTty: true, diffSql: 'DROP EXTENSION "pg_net";\n', removals: { extensions: ["pg_net"], extensionIntents: [] }, @@ -1280,7 +1101,6 @@ describe("legacy db schema declarative sync integration", () => { () => { seedDeclarative(tmp.current); const s = setup(tmp.current, { - engineImplementation: "next", stdinIsTty: true, diffSql: 'DROP EXTENSION "pgcrypto";\n', removals: { extensions: ["pgcrypto"], extensionIntents: [] }, @@ -1296,7 +1116,6 @@ describe("legacy db schema declarative sync integration", () => { it.effect("repairs the active tree in place when the user picks the advanced choice", () => { seedDeclarative(tmp.current); const s = setup(tmp.current, { - engineImplementation: "next", stdinIsTty: true, diffSql: 'DROP EXTENSION "pgcrypto";\n', replannedDiffSql: "ALTER TABLE a ADD COLUMN b int;\n", @@ -1316,7 +1135,6 @@ describe("legacy db schema declarative sync integration", () => { it.effect("stages a next export from the repair prompt without touching the tree", () => { seedDeclarative(tmp.current); const s = setup(tmp.current, { - engineImplementation: "next", stdinIsTty: true, diffSql: 'DROP EXTENSION "pgcrypto";\n', removals: { extensions: ["pgcrypto"], extensionIntents: [] }, @@ -1343,7 +1161,6 @@ describe("legacy db schema declarative sync integration", () => { // the spawner route's assumption (same as the apply-failure reset test). writeFileSync(join(tmp.current, "supabase", "config.toml"), 'project_id = "test"\n'); const s = setup(tmp.current, { - engineImplementation: "next", stdinIsTty: true, diffSql: 'DROP EXTENSION "pgcrypto";\n', removals: { extensions: ["pgcrypto"], extensionIntents: [] }, @@ -1368,7 +1185,6 @@ describe("legacy db schema declarative sync integration", () => { it.effect("cancels compatibility resolution without schema or migration writes", () => { seedDeclarative(tmp.current); const s = setup(tmp.current, { - engineImplementation: "next", stdinIsTty: true, diffSql: 'DROP EXTENSION "uuid-ossp";\n', removals: { extensions: ["uuid-ossp"], extensionIntents: [] }, @@ -1389,14 +1205,13 @@ describe("legacy db schema declarative sync integration", () => { ); const s = setup(tmp.current, { experimental: true, - engineImplementation: "next", diffSql: 'DROP EXTENSION "pgcrypto";\n', removals: { extensions: ["pgcrypto"], extensionIntents: [] }, }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); const output = stripAnsi(s.out.rawChunks.map((chunk) => chunk.text).join("")); - expect(output).not.toContain("may have been generated by the legacy engine"); + expect(output).not.toContain("looks like a legacy pg-delta export"); expect(output).toContain("Found destructive changes"); }).pipe(Effect.provide(s.layer)); }); @@ -1512,11 +1327,10 @@ describe("legacy db schema declarative sync integration", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("next engine preserves ordered migration segments as separate files", () => { + it.effect("preserves ordered migration segments as separate files", () => { seedDeclarative(tmp.current); const s = setup(tmp.current, { experimental: true, - engineImplementation: "next", renderedFiles: [ { sequence: 1, @@ -1540,7 +1354,6 @@ describe("legacy db schema declarative sync integration", () => { expect(migrations).toHaveLength(2); expect(migrations[0]).toMatch(/^\d{14}_declarative_sync_1\.sql$/); expect(migrations[1]).toMatch(/^\d{14}_declarative_sync_2\.sql$/); - expect(s.exportCatalogCalls).toEqual([]); }).pipe(Effect.provide(s.layer)); }); }); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-debug-bundle.ts b/apps/cli/src/legacy/commands/db/shared/legacy-debug-bundle.ts index f3b08a22dc..0353173c59 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-debug-bundle.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-debug-bundle.ts @@ -1,7 +1,7 @@ import { Effect, type FileSystem, type Path } from "effect"; import { legacyBold, legacyYellow } from "../../../shared/legacy-colors.ts"; -import { legacyListLocalMigrations } from "../../../shared/legacy-pgdelta.cache.ts"; +import { legacyListLocalMigrations } from "../../../shared/legacy-migration-list.ts"; /** * Diagnostic artifacts collected when a pg-delta operation fails (or an empty diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.layer.ts index 9b7d34e85e..699b38b68e 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.layer.ts @@ -1,75 +1,21 @@ -import { Effect, FileSystem, Layer, Path } from "effect"; +import { Layer } from "effect"; import { legacyHttpClientLayer } from "../../../auth/legacy-http-debug.layer.ts"; import { legacyCliSettingsLayer } from "../../../config/legacy-cli-settings.layer.ts"; -import { LegacyCliSettings } from "../../../config/legacy-cli-settings.service.ts"; import { legacyDbConfigLayer } from "../../../shared/legacy-db-config.layer.ts"; import { legacyDbConnectionLayer } from "../../../shared/legacy-db-connection.layer.ts"; -import { legacyLoadProjectEnv } from "../../../shared/legacy-db-config.toml-read.ts"; import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; -import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; import { legacyDockerRunLayer } from "../../../shared/legacy-docker-run.layer.ts"; import { legacyEdgeRuntimeScriptLayer } from "../../../shared/legacy-edge-runtime-script.layer.ts"; import { legacyIdentityStitchLayer } from "../../../shared/legacy-identity-stitch.ts"; import { legacyPgDeltaSslProbeLayer } from "../../../shared/legacy-pgdelta-ssl-probe.layer.ts"; -import { - LEGACY_PG_DELTA_NEXT_FLAG_NAME, - legacyPgDeltaImplementationFlag, - legacyResolvePgDeltaImplementation, -} from "../../../shared/legacy-pgdelta-next-flag.ts"; -import { legacyPgDeltaLegacyEngineLayer } from "./legacy-pgdelta-engine.legacy.layer.ts"; import { legacyPgDeltaNextEngineLayer } from "./legacy-pgdelta-engine.next.layer.ts"; -import { LegacyPgDeltaEngine } from "./legacy-pgdelta-engine.service.ts"; import { legacyPgDeltaNextAdapterLayer } from "./legacy-pgdelta-next-adapter.layer.ts"; import { legacyPgDeltaNextShadowLayer } from "./legacy-pgdelta-next-shadow.layer.ts"; import { legacyDeclarativeSeamLayer } from "./legacy-pgdelta.seam.layer.ts"; -const resolveAndLog = Effect.fnUntraced(function* (raw: string | undefined) { - const debug = yield* LegacyDebugLogger; - const implementation = legacyResolvePgDeltaImplementation(raw); - yield* debug.debug(`Using pg-delta ${implementation} implementation.`); - return implementation; -}); - -/** - * Selects exactly one implementation layer. There is intentionally no catch or - * retry path between implementations: a selected next-engine failure must - * propagate without invoking the legacy adapter. - */ -export function legacyPgDeltaEngineSelectorLayer( - raw: string | undefined, - layers: { - readonly next: Layer.Layer; - readonly legacy: Layer.Layer; - }, -): Layer.Layer { - return Layer.unwrap( - Effect.gen(function* () { - const implementation = yield* resolveAndLog(raw); - const selected: Layer.Layer = - implementation === "next" ? layers.next : layers.legacy; - return selected; - }), - ); -} - -/** Resolves the rollout flag once when the command-scoped layer is constructed. */ -export const legacyPgDeltaEngineLayer = Layer.unwrap( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cliSettings = yield* LegacyCliSettings; - const projectEnv = yield* legacyLoadProjectEnv(fs, path, cliSettings.workdir); - const raw = legacyPgDeltaImplementationFlag( - process.env[LEGACY_PG_DELTA_NEXT_FLAG_NAME], - projectEnv[LEGACY_PG_DELTA_NEXT_FLAG_NAME], - ); - return legacyPgDeltaEngineSelectorLayer(raw, { - next: legacyPgDeltaNextEngineLayer, - legacy: legacyPgDeltaLegacyEngineLayer, - }); - }), -); +/** The in-process pg-delta engine — the only implementation. */ +export const legacyPgDeltaEngineLayer = legacyPgDeltaNextEngineLayer; export const legacyPgDeltaCliSettingsRuntimeLayer = legacyCliSettingsLayer.pipe( Layer.provide(legacyDebugLoggerLayer), @@ -91,8 +37,6 @@ const seam = legacyDeclarativeSeamLayer.pipe( Layer.provide(legacyPgDeltaCliSettingsRuntimeLayer), Layer.provide(legacyDbConnectionLayer), Layer.provide(legacyDockerRunLayer), - Layer.provide(edgeRuntime), - Layer.provide(legacyPgDeltaSslProbeLayer), Layer.provide(httpClient), ); const nextShadow = legacyPgDeltaNextShadowLayer.pipe( @@ -104,9 +48,6 @@ const engine = legacyPgDeltaEngineLayer.pipe( Layer.provide(legacyPgDeltaCliSettingsRuntimeLayer), Layer.provide(legacyPgDeltaNextAdapterLayer), Layer.provide(nextShadow), - Layer.provide(edgeRuntime), - Layer.provide(legacyPgDeltaSslProbeLayer), - Layer.provide(seam), Layer.provide(legacyDockerRunLayer), Layer.provide(legacyDbConnectionLayer), Layer.provide(httpClient), diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.layer.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.layer.unit.test.ts deleted file mode 100644 index 7b5225937d..0000000000 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.layer.unit.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { Effect, Exit, Layer } from "effect"; -import { it } from "@effect/vitest"; -import { describe, expect } from "vitest"; - -import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; -import { legacyPgDeltaEngineSelectorLayer } from "./legacy-pgdelta-engine.layer.ts"; -import { LegacyPgDeltaEngine } from "./legacy-pgdelta-engine.service.ts"; - -function debugLayer(messages: Array) { - return Layer.succeed(LegacyDebugLogger, { - debug: (message) => Effect.sync(() => messages.push(message)), - http: () => Effect.void, - }); -} - -function metadataLayer(implementation: "next" | "legacy") { - return Layer.succeed( - LegacyPgDeltaEngine, - LegacyPgDeltaEngine.of({ - implementation, - diffExplicit: () => Effect.die(`${implementation} explicit diff not needed`), - diffDatabase: () => Effect.die(`${implementation} database diff not needed`), - exportDeclarativeSchema: () => Effect.die(`${implementation} export not needed`), - planDeclarativeSchema: () => Effect.die(`${implementation} plan not needed`), - }), - ); -} - -describe("legacyPgDeltaEngineSelectorLayer", () => { - it.effect("selects next by default and logs the decision once", () => { - const messages: Array = []; - return Effect.gen(function* () { - const engine = yield* LegacyPgDeltaEngine; - expect(engine.implementation).toBe("next"); - expect(messages).toEqual(["Using pg-delta next implementation."]); - }).pipe( - Effect.provide( - legacyPgDeltaEngineSelectorLayer(undefined, { - next: metadataLayer("next"), - legacy: metadataLayer("legacy"), - }).pipe(Layer.provide(debugLayer(messages))), - ), - ); - }); - - it.effect("does not invoke legacy after a selected next operation fails", () => { - const messages: Array = []; - let nextCalls = 0; - let legacyCalls = 0; - const next = Layer.succeed( - LegacyPgDeltaEngine, - LegacyPgDeltaEngine.of({ - implementation: "next", - diffExplicit: () => - Effect.sync(() => { - nextCalls += 1; - }).pipe(Effect.andThen(Effect.die("next diff failed"))), - diffDatabase: () => Effect.die("next database diff failed"), - exportDeclarativeSchema: () => Effect.die("next export failed"), - planDeclarativeSchema: () => Effect.die("next plan failed"), - }), - ); - const legacy = Layer.succeed( - LegacyPgDeltaEngine, - LegacyPgDeltaEngine.of({ - implementation: "legacy", - diffExplicit: () => - Effect.sync(() => { - legacyCalls += 1; - return { - changes: false, - sql: "", - files: [], - }; - }), - diffDatabase: () => Effect.die("legacy database diff should not run"), - exportDeclarativeSchema: () => Effect.die("legacy export should not run"), - planDeclarativeSchema: () => Effect.die("legacy plan should not run"), - }), - ); - - return Effect.gen(function* () { - const engine = yield* LegacyPgDeltaEngine; - const exit = yield* engine - .diffExplicit({ - context: { - projectId: "test", - cwd: "/tmp/test", - npmVersion: undefined, - denoVersion: 2, - projectEnv: {}, - }, - source: { - kind: "database", - ref: "postgresql://localhost/source", - connectOptions: { isLocal: true, dnsResolver: "native" }, - }, - desired: { - kind: "database", - ref: "postgresql://localhost/desired", - connectOptions: { isLocal: true, dnsResolver: "native" }, - }, - schema: [], - formatOptions: "", - debug: false, - strictCoverage: false, - }) - .pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(nextCalls).toBe(1); - expect(legacyCalls).toBe(0); - }).pipe( - Effect.provide( - legacyPgDeltaEngineSelectorLayer("true", { next, legacy }).pipe( - Layer.provide(debugLayer(messages)), - ), - ), - ); - }); -}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.legacy.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.legacy.layer.ts deleted file mode 100644 index a0878b34a8..0000000000 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.legacy.layer.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { Effect, FileSystem, Layer, Path } from "effect"; -import * as HttpClient from "effect/unstable/http/HttpClient"; -import { ChildProcessSpawner } from "effect/unstable/process"; - -import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; -import { - LegacyDebugFlag, - LegacyExperimentalFlag, - LegacyNetworkIdFlag, -} from "../../../../shared/legacy/global-flags.ts"; -import { Output } from "../../../../shared/output/output.service.ts"; -import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; -import { legacyYellow } from "../../../shared/legacy-colors.ts"; -import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; -import { LegacyEdgeRuntimeScript } from "../../../shared/legacy-edge-runtime-script.service.ts"; -import { LegacyDockerRun } from "../../../shared/legacy-docker-run.service.ts"; -import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; -import type { LegacyDbTomlValues } from "../../../shared/legacy-db-config.toml-read.ts"; -import { legacyFindDropStatements } from "../../../shared/legacy-sql-split.ts"; -import { - LegacyPgDeltaEngine, - LegacyPgDeltaEngineError, - type LegacyPgDeltaDiffResult, - type LegacyPgDeltaEndpoint, -} from "./legacy-pgdelta-engine.service.ts"; -import type { LegacyMigrationTransactionMode } from "../../../shared/legacy-migration-file.ts"; -import { - type LegacyPgDeltaContext, - legacyDeclarativeExportPgDelta, - legacyDiffPgDelta, - legacyExportCatalogPgDelta, -} from "../../../shared/legacy-pgdelta.ts"; -import { - legacyGetMigrationsCatalogRef, - legacyResolveMigrationsCatalogRef, -} from "../../../shared/legacy-pgdelta.cache.ts"; -import { LegacyDeclarativeSeam } from "./legacy-pgdelta.seam.service.ts"; - -const mapError = (cause: { readonly message: string }) => - new LegacyPgDeltaEngineError({ message: cause.message, cause }); - -/** - * `--strict-coverage` is enforced entirely by the next engine's diagnostic report - * (`legacy-pgdelta-next-diagnostics.ts`); this adapter has no coverage diagnostics - * to reject, so the flag is a silent no-op under - * `SUPABASE_USE_PG_DELTA_NEXT=false`. Say so once instead, rather than letting a - * user believe an unsupported-object guard is active. Mirrors `db diff`'s - * `warnPgSchemaDeprecated` line shape. - */ -export const legacyStrictCoverageIgnoredWarning = `${legacyYellow( - "WARNING:", -)} "--strict-coverage" has no effect with the legacy pg-delta engine.`; - -function normalizeDiff( - result: { - readonly sql: string; - readonly stderr: string; - readonly files: ReadonlyArray<{ - readonly order: number; - readonly name: string; - readonly transactionMode: LegacyMigrationTransactionMode; - readonly sql: string; - }>; - }, - debug: boolean, -): LegacyPgDeltaDiffResult { - return { - changes: result.sql.trim().length > 0, - sql: result.sql, - files: result.files.map((file) => ({ - sequence: file.order, - name: file.name, - sql: file.sql, - transactionMode: file.transactionMode, - })), - ...(debug ? { debug: { stderr: result.stderr } } : {}), - }; -} - -/** Behavior-preserving adapter for the alpha.33 edge-runtime implementation. */ -export const legacyPgDeltaLegacyEngineLayer = Layer.effect( - LegacyPgDeltaEngine, - Effect.gen(function* () { - const edgeRuntime = yield* LegacyEdgeRuntimeScript; - const sslProbe = yield* LegacyPgDeltaSslProbe; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const seam = yield* LegacyDeclarativeSeam; - const output = yield* Output; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const runtimeInfo = yield* RuntimeInfo; - const dbConnection = yield* LegacyDbConnection; - const docker = yield* LegacyDockerRun; - const httpClient = yield* HttpClient.HttpClient; - const cliArgs = yield* CliArgs; - const debugFlag = yield* LegacyDebugFlag; - const experimentalFlag = yield* LegacyExperimentalFlag; - const networkIdFlag = yield* LegacyNetworkIdFlag; - - const runtime = Layer.mergeAll( - Layer.succeed(LegacyEdgeRuntimeScript, edgeRuntime), - Layer.succeed(LegacyPgDeltaSslProbe, sslProbe), - Layer.succeed(FileSystem.FileSystem, fs), - Layer.succeed(Path.Path, path), - Layer.succeed(LegacyDeclarativeSeam, seam), - Layer.succeed(Output, output), - Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), - Layer.succeed(RuntimeInfo, runtimeInfo), - Layer.succeed(LegacyDbConnection, dbConnection), - Layer.succeed(LegacyDockerRun, docker), - Layer.succeed(HttpClient.HttpClient, httpClient), - Layer.succeed(CliArgs, cliArgs), - Layer.succeed(LegacyDebugFlag, debugFlag), - Layer.succeed(LegacyExperimentalFlag, experimentalFlag), - Layer.succeed(LegacyNetworkIdFlag, networkIdFlag), - ); - - const provideRuntime = ( - operation: Effect.Effect, - ) => operation.pipe(Effect.provide(runtime)); - - // Emitted from the engine layer, not from each handler: this is the single place - // where the resolved implementation and the per-operation input meet, so all four - // workflows (`db diff`, `db pull`, and declarative `generate`/`sync`) get the line - // with no per-command wiring. Once per process — `sync` can plan twice (extension - // repair re-plans) and a repeated line adds nothing. - let strictCoverageWarned = false; - const warnStrictCoverageIgnored = (strictCoverage: boolean) => - Effect.suspend(() => { - if (!strictCoverage || strictCoverageWarned) return Effect.void; - strictCoverageWarned = true; - return output.raw(`${legacyStrictCoverageIgnoredWarning}\n`, "stderr"); - }); - - const endpointRef = ( - context: LegacyPgDeltaContext, - endpoint: LegacyPgDeltaEndpoint, - toml: LegacyDbTomlValues | undefined, - ) => - endpoint.kind === "database" - ? Effect.succeed(endpoint.ref) - : toml === undefined - ? Effect.fail( - new LegacyPgDeltaEngineError({ - message: "pg-delta migrations endpoint requires loaded database config", - cause: "missing database config", - }), - ) - : legacyResolveMigrationsCatalogRef( - fs, - path, - context, - toml, - endpoint.projectRef !== undefined ? { projectRef: endpoint.projectRef } : {}, - ).pipe(provideRuntime); - - return LegacyPgDeltaEngine.of({ - implementation: "legacy", - diffExplicit: (input) => - Effect.gen(function* () { - yield* warnStrictCoverageIgnored(input.strictCoverage); - const sourceRef = yield* endpointRef(input.context, input.source, input.toml); - const targetRef = yield* endpointRef(input.context, input.desired, input.toml); - const result = yield* provideRuntime( - legacyDiffPgDelta(input.context, { - sourceRef, - targetRef, - schema: input.schema, - formatOptions: input.formatOptions, - }), - ); - return normalizeDiff(result, input.debug); - }).pipe(Effect.mapError(mapError)), - diffDatabase: (input) => - Effect.gen(function* () { - yield* warnStrictCoverageIgnored(input.strictCoverage); - const sourceSnapshot = input.debug - ? yield* provideRuntime( - legacyExportCatalogPgDelta(input.context, { - targetRef: input.source.ref, - role: "postgres", - }), - ).pipe(Effect.orElseSucceed(() => undefined)) - : undefined; - return yield* provideRuntime( - legacyDiffPgDelta(input.context, { - sourceRef: input.source.ref, - targetRef: input.target.ref, - schema: input.schema, - formatOptions: input.formatOptions, - }), - ).pipe( - Effect.map((result) => { - const normalized = normalizeDiff(result, input.debug); - return input.debug - ? { - ...normalized, - debug: { - ...(sourceSnapshot !== undefined ? { sourceSnapshot } : {}), - stderr: result.stderr, - }, - } - : normalized; - }), - ); - }).pipe(Effect.mapError(mapError)), - exportDeclarativeSchema: (input) => - Effect.gen(function* () { - yield* warnStrictCoverageIgnored(input.strictCoverage); - if (input.source === undefined) { - return yield* Effect.fail( - new LegacyPgDeltaEngineError({ - message: "legacy pg-delta declarative export requires an empty shadow database", - cause: "missing declarative export source", - }), - ); - } - const result = yield* provideRuntime( - legacyDeclarativeExportPgDelta(input.context, { - sourceRef: input.source.ref, - targetRef: input.target.ref, - schema: input.schema, - formatOptions: input.formatOptions, - }), - ); - return { - files: result.files.map((file) => ({ name: file.path, sql: file.sql })), - }; - }).pipe(Effect.mapError(mapError)), - planDeclarativeSchema: (input) => - Effect.gen(function* () { - yield* warnStrictCoverageIgnored(input.strictCoverage); - const sourceRef = yield* legacyGetMigrationsCatalogRef( - fs, - path, - input.context, - input.toml, - input.setupInputs, - { - noCache: input.noCache, - ...(input.projectRef !== undefined ? { projectRef: input.projectRef } : {}), - }, - ).pipe(provideRuntime); - const targetRef = yield* seam.exportCatalog({ - mode: "declarative", - noCache: input.noCache, - }); - const result = yield* provideRuntime( - legacyDiffPgDelta(input.context, { - sourceRef, - targetRef, - schema: input.schema, - formatOptions: input.formatOptions, - }), - ); - return { - ...normalizeDiff(result, input.debug), - sourceRef, - targetRef, - dropWarnings: legacyFindDropStatements(result.sql), - }; - }).pipe(Effect.mapError(mapError)), - }); - }), -); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.integration.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.integration.test.ts index fdd8189d7f..34c21d14bc 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.integration.test.ts @@ -15,7 +15,6 @@ const common = { context: { projectId: "test", cwd: "/tmp/test", - npmVersion: undefined, denoVersion: 2, projectEnv: {}, }, @@ -41,7 +40,6 @@ const toml: LegacyDbTomlValues = { enabled: false, declarativeSchemaPath: Option.none(), formatOptions: Option.none(), - npmVersion: Option.none(), }, webhooksEnabled: false, baseline: { @@ -162,16 +160,6 @@ describe("pg-delta next shadow selection", () => { toml, files: [{ name: "schema.sql", sql: "create table example(id int);" }], noCache: false, - setupInputs: { - image: "postgres:17", - majorVersion: 17, - authEnabled: true, - storageEnabled: true, - realtimeEnabled: true, - autoExpose: true, - vaultNames: [], - rolesSql: "", - }, }) .pipe(Effect.exit); @@ -191,16 +179,6 @@ describe("pg-delta next shadow selection", () => { toml, files: [{ name: "schema.sql", sql: "create table example(id int);" }], noCache: true, - setupInputs: { - image: "postgres:17", - majorVersion: 17, - authEnabled: true, - storageEnabled: true, - realtimeEnabled: true, - autoExpose: true, - vaultNames: [], - rolesSql: "", - }, }) .pipe(Effect.exit); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.ts index cad0d179a2..139a33f8ae 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.ts @@ -233,7 +233,6 @@ export const legacyPgDeltaNextEngineLayer = Layer.effect( }); return LegacyPgDeltaEngine.of({ - implementation: "next", diffExplicit: (input) => Effect.scoped( Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.service.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.service.ts index 837a057614..46a23ff8a7 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.service.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.service.ts @@ -5,8 +5,6 @@ import type { LegacyPgConnInput, } from "../../../shared/legacy-db-connection.service.ts"; import type { LegacyPgDeltaContext } from "../../../shared/legacy-pgdelta.ts"; -import type { LegacySetupInputs } from "../../../shared/legacy-pgdelta.cache.ts"; -import type { LegacyPgDeltaImplementation } from "../../../shared/legacy-pgdelta-next-flag.ts"; import type { LegacyMigrationTransactionMode } from "../../../shared/legacy-migration-file.ts"; import type { LegacyDbTomlValues } from "../../../shared/legacy-db-config.toml-read.ts"; import { @@ -17,9 +15,9 @@ import { export interface LegacyPgDeltaDatabaseEndpoint { readonly kind: "database"; - /** URL/reference used by the legacy edge-runtime implementation. */ + /** Postgres connection URL; parsed when `connection` is absent. */ readonly ref: string; - /** Full parsed connection, preferred by the next implementation. */ + /** Full parsed connection, preferred over parsing `ref`. */ readonly connection?: LegacyPgConnInput; readonly connectOptions: LegacyDbConnectOptions; } @@ -139,10 +137,7 @@ export interface LegacyPgDeltaDatabaseDiffInput extends LegacyPgDeltaCommonInput } interface LegacyPgDeltaDeclarativeExportInput extends LegacyPgDeltaCommonInput { - /** Workflow-owned empty shadow used only by the legacy declarative exporter. */ - readonly source?: LegacyPgDeltaDatabaseEndpoint; readonly target: LegacyPgDeltaDatabaseEndpoint; - readonly noCache: boolean; } export interface LegacyPgDeltaDeclarativeExportResult { @@ -156,7 +151,6 @@ export interface LegacyPgDeltaDeclarativePlanInput extends LegacyPgDeltaCommonIn readonly noCache: boolean; /** Already-loaded config used by native shadow/catalog provisioning. */ readonly toml: LegacyDbTomlValues; - readonly setupInputs: LegacySetupInputs; } interface LegacyPgDeltaDeclarativePlanResult extends LegacyPgDeltaDiffResult { @@ -185,7 +179,6 @@ export class LegacyPgDeltaEngineError extends Data.TaggedError("LegacyPgDeltaEng } export interface LegacyPgDeltaEngineShape { - readonly implementation: LegacyPgDeltaImplementation; readonly diffExplicit: ( input: LegacyPgDeltaExplicitDiffInput, ) => Effect.Effect; diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.integration.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.integration.test.ts deleted file mode 100644 index 6522c8f877..0000000000 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.integration.test.ts +++ /dev/null @@ -1,983 +0,0 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { BunServices } from "@effect/platform-bun"; -import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer } from "effect"; - -import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; -import { LegacyDebugFlag } from "../../../../shared/legacy/global-flags.ts"; -import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; -import { - type LegacyEdgeRuntimeRunOpts, - type LegacyEdgeRuntimeRunResult, - LegacyEdgeRuntimeScript, -} from "../../../shared/legacy-edge-runtime-script.service.ts"; -import { LegacyEdgeRuntimeScriptError } from "../../../shared/legacy-edge-runtime-script.errors.ts"; -import { legacyApplyDeclarativePgDelta } from "./legacy-pgdelta.apply.ts"; -import type { LegacyPgDeltaContext } from "../../../shared/legacy-pgdelta.ts"; - -const CTX: LegacyPgDeltaContext = { - projectId: "ref", - cwd: "/proj", - npmVersion: undefined, - denoVersion: 2, - projectEnv: {}, -}; - -function fakeEdgeRuntime(outcome: { stdout?: string; stderr?: string; fail?: string } = {}) { - const calls: Array = []; - const layer = Layer.succeed(LegacyEdgeRuntimeScript, { - run: (opts: LegacyEdgeRuntimeRunOpts) => { - calls.push(opts); - if (outcome.fail !== undefined) { - return Effect.fail(new LegacyEdgeRuntimeScriptError({ message: outcome.fail })); - } - return Effect.succeed({ - stdout: outcome.stdout ?? "", - stderr: outcome.stderr ?? "", - } satisfies LegacyEdgeRuntimeRunResult); - }, - }); - return { layer, calls }; -} - -function makeDeclarativeDir(): string { - const dir = mkdtempSync(join(tmpdir(), "legacy-pgdelta-apply-")); - mkdirSync(join(dir, "declarative"), { recursive: true }); - writeFileSync(join(dir, "declarative", "public.sql"), "create table t ();"); - return join(dir, "declarative"); -} - -const failError = (exit: Exit.Exit) => - Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isFailReason)?.error : undefined; - -describe("legacyApplyDeclarativePgDelta", () => { - it.effect( - "fails with LegacyPgDeltaDeclarativeApplyError interpolating the RELATIVE dir, not the absolute one, when the declarative dir doesn't exist", - () => { - // Go's `ApplyDeclarative` interpolates `utils.GetDeclarativeDir()` (relative) into - // this error, never the `filepath.Abs`-resolved dir it separately computes only for - // the bind (`apply.go:304-307`). - const edge = fakeEdgeRuntime(); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: "/does/not/exist", - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - expect((failError(exit) as { message: string }).message).toBe( - "declarative schema directory not found: supabase/database", - ); - // Never even reaches the edge-runtime — the exists() check runs first. - expect(edge.calls).toHaveLength(0); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect("maps an edge-runtime failure to LegacyPgDeltaDeclarativeApplyError", () => { - const dir = makeDeclarativeDir(); - const edge = fakeEdgeRuntime({ fail: "error running pg-delta script: boom" }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - expect((failError(exit) as { message: string }).message).toBe( - "error running pg-delta script: boom", - ); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }); - - it.effect("fails with a parse error WITHOUT the raw stdout when --debug is unset", () => { - const dir = makeDeclarativeDir(); - const edge = fakeEdgeRuntime({ stdout: "not json{" }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - const message = (failError(exit) as { message: string }).message; - expect(message).toContain("failed to parse pg-delta apply output"); - expect(message).not.toContain("stdout:"); - expect(message).not.toContain("not json{"); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }); - - it.effect("fails with a parse error INCLUDING the raw stdout when --debug is set", () => { - const dir = makeDeclarativeDir(); - const edge = fakeEdgeRuntime({ stdout: "not json{" }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - const message = (failError(exit) as { message: string }).message; - expect(message).toContain("failed to parse pg-delta apply output"); - expect(message).toContain("stdout: not json{"); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, true), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }); - - it.effect( - "fails with a parse error INCLUDING the raw stdout when SUPABASE_DEBUG is set only in the project .env", - () => { - // Go's `Config.Load` -> `loadNestedEnv` `os.Setenv`s the project `supabase/.env` into the - // process before `pgdelta.ApplyDeclarative` ever reads `viper.GetBool("DEBUG")` - // (review: PRRT_kwDOErm0O86XL_oz) — so a `SUPABASE_DEBUG` set only in `supabase/.env`, - // never in the shell or via `--debug`, still surfaces the raw stdout. Delete any shell - // `SUPABASE_DEBUG` first: shell *presence* (even `false`) would otherwise suppress the - // project value entirely, per `legacyViperEnvBoolWithProjectFallback`'s own semantics. - const previous = process.env["SUPABASE_DEBUG"]; - delete process.env["SUPABASE_DEBUG"]; - const dir = makeDeclarativeDir(); - const edge = fakeEdgeRuntime({ stdout: "not json{" }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta( - { ...CTX, projectEnv: { SUPABASE_DEBUG: "true" } }, - { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }, - ).pipe(Effect.exit); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - const message = (failError(exit) as { message: string }).message; - expect(message).toContain("failed to parse pg-delta apply output"); - expect(message).toContain("stdout: not json{"); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_DEBUG"]; - else process.env["SUPABASE_DEBUG"] = previous; - }), - ), - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect( - "fails with a normal status-failure summary (not a parse error) when stdout is a top-level JSON null", - () => { - // Go's `json.Unmarshal([]byte("null"), &result)` into the zero-valued (non-pointer) - // `ApplyResult` struct is a no-op that returns no error (verified empirically) — Go falls - // through to the normal `result.Status != "success"` branch and prints the usual - // failed-apply summary with every counter at its zero value, rather than treating `null` - // as a parse failure. `legacyApplyDeclarativePgDelta` must normalize `null` to `{}` before - // its own structural guard, matching that behavior (review: PRRT_kwDOErm0O86W8ZYo). - const dir = makeDeclarativeDir(); - const edge = fakeEdgeRuntime({ stdout: "null" }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - expect((failError(exit) as { message: string }).message).toBe( - "pg-delta declarative apply failed with status: ", - ); - expect((failError(exit) as { message: string }).message).not.toContain( - "failed to parse pg-delta apply output", - ); - expect(out.stderrText).toContain('pg-delta apply returned status "".'); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect( - "fails with LegacyPgDeltaDeclarativeApplyError (not an unhandled defect) when stdout is syntactically valid but non-object, non-null JSON", - () => { - // Unlike `null` (see the sibling test above), Go's `json.Unmarshal` genuinely rejects an - // array/string/number/bool payload for a struct destination with an UnmarshalTypeError — - // so a bare `JSON.parse(...) as LegacyPgDeltaApplyResult` cast would let `parsed.status` - // throw an unhandled TypeError instead of failing typed. - const dir = makeDeclarativeDir(); - const edge = fakeEdgeRuntime({ stdout: "42" }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - expect((failError(exit) as { message: string }).message).toContain( - "failed to parse pg-delta apply output", - ); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect("fails with LegacyPgDeltaDeclarativeApplyError when stdout is a JSON array", () => { - const dir = makeDeclarativeDir(); - const edge = fakeEdgeRuntime({ stdout: "[1,2,3]" }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - expect((failError(exit) as { message: string }).message).toContain( - "failed to parse pg-delta apply output", - ); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }); - - it.effect( - "fails with LegacyPgDeltaDeclarativeApplyError (not an unhandled defect) when a field typed as an array arrives as an object", - () => { - // A configured or future pg-delta emitting `{"status":"error","errors":{"length":1}}` must - // not reach `legacyFormatApplyFailure`'s `for (const issue of errors)`, which would throw an - // unhandled TypeError on a non-iterable object — Go's `json.Unmarshal` rejects this the same - // way, since `Errors` is declared `[]ApplyIssue` (`apps/cli-go/internal/pgdelta/apply.go:33`). - const dir = makeDeclarativeDir(); - const edge = fakeEdgeRuntime({ - stdout: JSON.stringify({ status: "error", errors: { length: 1 } }), - }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - expect((failError(exit) as { message: string }).message).toContain( - "failed to parse pg-delta apply output", - ); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect( - "fails with LegacyPgDeltaDeclarativeApplyError (not treated as a false success) when an errors array element is a number", - () => { - // A configured or future pg-delta emitting `{"status":"success","errors":[123]}` must not - // be accepted as a successful apply. Verified against Go's real `ApplyIssue.UnmarshalJSON` - // (`apps/cli-go/internal/pgdelta/apply.go:124-142`): a numeric element fails BOTH its - // string-arm and its object-arm unmarshal, which fails the WHOLE `ApplyResult` decode — - // Go never reaches a "success" status in this case, so the TS guard must reject it too. - const dir = makeDeclarativeDir(); - const edge = fakeEdgeRuntime({ - stdout: JSON.stringify({ status: "success", errors: [123] }), - }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - expect((failError(exit) as { message: string }).message).toContain( - "failed to parse pg-delta apply output", - ); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect( - "fails with LegacyPgDeltaDeclarativeApplyError (not treated as a false success) when a diagnostics array element is a bare string", - () => { - // Unlike `ApplyIssue`, Go's `ApplyDiagnosis.UnmarshalJSON` (`apply.go:79-116`) has no - // bare-string acceptance branch, so `{"diagnostics":["boom"]}` fails Go's whole decode too - // (verified: unmarshaling a JSON string into `ApplyDiagnosis`'s shadow struct errors). - const dir = makeDeclarativeDir(); - const edge = fakeEdgeRuntime({ - stdout: JSON.stringify({ status: "success", diagnostics: ["boom"] }), - }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - expect((failError(exit) as { message: string }).message).toContain( - "failed to parse pg-delta apply output", - ); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect( - "accepts a diagnostics element whose statementId is a mistyped, non-object/non-string value (Go degrades it silently)", - () => { - // Unlike a top-level array-element shape mismatch, Go's `ApplyDiagnosis.UnmarshalJSON` - // decodes `statementId` into a `json.RawMessage` first (accepts ANY valid JSON value), then - // tries `ApplyStatementLocation`, then a bare string, and silently leaves `StatementID` nil - // if BOTH fail — never propagating an error. A mistyped `statementId` must NOT fail the - // whole parse. - const dir = makeDeclarativeDir(); - const payload = { - status: "success", - diagnostics: [{ message: "note", statementId: 42 }], - }; - const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect( - "drops a diagnostics element's statementId when a nested field is mistyped, instead of rendering a bogus location (Go's nil fallback)", - () => { - // Unlike the mistyped-non-object/non-string `statementId` case above, this reproduces a - // mistyped FIELD INSIDE an otherwise object-shaped `statementId` - // (`{"filePath":123,...}`). Go's `(d *ApplyDiagnosis) UnmarshalJSON` (`apply.go:100-115`) - // tries the `ApplyStatementLocation` object shape first — the mistyped `filePath` fails - // that decode — then falls back to a bare string, which ALSO fails (it's an object, not a - // string) — so Go silently leaves `StatementID` nil rather than erroring the whole parse, - // verified empirically. Rendering the raw object anyway would show a bogus `(123#1)` - // location Go never emits. - const dir = makeDeclarativeDir(); - const payload = { - status: "success", - diagnostics: [{ message: "note", statementId: { filePath: 123, statementIndex: 1 } }], - }; - const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect( - "accepts a null scalar field on an errors/diagnostics element and formats it as absent (Go's encoding/json leaves the zero value)", - () => { - // `ApplyIssue`'s non-`Statement` fields (`Code`/`Message`/`IsDependencyError`/`Position`/ - // `Detail`/`Hint`) and `ApplyDiagnosis`'s (`Code`/`Message`/`SuggestedFix`) are all plain, - // non-pointer Go types decoded via the default `encoding/json` — verified empirically that - // a JSON `null` for a non-pointer struct field produces NO error and leaves the zero value, - // so `{"errors":[{"message":null}]}` is a valid, Go-accepted payload, not a parse failure. - // The formatter's existing `String(issue.message ?? "")` already renders a zero-value - // message as "unknown pg-delta issue" once the guard lets the `null` through. - const dir = makeDeclarativeDir(); - const payload = { - status: "error", - totalApplied: 0, - totalRounds: 1, - totalSkipped: 0, - errors: [{ message: null, code: null, isDependencyError: null, position: null }], - diagnostics: [{ message: null, code: null, suggestedFix: null }], - }; - const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - expect(out.stderrText).toContain("- unknown pg-delta issue"); - expect(out.stderrText).toContain("- unknown pg-delta diagnostic"); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, true), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect( - "accepts a null top-level counter and formats it as zero (Go's encoding/json leaves the zero value)", - () => { - // `ApplyResult` has no custom `UnmarshalJSON` of its own, so its plain, non-pointer `int` - // counters (`TotalStatements`/`TotalRounds`/`TotalApplied`/`TotalSkipped`) decode via the - // default `encoding/json` — verified empirically that a JSON `null` for a non-pointer `int` - // field produces NO error and leaves the zero value, so - // `{"status":"success","totalApplied":null}` is a valid, Go-accepted payload, not a parse - // failure — same "null means absent" rule already applied to nested issue/diagnostic - // scalar fields above. - const dir = makeDeclarativeDir(); - const payload = { status: "success", totalApplied: null, totalRounds: null }; - const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - expect(out.stderrText).toContain("Applied 0 statements in 0 round(s)."); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect( - "accepts an absent or null top-level status and formats it as the empty-string zero value (Go's encoding/json)", - () => { - // `ApplyResult.Status` has no custom `UnmarshalJSON` of its own, so it's a plain, - // non-pointer `string` field decoded via the default `encoding/json` — verified - // empirically that `{}` and `{"status":null}` both decode with `err == nil` and - // `Status == ""`, reaching the normal failed-apply summary (not a parse failure). - const dir = makeDeclarativeDir(); - const edge = fakeEdgeRuntime({ stdout: JSON.stringify({}) }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - expect((failError(exit) as { message: string }).message).toBe( - "pg-delta declarative apply failed with status: ", - ); - expect(out.stderrText).toContain('pg-delta apply returned status "".'); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect( - "accepts a null errors/stuckStatements/validationErrors/diagnostics array and treats it as empty (Go's encoding/json leaves a nil slice)", - () => { - // `ApplyResult`'s array fields have no custom `UnmarshalJSON` of their own, so Go's - // `encoding/json` accepts a JSON `null` for a `[]T` slice field with no error, leaving a - // nil (zero-length) slice — verified empirically: - // `json.Unmarshal([]byte(\`{"status":"error","errors":null}\`), &r)` returns `err == nil` - // with `len(r.Errors) == 0`. A payload reporting all four as `null` must format as if none - // were reported at all, not fail the parse. - const dir = makeDeclarativeDir(); - const payload = { - status: "error", - totalApplied: 0, - totalRounds: 1, - totalSkipped: 0, - errors: null, - stuckStatements: null, - validationErrors: null, - diagnostics: null, - }; - const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - expect(out.stderrText).toContain("No per-statement diagnostics were reported by pg-delta."); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect( - "fails with LegacyPgDeltaDeclarativeApplyError (not an unhandled defect) when a field typed as a number arrives as a string", - () => { - // Same reasoning as the array-typed-field test above, for `ApplyResult`'s numeric fields - // (`TotalApplied int`, etc.) — a malformed counter must fail the parse, not be silently - // treated as a genuine successful-apply summary. - const dir = makeDeclarativeDir(); - const edge = fakeEdgeRuntime({ - stdout: JSON.stringify({ status: "success", totalApplied: "5" }), - }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - expect((failError(exit) as { message: string }).message).toContain( - "failed to parse pg-delta apply output", - ); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect( - "fails with LegacyPgDeltaDeclarativeApplyError (not an unhandled defect) when a field typed as an int arrives as a fractional number", - () => { - // Go's `TotalApplied int` (and its `int`-typed siblings) reject any JSON number literal - // with a decimal point via `strconv.ParseInt` on the raw literal text — verified - // empirically that `json.Unmarshal` on `{"totalApplied":1.5}` errors identically to a - // string-typed field mismatch, so `1.5` must fail the parse here too, not be treated as a - // truncated/rounded successful-apply count. - const dir = makeDeclarativeDir(); - const edge = fakeEdgeRuntime({ - stdout: JSON.stringify({ status: "success", totalApplied: 1.5 }), - }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - expect((failError(exit) as { message: string }).message).toContain( - "failed to parse pg-delta apply output", - ); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect( - "fails with LegacyPgDeltaDeclarativeApplyError (not an unhandled defect) when an int field arrives as a value outside Go's int64 range", - () => { - // `Number.isInteger(1e20)` is `true`, but Go's `json.Unmarshal` of that same literal - // into `int` fails with "value out of range" (`strconv.ParseInt`'s int64 width) — so a - // mistyped/oversized numeric field must be rejected here too, not accepted as a (false) - // successful-apply count. - const dir = makeDeclarativeDir(); - const edge = fakeEdgeRuntime({ - stdout: JSON.stringify({ status: "success", totalApplied: 1e20 }), - }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - expect((failError(exit) as { message: string }).message).toContain( - "failed to parse pg-delta apply output", - ); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect( - "on a non-success status, prints the formatted failure to stderr but not the raw payload when --debug is unset", - () => { - const dir = makeDeclarativeDir(); - const payload = { - status: "error", - totalApplied: 0, - totalRounds: 1, - totalSkipped: 0, - errors: ["boom"], - }; - const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const exit = yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); - expect((failError(exit) as { message: string }).message).toBe( - "pg-delta declarative apply failed with status: error", - ); - expect(out.stderrText).toContain('pg-delta apply returned status "error".'); - expect(out.stderrText).toContain("- boom"); - expect(out.stderrText).not.toContain("pg-delta apply result:"); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect( - "on a non-success status with --debug set, additionally dumps the pretty-printed raw payload", - () => { - const dir = makeDeclarativeDir(); - const payload = { - status: "error", - totalApplied: 0, - totalRounds: 1, - totalSkipped: 0, - errors: ["boom"], - }; - const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }).pipe(Effect.exit); - expect(out.stderrText).toContain("pg-delta apply result:"); - expect(out.stderrText).toContain(JSON.stringify(payload, null, 2)); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, true), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); - - it.effect( - "on success, prints the applied-statements summary and forwards SCHEMA_PATH/TARGET/binds", - () => { - const dir = makeDeclarativeDir(); - const payload = { - status: "success", - totalStatements: 3, - totalApplied: 3, - totalRounds: 2, - totalSkipped: 0, - }; - const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - yield* legacyApplyDeclarativePgDelta(CTX, { - fs, - declarativeDirAbs: dir, - declarativeDirRel: "supabase/database", - target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - }); - expect(out.stderrText).toContain("Applying declarative schemas via pg-delta..."); - expect(out.stderrText).toContain("Applied 3 statements in 2 round(s)."); - const opts = edge.calls[0]!; - expect(opts.env["SCHEMA_PATH"]).toBe("/declarative"); - expect(opts.env["TARGET"]).toBe( - "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", - ); - expect(opts.binds).toEqual([ - "supabase_edge_runtime_ref:/root/.cache/deno:rw", - `${dir}:/declarative:ro`, - ]); - expect(opts.errPrefix).toBe("error running pg-delta script"); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - BunServices.layer, - edge.layer, - out.layer, - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(CliArgs, { args: [] }), - Layer.succeed(CliArgs, { args: [] }), - ), - ), - ); - }, - ); -}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.ts deleted file mode 100644 index da63c39645..0000000000 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.ts +++ /dev/null @@ -1,1002 +0,0 @@ -/** - * Port of Go's `pgdelta.ApplyDeclarative` (`apps/cli-go/internal/pgdelta/apply.go:303-354`) — - * CLI-1956's declarative-apply runner: applies `supabase/schemas` (or the configured - * declarative dir) to the shadow's `contrib_regression` override database via pg-delta's - * declarative apply engine, run inside the edge-runtime container. - * - * This is genuinely NEW work, not a seam removal: the Deno script template itself - * (`legacyPgDeltaDeclarativeApplyScript`) already existed (ported for a different, now-dead - * seam), but nothing in TS ever invoked it — every declarative apply ran through the bundled - * Go binary until now. - */ - -import { Data, Effect, type FileSystem } from "effect"; - -import { legacyResolveDebugWithProjectEnv } from "../../../../shared/legacy/global-flags.ts"; -import { Output } from "../../../../shared/output/output.service.ts"; -import { - actionability, - type CliErrorActionabilityDeclaration, - ErrorActionabilityId, -} from "../../../../shared/telemetry/error-actionability.ts"; -import { LegacyEdgeRuntimeScript } from "../../../shared/legacy-edge-runtime-script.service.ts"; -import { legacyGoQuote } from "../../../shared/legacy-go-quote.ts"; -import { legacyTrimGoSpace } from "./legacy-go-string.ts"; -import { - legacyInterpolatePgDeltaScript, - legacyPgDeltaDeclarativeApplyScript, -} from "./legacy-pgdelta.deno-templates.ts"; -import { - legacyEdgeRuntimeId, - legacyPgDeltaNpmRegistryOption, - type LegacyPgDeltaContext, -} from "../../../shared/legacy-pgdelta.ts"; - -const errMessage = (e: unknown): string => - typeof e === "object" && e !== null && "message" in e && typeof e.message === "string" - ? e.message - : String(e); - -/** - * `pgdelta.ApplyDeclarative` failed — Go's own error messages at each step (see call sites - * below). `reason` narrows the actionability classification below beyond the "user's own - * SQL/schema" (`dbFinding`) default that a failed-status apply (Go's own `pg-delta declarative - * apply failed with status: %s`) and a plain reset/apply fallback (`sync.handler.ts`, - * `declarative.smart-target.ts`) both keep: `missing_schema_dir`/`output_parse` (this file's - * own directory-not-found/malformed-subprocess-output branches) and `connect`/`daemon`/`pull`/ - * `inspect` (a local-Postgres connect failure, or a docker-boundary failure threaded from - * `LegacyEdgeRuntimeScriptError.docker` — see that class's own doc comment for the same three - * values) are genuinely NOT the user's schema/SQL failing, and must not be misclassified as - * such. - */ -export class LegacyPgDeltaDeclarativeApplyError extends Data.TaggedError( - "LegacyPgDeltaDeclarativeApplyError", -)<{ - readonly message: string; - readonly reason?: - | "missing_schema_dir" - | "output_parse" - | "connect" - | "daemon" - | "pull" - | "inspect"; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - switch (this.reason) { - case "missing_schema_dir": - return { ...actionability.invalidConfig, fingerprint_suffix: "invalid_config" }; - case "output_parse": - return { ...actionability.impossibleState, fingerprint_suffix: "invalid_content" }; - case "connect": - return { ...actionability.dbConnection, fingerprint_suffix: "connect" }; - case "daemon": - return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; - case "pull": - return { ...actionability.externalNetwork, fingerprint_suffix: "registry_pull" }; - case "inspect": - return { ...actionability.invalidConfig, fingerprint_suffix: "image_inspect" }; - default: - return actionability.dbFinding; - } - } -} - -/** Go's `containerSchemaPath` (`apply.go:313`). */ -const LEGACY_PG_DELTA_APPLY_CONTAINER_SCHEMA_PATH = "/declarative"; - -/** One statement/error entry — Go's `ApplyIssue`, which may arrive as a bare string or an object. */ -export interface LegacyPgDeltaApplyIssue { - readonly statement?: { - // Optional (not required): `legacyIsValidApplyIssueElement` only checks the TYPE of each - // present field (matching Go's per-field `json.Unmarshal` type check), not that every - // field is present — so a partially-populated `statement` object (e.g. a future pg-delta - // release that only reports `id`) must still render, not throw — see - // `legacyFormatApplyIssue`'s defensive `?? ""` handling below. Go's own `(i - // *ApplyIssue) UnmarshalJSON` is deliberately just as permissive about ABSENT fields, - // while still rejecting a MISTYPED one for the whole payload — see - // `legacyIsValidApplyIssueElement`'s own doc comment. - // - // `| null` on each of `id`/`sql`/`statementClass` (not just `?`): these are plain, - // non-pointer `string` fields on Go's `ApplyStatement`, which has no custom - // `UnmarshalJSON` of its own — so they decode via the default `encoding/json`, which - // (verified empirically) accepts a JSON `null` for a non-pointer field with NO error and - // leaves the zero value (`""`), the same "null means absent" rule as every other scalar - // on this interface — see {@link LegacyPgDeltaApplyIssue.code}'s doc comment. - readonly id?: string | null; - readonly sql?: string | null; - readonly statementClass?: string | null; - // `| null` (not just `?`): Go's `Statement *ApplyStatement` is a pointer, so a JSON - // `"statement":null` entry (e.g. `{"statement":null,"message":"failed"}`) unmarshals to a - // nil pointer — `formatApplyIssue`'s `issue.Statement == nil` (`apply.go:202`) treats that - // identically to a missing field. `legacyFormatApplyIssue`'s guard below must check for - // `null` as well as `undefined`, or a `JSON.parse`'d `null` reaches `issue.statement.*` and - // throws a `TypeError` instead of rendering the message. - } | null; - // `| null` on every scalar below (not just `?`): `ApplyIssue`'s non-`Statement` fields - // (`Code`/`Message`/`IsDependencyError`/`Position`/`Detail`/`Hint`) are all plain, - // non-pointer Go types (`string`/`bool`/`int`) decoded via the default `encoding/json` - // inside `(i *ApplyIssue) UnmarshalJSON`'s `json.Unmarshal(trimmed, &parsed)` call - // (`apply.go:135-140`) — verified empirically that unmarshaling a JSON `null` into a - // non-pointer struct field produces NO error and leaves the zero value untouched (Go's - // documented "null means absent" rule applies to any Go type, not just pointers/maps/ - // slices/interfaces). So `{"message":null}` is a valid, Go-accepted `ApplyIssue` element — - // rejecting it here would turn an otherwise-parseable pg-delta payload into a spurious - // "failed to parse pg-delta apply output" instead of rendering `unknown pg-delta issue` - // the way `legacyFormatApplyIssueMessage`'s existing `String(issue.message ?? "")` already - // does once this type (and `legacyIsValidApplyIssueElement`) let a null through. - readonly code?: string | null; - readonly message?: string | null; - readonly isDependencyError?: boolean | null; - readonly position?: number | null; - readonly detail?: string | null; - readonly hint?: string | null; -} - -/** - * Go's `ApplyStatementLocation` (pg-topo's `StatementId` shape). `ApplyStatementLocation` - * has no custom `UnmarshalJSON` of its own, so `filePath`/`statementIndex`/`sourceOffset` - * are plain, non-pointer Go types decoded via the default `encoding/json` — same "null - * means absent" rule as every other scalar in this file (verified empirically, see {@link - * LegacyPgDeltaApplyIssue.code}'s doc comment), hence `| null` on all three. `sourceOffset` - * is never read by {@link legacyFormatStatementLocation} (Go's own `formatStatementLocation` - * doesn't display it either), but it still must be validated in - * {@link legacyNormalizeApplyStatementId}: Go's struct-level `json.Unmarshal` fails the - * WHOLE object the moment any declared field — including this unused one — has the wrong - * type, not just the fields the formatter happens to read. - */ -export interface LegacyPgDeltaApplyStatementLocation { - readonly filePath?: string | null; - readonly statementIndex?: number | null; - readonly sourceOffset?: number | null; -} - -/** Go's `ApplyDiagnosis` — a pg-topo static-analysis diagnostic. */ -export interface LegacyPgDeltaApplyDiagnosis { - // `| null` on `code`/`message`/`suggestedFix` (not just `?`): `(d *ApplyDiagnosis) - // UnmarshalJSON`'s shadow `raw` struct (`apply.go:88-93`) declares these as plain, - // non-pointer `string` fields with no custom unmarshaler of their own, so — same - // empirically-verified "null means absent" `encoding/json` rule as - // {@link LegacyPgDeltaApplyIssue.code} — a JSON `null` for any of them decodes with no - // error and leaves `""`, not a rejected payload. - readonly code?: string | null; - readonly message?: string | null; - // `| null` (not just `?`): Go's `(d *ApplyDiagnosis) UnmarshalJSON` (`apply.go:79-108`) - // explicitly maps a JSON `"statementId":null` to a nil `*ApplyStatementLocation`, and - // `formatStatementLocation` (`apply.go:263-274`) returns `""` for a nil pointer — so the TS - // path must accept `null` here as absent too, or a `JSON.parse`'d `null` reaches - // `legacyFormatStatementLocation`'s `resolved.filePath` and throws a `TypeError` instead of - // rendering the rest of the diagnostic. - readonly statementId?: LegacyPgDeltaApplyStatementLocation | string | null; - readonly suggestedFix?: string | null; -} - -/** - * The JSON payload `pgdelta_declarative_apply.ts` prints on stdout. Go's `ApplyResult`. - * - * `| null` on each `total*` counter (not just `?`): `ApplyResult` has no custom - * `UnmarshalJSON` of its own, so these plain, non-pointer `int` fields decode via the - * default `encoding/json`, which — verified empirically, same rule as {@link - * LegacyPgDeltaApplyIssue.code} — accepts a JSON `null` for a non-pointer `int` field with - * NO error and leaves the zero value. So `{"status":"success","totalApplied":null}` is a - * valid, Go-accepted `ApplyResult`, not a parse failure. - * - * `| null` on each array field too (`errors`/`stuckStatements`/`validationErrors`/ - * `diagnostics`): these are plain, non-pointer Go `[]T` slice fields with no custom - * unmarshaler on `ApplyResult` itself, and `encoding/json` accepts a JSON `null` for a - * slice field with NO error, leaving a nil (zero-length) slice — verified empirically: - * `json.Unmarshal([]byte(\`{"status":"error","errors":null}\`), &r)` returns `err == nil` - * with `r.Errors == nil` (`len(r.Errors) == 0`). `formatApplyFailure`'s `len(result.Errors) - * > 0` guards treat a nil slice identically to an empty one, so `{"status":"error", - * "errors":null}` must be accepted here too, not rejected as a parse failure. - * - * `status?: string | null` (not required non-null `string`): like every other field here, - * `Status` has no custom unmarshaler on `ApplyResult` itself, so an absent key or a JSON - * `null` decodes with NO error and leaves Go's zero value `""` — verified empirically: - * `json.Unmarshal([]byte(\`{}\`), &r)` and the `{"status":null}` variant both return - * `err == nil` with `r.Status == ""`. So `{}`/`{"status":null}` must reach the normal - * failed-apply summary (status rendered as `""`), not a rejected parse failure. - */ -export interface LegacyPgDeltaApplyResult { - readonly status?: string | null; - readonly totalStatements?: number | null; - readonly totalRounds?: number | null; - readonly totalApplied?: number | null; - readonly totalSkipped?: number | null; - readonly errors?: ReadonlyArray | null; - readonly stuckStatements?: ReadonlyArray | null; - readonly validationErrors?: ReadonlyArray | null; - readonly diagnostics?: ReadonlyArray | null; -} - -/** - * Go's `int`-typed fields (`TotalStatements`/`TotalRounds`/`TotalApplied`/`TotalSkipped` on - * `ApplyResult`, `Position` on `ApplyIssue`) reject any JSON number literal containing a decimal - * point or exponent — Go's `json.Unmarshal` parses the literal text via `strconv.ParseInt` - * rather than decoding a `float64` and truncating it, so even a "whole" float like `1.0` fails - * identically to `1.5` (verified empirically: `json.Unmarshal([]byte(\`{"totalApplied":1.0}\`), - * &r)` and the `1.5` variant both return `cannot unmarshal number ... into ... type int`). A - * `JSON.parse`'d `1.0` is already indistinguishable from the integer `1` by the time it reaches - * this guard — `JSON.parse` itself collapses that distinction, so that exact literal-text - * sub-case can't be reproduced post-parse — but `Number.isInteger` still correctly rejects any - * genuinely fractional value like `1.5`, which is the reachable and observable part of this - * parity gap. - * - * The `[-2^63, 2^63)` bound mirrors Go's `int64` range (`strconv.ParseInt`'s target width on - * every build this CLI ships for): `Number.isInteger(1e20)` is `true`, but Go's `json.Unmarshal` - * of that same literal into `int` fails with "value out of range" — so a mistyped/oversized - * numeric field must be rejected here too, not accepted as a (false) match. Residual gap, same - * class as the `1.0`/exponent one above: the exact boundary literal `9223372036854775807` - * (`2^63-1`, the largest valid `int64`) round-trips through `JSON.parse`'s double-precision - * `float64` as `9223372036854775808` (`2^63`) — indistinguishable from the boundary this check - * rejects — so that one exact literal is spuriously rejected where Go would accept it. - */ -function legacyIsGoIntNumber(value: unknown): value is number { - return ( - typeof value === "number" && Number.isInteger(value) && value >= -(2 ** 63) && value < 2 ** 63 - ); -} - -/** - * Go's `(i *ApplyIssue) UnmarshalJSON` (`apply.go:124-142`) accepts `null`, a bare string, or - * an object whose PRESENT fields each match `ApplyIssue`'s declared JSON types — anything else - * (a number, boolean, array, or an object with a mistyped field) fails Go's `json.Unmarshal` - * for the WHOLE `ApplyResult`, not just that element. Verified empirically against Go's real - * struct definitions: `{"errors":[123]}` returns `cannot unmarshal number into Go struct field - * ApplyResult.errors of type main.alias`, and `{"errors":[{"message":123}]}` returns `cannot - * unmarshal number into Go struct field ApplyResult.errors.message of type string` — both abort - * the ENTIRE parse rather than degrading that one element, so a payload like - * `{"status":"success","errors":[123]}` must be rejected here too, not accepted as a (false) - * success. Nested `statement` is checked the same way, one level deep — Go's `ApplyStatement` - * has no custom `UnmarshalJSON`, so a mistyped `id`/`sql`/`statementClass` fails identically. - * - * A JSON `null` for any INDIVIDUAL scalar field, though — top-level (`code`/`message`/ - * `isDependencyError`/`position`/`detail`/`hint`) or nested under `statement` - * (`id`/`sql`/`statementClass`) — is NOT a mistyped field: every one of these is a plain, - * non-pointer Go type with no custom unmarshaler, and `encoding/json` accepts `null` for those - * with no error, leaving the zero value (verified empirically — see - * {@link LegacyPgDeltaApplyIssue.code}'s doc comment). So `null` is tolerated alongside each - * field's declared type below, matching Go exactly instead of rejecting an otherwise - * Go-compatible payload like `{"message":null}`. - */ -function legacyIsValidApplyIssueElement(value: unknown): boolean { - if (value === null || typeof value === "string") return true; - if (typeof value !== "object" || Array.isArray(value)) return false; - if ("statement" in value) { - const statement = value.statement; - if (statement !== null && statement !== undefined) { - if (typeof statement !== "object" || Array.isArray(statement)) return false; - if ("id" in statement && statement.id !== null && typeof statement.id !== "string") { - return false; - } - if ("sql" in statement && statement.sql !== null && typeof statement.sql !== "string") { - return false; - } - if ( - "statementClass" in statement && - statement.statementClass !== null && - typeof statement.statementClass !== "string" - ) { - return false; - } - } - } - if ("code" in value && value.code !== null && typeof value.code !== "string") return false; - if ("message" in value && value.message !== null && typeof value.message !== "string") { - return false; - } - if ( - "isDependencyError" in value && - value.isDependencyError !== null && - typeof value.isDependencyError !== "boolean" - ) { - return false; - } - if ("position" in value && value.position !== null && !legacyIsGoIntNumber(value.position)) { - return false; - } - if ("detail" in value && value.detail !== null && typeof value.detail !== "string") return false; - if ("hint" in value && value.hint !== null && typeof value.hint !== "string") return false; - return true; -} - -/** - * Go's `(d *ApplyDiagnosis) UnmarshalJSON` (`apply.go:79-116`) — unlike `ApplyIssue`, there is - * NO bare-string acceptance branch, so only `null` or an object is valid; a bare - * string/number/boolean/array element fails the whole `ApplyResult` unmarshal. Verified - * empirically: `{"diagnostics":["boom"]}` returns `cannot unmarshal string into Go struct field - * ApplyResult.diagnostics of type struct {...}`. `statementId` is deliberately NOT type-checked - * here: Go decodes it into a `json.RawMessage` first (accepts any valid JSON value), then tries - * `ApplyStatementLocation`, then a bare string, and silently leaves `StatementID` nil if BOTH - * fail — it never propagates an error for a mistyped `statementId` (verified empirically: - * `{"statementId":42}` and `{"statementId":{"filePath":123}}` both unmarshal with `err: `), - * so `legacyNormalizeApplyDiagnosis`/`legacyFormatStatementLocation`'s existing defensive - * handling is the correct (and only) place that degrades gracefully. - * - * Same "null tolerated on a scalar field" rule as {@link legacyIsValidApplyIssueElement} - * applies to `code`/`message`/`suggestedFix` here too: `UnmarshalJSON`'s shadow `raw` struct - * (`apply.go:88-93`) decodes them via the default `encoding/json`, which accepts a JSON - * `null` for a plain `string` field with no error (verified empirically). - */ -function legacyIsValidApplyDiagnosisElement(value: unknown): boolean { - if (value === null) return true; - if (typeof value !== "object" || Array.isArray(value)) return false; - if ("code" in value && value.code !== null && typeof value.code !== "string") return false; - if ("message" in value && value.message !== null && typeof value.message !== "string") { - return false; - } - if ( - "suggestedFix" in value && - value.suggestedFix !== null && - typeof value.suggestedFix !== "string" - ) { - return false; - } - return true; -} - -/** - * Structural guard for Go's `ApplyResult` JSON shape, applied to an untrusted - * `JSON.parse` of the pg-delta subprocess's stdout. A syntactically valid but non-object - * payload — an array, a bare string/number/bool (e.g. a future pg-delta release that - * changes its output shape) — must fail typed as {@link LegacyPgDeltaDeclarativeApplyError}, not - * crash `parsed.status` with an unhandled `TypeError`. A top-level `null` is NOT one of - * these: `json.Unmarshal([]byte("null"), &result)` into Go's zero-valued (non-pointer) - * `ApplyResult` struct is a no-op that returns no error (verified empirically), unlike the - * array/string/number/bool cases, which genuinely fail with an `UnmarshalTypeError` — so the - * caller normalizes a top-level `null` to `{}` before this guard ever sees it (review: - * PRRT_kwDOErm0O86W8ZYo), and this function only needs to reject the cases Go actually - * rejects. - * - * Every field `ApplyResult` itself declares a type for is checked when present — Go's - * `json.Unmarshal` rejects the whole payload with an `UnmarshalTypeError` the moment any of - * these doesn't match its struct field's declared type (`Errors []ApplyIssue`, `TotalApplied - * int`, etc., `apps/cli-go/internal/pgdelta/apply.go:27-40`), so e.g. an `errors` field that - * arrives as an object (`{"length":1}`) instead of an array must fail here too, not reach - * `legacyFormatApplyFailure`'s `for (const issue of errors)` and throw an unhandled - * `TypeError` defect. Each ARRAY field's elements are also validated ({@link - * legacyIsValidApplyIssueElement}/{@link legacyIsValidApplyDiagnosisElement}) since Go's own - * per-element `UnmarshalJSON` implementations reject a malformed element by failing the WHOLE - * `ApplyResult` decode, not by skipping just that element — see those functions' own doc - * comments for the empirical verification. This is also the AGENTS.md-mandated way to narrow - * `unknown` without an `as` cast. - * - * Each array field also tolerates a JSON `null` (not just an absent key): `ApplyResult`'s - * `[]ApplyIssue`/`[]ApplyDiagnosis` fields have no custom unmarshaler of their own, and - * Go's `encoding/json` accepts `null` for a slice field with no error, leaving a nil - * (zero-length) slice — verified empirically, see {@link LegacyPgDeltaApplyResult}'s own - * doc comment. So `{"status":"error","errors":null}` is a valid, Go-accepted payload, not - * a rejected one. - * - * `status` is checked the same "null/absent tolerated" way as every other field, NOT - * required to be present and non-null: an absent key or `"status":null` is Go's zero - * value `""`, not a parse failure — see {@link LegacyPgDeltaApplyResult}'s own doc comment - * for the empirical verification. - */ -function legacyIsPgDeltaApplyResult(value: unknown): value is LegacyPgDeltaApplyResult { - if ( - typeof value !== "object" || - value === null || - Array.isArray(value) || - ("status" in value && value.status !== null && typeof value.status !== "string") - ) { - return false; - } - if ( - "totalStatements" in value && - value.totalStatements !== null && - !legacyIsGoIntNumber(value.totalStatements) - ) { - return false; - } - if ( - "totalRounds" in value && - value.totalRounds !== null && - !legacyIsGoIntNumber(value.totalRounds) - ) { - return false; - } - if ( - "totalApplied" in value && - value.totalApplied !== null && - !legacyIsGoIntNumber(value.totalApplied) - ) { - return false; - } - if ( - "totalSkipped" in value && - value.totalSkipped !== null && - !legacyIsGoIntNumber(value.totalSkipped) - ) { - return false; - } - if ("errors" in value && value.errors !== null) { - if (!Array.isArray(value.errors) || !value.errors.every(legacyIsValidApplyIssueElement)) { - return false; - } - } - if ("stuckStatements" in value && value.stuckStatements !== null) { - if ( - !Array.isArray(value.stuckStatements) || - !value.stuckStatements.every(legacyIsValidApplyIssueElement) - ) { - return false; - } - } - if ("validationErrors" in value && value.validationErrors !== null) { - if ( - !Array.isArray(value.validationErrors) || - !value.validationErrors.every(legacyIsValidApplyIssueElement) - ) { - return false; - } - } - if ("diagnostics" in value && value.diagnostics !== null) { - if ( - !Array.isArray(value.diagnostics) || - !value.diagnostics.every(legacyIsValidApplyDiagnosisElement) - ) { - return false; - } - } - return true; -} - -/** Go's `(i *ApplyIssue) UnmarshalJSON` string/object dual shape, applied post-`JSON.parse`. */ -function legacyNormalizeApplyIssue( - raw: LegacyPgDeltaApplyIssue | string | null | undefined, -): LegacyPgDeltaApplyIssue { - if (raw === null || raw === undefined) return {}; - if (typeof raw === "string") return { message: raw }; - return raw; -} - -/** - * Go's `(d *ApplyDiagnosis) UnmarshalJSON` three-way `statementId` fallback - * (`apply.go:100-115`): decode into `ApplyStatementLocation` first — an object whose - * PRESENT `filePath`/`statementIndex` fields each match the declared type (`null` - * tolerated per field, same rule as {@link legacyIsValidApplyIssueElement}) — and if - * that fails (a non-object, or an object with a mistyped field), fall back to a bare - * string; if BOTH fail, Go silently leaves `StatementID` nil rather than erroring the - * whole `ApplyResult` parse. Verified empirically: `{"statementId":{"filePath":123, - * "statementIndex":1}}` decodes with `StatementID == nil` in Go — the object-shape - * unmarshal fails on the mistyped `filePath`, and the string fallback also fails since - * the value is an object, not a string. `legacyIsValidApplyDiagnosisElement` deliberately - * does NOT check `statementId`'s shape (see its own doc comment — Go defers this into a - * `json.RawMessage` that never fails the outer parse), so this is the only place that can - * drop a malformed location instead of `legacyFormatStatementLocation`'s `String(...)` - * coercion rendering a bogus location (e.g. `123#1`) Go would never have shown. - * - * `sourceOffset` is validated here too, even though {@link legacyFormatStatementLocation} - * never reads it: Go's struct-level unmarshal (`apply.go:105`) fails on ANY declared field - * with the wrong type, not just the ones a later formatter happens to display. Verified - * empirically: `json.Unmarshal([]byte(\`{"filePath":"x.sql","sourceOffset":"bad"}\`), &loc)` - * returns a non-nil `UnmarshalTypeError` even though `filePath` itself is well-typed, so - * the object-shape decode fails, the string fallback also fails (the value is an object), - * and Go leaves `StatementID` nil — dropping the location entirely rather than keeping a - * `{filePath:"x.sql"}` that misattributes the diagnostic to the wrong file. - */ -function legacyNormalizeApplyStatementId( - raw: LegacyPgDeltaApplyStatementLocation | string | null | undefined, -): LegacyPgDeltaApplyStatementLocation | undefined { - if (raw === null || raw === undefined) return undefined; - if (typeof raw === "string") return { filePath: raw }; - if (typeof raw !== "object" || Array.isArray(raw)) return undefined; - const filePathOk = - !("filePath" in raw) || raw.filePath === null || typeof raw.filePath === "string"; - const indexOk = - !("statementIndex" in raw) || - raw.statementIndex === null || - legacyIsGoIntNumber(raw.statementIndex); - const sourceOffsetOk = - !("sourceOffset" in raw) || raw.sourceOffset === null || legacyIsGoIntNumber(raw.sourceOffset); - if (filePathOk && indexOk && sourceOffsetOk) return raw; - return undefined; -} - -/** Go's `(d *ApplyDiagnosis) UnmarshalJSON` defensive `statementId` handling. */ -function legacyNormalizeApplyDiagnosis( - raw: LegacyPgDeltaApplyDiagnosis | null | undefined, -): LegacyPgDeltaApplyDiagnosis { - if (raw === null || raw === undefined) return {}; - return { ...raw, statementId: legacyNormalizeApplyStatementId(raw.statementId) }; -} - -/** - * Go's `formatStatementLocation` (`apply.go:262-275`). `String(... ?? "")` rather than a bare - * `?? ""` before `.trim()`: `filePath` is typed as `string | undefined`, but this whole module - * types an untrusted `JSON.parse` of subprocess output, so a malformed payload can hand this a - * non-string value (e.g. a number) at runtime — `?? ""` alone only substitutes `null`/ - * `undefined`, so a non-string, non-nullish value would still reach `.trim()` and throw. The - * `resolved === null` check (not just `undefined`) is the same shape: Go's `StatementID - * *ApplyStatementLocation` is a pointer, so `"statementId":null` unmarshals to `nil` and - * `formatStatementLocation`'s own `loc == nil` (`apply.go:264`) treats it as absent — checking - * only `undefined` here would fall through to `resolved.filePath` on a `null` and throw a - * `TypeError` instead of rendering the rest of the diagnostic. - */ -function legacyFormatStatementLocation( - loc: LegacyPgDeltaApplyStatementLocation | string | null | undefined, -): string { - const resolved = typeof loc === "string" ? { filePath: loc } : loc; - if (resolved === null || resolved === undefined) return ""; - const path = legacyTrimGoSpace(String(resolved.filePath ?? "")); - if (path.length === 0) return ""; - if ((resolved.statementIndex ?? 0) > 0) return `${path}#${resolved.statementIndex}`; - return path; -} - -/** - * Go's `formatStatementSQL` (`apply.go:277-283`): collapse whitespace, then truncate at 120 - * UTF-8 bytes — not JS UTF-16 code units. Go's `len(normalized)` and `normalized[:maxLen-3]` - * both count/slice raw bytes, so a statement with multibyte (e.g. non-ASCII identifier) - * characters can be far longer in bytes than in UTF-16 units — a `.length`/`.slice()` guard - * would under-truncate (or not truncate at all) relative to Go's 120-byte limit, changing the - * legacy stderr contract for an already-failed apply. - * - * `\p{White_Space}+`, not `\s+`: `sql` is a user-authored SQL statement pulled verbatim from - * `supabase/declarative`, so — unlike this file's JSON envelope, whose key/shape is controlled - * by the embedded producer script — it can genuinely contain any Unicode code point a user's - * editor wrote, including NEL (code point 0x85) or a BOM (code point 0xFEFF) pasted into a - * comment or string literal. Go's `strings.Fields`/`unicode.IsSpace` and ECMAScript's `\s` - * disagree on both: verified empirically — Go's `unicode.IsSpace(rune(0x85))` (NEL) is `true` - * (`strings.Fields` collapses it, splitting `"a"+NEL+"b"` into two fields) while - * `unicode.IsSpace(rune(0xFEFF))` (BOM) is `false` (`strings.Fields` preserves it inside one - * field); ECMAScript's `\s` is the exact opposite (`/\s/u.test(String.fromCodePoint(0x85))` is - * `false`, `/\s/u.test(String.fromCodePoint(0xfeff))` is `true`). `\p{White_Space}` matches the - * Unicode `White_Space` property Go's `unicode.IsSpace` is itself built from (confirmed - * empirically against the same two code points, plus NBSP `0xA0` and ideographic space - * `0x3000`), so it reproduces Go's classification instead of ECMAScript's — both the rendered - * SQL text and, for a statement long enough to need it, the 120-byte truncation boundary now - * line up with Go's. - * - * Returns a `Buffer`, not a `string`: Go's `[:maxLen-3]` is a raw byte slice with no regard - * for codepoint boundaries, so a multibyte (e.g. non-ASCII identifier) character straddling - * byte 117 is cut mid-sequence, leaving an intentionally INVALID trailing UTF-8 fragment — - * exactly what Go writes to stderr, unvalidated. `Buffer#toString("utf-8")` on that same - * fragment does NOT reproduce it: Node's UTF-8 decoder substitutes U+FFFD for the incomplete - * sequence, and re-encoding that string back to bytes for output yields a DIFFERENT (and - * differently-sized) byte sequence than Go's raw slice — verified empirically: slicing Go's - * own `formatStatementSQL` at a non-boundary-aligned cut produces a 120-byte, deliberately - * invalid-UTF-8 result (`utf8.ValidString` reports `false`), while - * `Buffer.from(sql,"utf-8").subarray(...).toString("utf-8")` on that exact byte range - * decodes+re-encodes to a 121-byte result containing U+FFFD instead. Keeping this a `Buffer` - * all the way to `output.rawBytes` (see {@link legacyFormatApplyFailure}) avoids that - * lossy string round-trip and reproduces Go's bytes exactly, valid or not. - */ -function legacyFormatStatementSql(sql: string): Buffer { - const normalized = sql - .split(/\p{White_Space}+/u) - .filter((part) => part.length > 0) - .join(" "); - const maxLen = 120; - const normalizedBytes = Buffer.from(normalized, "utf-8"); - if (normalizedBytes.byteLength <= maxLen) return normalizedBytes; - return Buffer.concat([normalizedBytes.subarray(0, maxLen - 3), Buffer.from("...", "utf-8")]); -} - -/** - * Joins Buffer "lines" with `\n` — a Buffer-safe equivalent of `Array#join("\n")`, used so - * {@link legacyFormatApplyIssue}/{@link legacyFormatApplyFailure} can embed - * {@link legacyFormatStatementSql}'s raw (possibly invalid-UTF-8) bytes without ever - * decoding them back into a JS string. - */ -function legacyJoinLines(lines: ReadonlyArray): Buffer { - const newline = Buffer.from("\n", "utf-8"); - const parts: Array = []; - lines.forEach((line, index) => { - if (index > 0) parts.push(newline); - parts.push(line); - }); - return Buffer.concat(parts); -} - -/** - * Go's `json.Indent` (`encoding/json/indent.go`): re-flows compact/pretty JSON by inserting - * whitespace between tokens ONLY — every token (string, number, `true`/`false`/`null`) is - * copied byte-for-byte from `src`, never decoded into a value and re-encoded. This is NOT the - * same as `JSON.parse` + `JSON.stringify`: parsing a number decodes it into a JS `float64`, - * which silently loses precision for an integer literal beyond - * `Number.MAX_SAFE_INTEGER` (e.g. a snowflake-style id), and re-stringifying a string - * re-escapes it using `JSON.stringify`'s own rules, which can change an existing escape's - * representation (e.g. `\/` becomes a literal `/`) — both would corrupt the exact debug - * payload users are asked to attach to bug reports. `legacyGoJsonIndentTokens` instead scans - * `src` as a token stream (only tracking string boundaries, via backslash-escape skipping, to - * avoid misreading punctuation inside a string as structural) and reproduces Go's exact - * spacing rules: verified empirically against `encoding/json.Indent` for nested objects/ - * arrays, empty `{}`/`[]` (no inserted newline), a `\/`-escaped string, an emoji (multi-UTF-16 - * code point) string, and an integer literal beyond `Number.MAX_SAFE_INTEGER` — all byte- - * identical to Go's own output. Caller ({@link legacyFormatDebugJson}) is responsible for - * validating `src` is well-formed JSON first; this function assumes it and does not itself - * detect malformed input. - */ -function legacyGoJsonIndentTokens(src: string): string { - let out = ""; - let depth = 0; - let needIndent = false; - let i = 0; - const n = src.length; - const newline = (): void => { - out += `\n${" ".repeat(depth)}`; - }; - const openIndentIfNeeded = (): void => { - if (!needIndent) return; - needIndent = false; - depth++; - newline(); - }; - while (i < n) { - const c = src[i]; - if (c === " " || c === "\t" || c === "\r" || c === "\n") { - i++; - continue; - } - if (c === '"') { - const start = i; - i++; - while (i < n) { - if (src[i] === "\\") { - i += 2; - continue; - } - if (src[i] === '"') { - i++; - break; - } - i++; - } - openIndentIfNeeded(); - out += src.slice(start, i); - continue; - } - if (c === "{" || c === "[") { - openIndentIfNeeded(); - out += c; - needIndent = true; - i++; - continue; - } - if (c === "}" || c === "]") { - if (needIndent) { - needIndent = false; - } else { - depth--; - newline(); - } - out += c; - i++; - continue; - } - if (c === ",") { - openIndentIfNeeded(); - out += c; - newline(); - i++; - continue; - } - if (c === ":") { - openIndentIfNeeded(); - out += ": "; - i++; - continue; - } - openIndentIfNeeded(); - out += c; - i++; - } - return out; -} - -/** - * Go's `formatDebugJSON` (`apply.go:286-296`): pretty-print if parseable, else the trimmed raw - * bytes. `JSON.parse` here is used ONLY as a well-formedness check (its result is discarded); - * the actual reformatting goes through {@link legacyGoJsonIndentTokens} so token values are - * never decoded and re-encoded — see that function's own doc comment for why - * `JSON.stringify(JSON.parse(...))` would corrupt the payload Go's `json.Indent` preserves. - */ -export function legacyFormatDebugJson(raw: string): string { - const trimmed = legacyTrimGoSpace(raw); - if (trimmed.length === 0) return ""; - try { - JSON.parse(trimmed); - } catch { - return trimmed; - } - return legacyGoJsonIndentTokens(trimmed); -} - -/** Go's `formatApplyIssueMessage` (`apply.go:223-242`). `String(x ?? "")` throughout — see {@link legacyFormatApplyIssue}'s own doc comment for why. */ -function legacyFormatApplyIssueMessage(issue: LegacyPgDeltaApplyIssue): string { - const trimmed = legacyTrimGoSpace(String(issue.message ?? "")); - const message = trimmed.length > 0 ? trimmed : "unknown pg-delta issue"; - const metadata: Array = []; - const code = String(issue.code ?? ""); - if (code.length > 0) metadata.push(`SQLSTATE ${code}`); - if ((issue.position ?? 0) > 0) metadata.push(`position ${issue.position}`); - if (issue.isDependencyError === true) metadata.push("dependency error"); - if (metadata.length === 0) return message; - return `${message} (${metadata.join(", ")})`; -} - -/** - * Go's `formatApplyIssue` (`apply.go:202-221`). Every `issue.statement.*`/`issue.*` field is - * defaulted with `String(x ?? "")` before use — not a bare `?? ""`: a malformed subprocess - * payload (e.g. a pg-delta release that reports `detail`/`hint`/`sql` as a number) can hand any - * of these a non-string value, which `?? ""` alone does not catch (it only substitutes - * `null`/`undefined`), and the very next call on several of these fields is a string-only - * method (`.trim()`, `legacyFormatStatementSql`'s `.split()`) that throws a `TypeError` on - * anything else — turning an actionable SQL error into an unhandled defect, the worst place for - * a rendering bug to exist, since this only ever runs on an ALREADY-FAILED apply. - * - * The no-statement guard checks both `undefined` and `null`: Go's `Statement *ApplyStatement` - * is a pointer, so `{"statement":null,...}` unmarshals to `nil` and `issue.Statement == nil` - * (`apply.go:202`) treats it exactly like a missing field. A `JSON.parse`'d `null` is not - * `=== undefined`, so checking only `undefined` would fall through to `issue.statement.*` and - * throw a `TypeError` instead of rendering the message. - * - * Returns a `Buffer`, not a `string`: the `SQL: ` line embeds {@link legacyFormatStatementSql}'s - * raw bytes directly (via {@link legacyJoinLines}) rather than interpolating them into a - * template string, so a truncation that lands mid-codepoint reaches `output.rawBytes` - * unmodified instead of being silently corrupted by a UTF-8 decode/re-encode round-trip. - */ -function legacyFormatApplyIssue(rawIssue: LegacyPgDeltaApplyIssue | string | null): Buffer { - const issue = legacyNormalizeApplyIssue(rawIssue); - if (issue.statement === undefined || issue.statement === null) { - return Buffer.from(`- ${legacyFormatApplyIssueMessage(issue)}`, "utf-8"); - } - const statementClass = String(issue.statement.statementClass ?? ""); - const classSuffix = statementClass.length > 0 ? ` [${statementClass}]` : ""; - const lines: Array = [ - Buffer.from(`- ${String(issue.statement.id ?? "")}${classSuffix}`, "utf-8"), - Buffer.from(` ${legacyFormatApplyIssueMessage(issue)}`, "utf-8"), - ]; - const detail = legacyTrimGoSpace(String(issue.detail ?? "")); - if (detail.length > 0) lines.push(Buffer.from(` Detail: ${detail}`, "utf-8")); - const hint = legacyTrimGoSpace(String(issue.hint ?? "")); - if (hint.length > 0) lines.push(Buffer.from(` Hint: ${hint}`, "utf-8")); - const sql = legacyFormatStatementSql(String(issue.statement.sql ?? "")); - if (sql.byteLength > 0) { - lines.push(Buffer.concat([Buffer.from(" SQL: ", "utf-8"), sql])); - } - return legacyJoinLines(lines); -} - -/** Go's `formatApplyDiagnosis` (`apply.go:244-261`). `String(x ?? "")` throughout — see {@link legacyFormatApplyIssue}'s own doc comment for why. */ -function legacyFormatApplyDiagnosis(rawDiagnosis: LegacyPgDeltaApplyDiagnosis | null): string { - const diagnosis = legacyNormalizeApplyDiagnosis(rawDiagnosis); - const trimmed = legacyTrimGoSpace(String(diagnosis.message ?? "")); - const message = trimmed.length > 0 ? trimmed : "unknown pg-delta diagnostic"; - let out = "- "; - const code = legacyTrimGoSpace(String(diagnosis.code ?? "")); - if (code.length > 0) out += `[${code}] `; - out += message; - const loc = legacyFormatStatementLocation(diagnosis.statementId); - if (loc.length > 0) out += ` (${loc})`; - const fix = legacyTrimGoSpace(String(diagnosis.suggestedFix ?? "")); - if (fix.length > 0) out += `\n Suggested fix: ${fix}`; - return out; -} - -/** - * Port of Go's `formatApplyFailure` (`apply.go:150-199`): a human-readable summary of an - * unsuccessful pg-delta apply, rendered on failure regardless of `--debug`. `verbose` - * (Go's `viper.GetBool("DEBUG")`) only expands pg-topo diagnostics inline — collapsed to a - * one-line count by default since a large schema can produce hundreds of them. - * - * Returns a `Buffer`, not a `string` — see {@link legacyFormatStatementSql}'s doc comment: - * an embedded truncated SQL statement can be intentionally invalid UTF-8 (matching Go's raw - * byte slice), and only a `Buffer` carried through to `output.rawBytes` reproduces those - * exact bytes instead of a lossy decode/re-encode round-trip. Callers that only need the - * text for display/assertions (this module's own unit tests) can `.toString("utf-8")` it — - * safe for every case except the one pathological truncation this return type exists to - * preserve exactly. - */ -export function legacyFormatApplyFailure( - result: LegacyPgDeltaApplyResult, - verbose: boolean, -): Buffer { - const errors = result.errors ?? []; - const stuckStatements = result.stuckStatements ?? []; - const validationErrors = result.validationErrors ?? []; - const diagnostics = result.diagnostics ?? []; - - let totalStatements = result.totalStatements ?? 0; - if (totalStatements === 0) { - totalStatements = - (result.totalApplied ?? 0) + (result.totalSkipped ?? 0) + stuckStatements.length; - } - - const lines: Array = [ - // Go renders the status with `%q` (`apply.go:156`) — plain quotes diverge the - // moment a malformed payload puts a quote/control char in `status`. - Buffer.from( - `pg-delta apply returned status ${legacyGoQuote( - Buffer.from(String(result.status ?? ""), "utf-8"), - )}.`, - "utf-8", - ), - Buffer.from( - `${result.totalApplied ?? 0}/${totalStatements} statements applied in ${ - result.totalRounds ?? 0 - } round(s); ${result.totalSkipped ?? 0} skipped.`, - "utf-8", - ), - ]; - if (errors.length > 0) { - lines.push(Buffer.from("Errors:", "utf-8")); - for (const issue of errors) lines.push(legacyFormatApplyIssue(issue)); - } - if (stuckStatements.length > 0) { - lines.push(Buffer.from("Stuck statements:", "utf-8")); - for (const issue of stuckStatements) lines.push(legacyFormatApplyIssue(issue)); - } - if (validationErrors.length > 0) { - lines.push(Buffer.from("Validation errors (from check_function_bodies=on pass):", "utf-8")); - for (const issue of validationErrors) lines.push(legacyFormatApplyIssue(issue)); - } - if (diagnostics.length > 0) { - if (verbose) { - lines.push(Buffer.from("Diagnostics:", "utf-8")); - for (const diagnosis of diagnostics) { - lines.push(Buffer.from(legacyFormatApplyDiagnosis(diagnosis), "utf-8")); - } - } else { - lines.push( - Buffer.from( - `${diagnostics.length} pg-topo diagnostic(s) omitted (re-run with --debug to view).`, - "utf-8", - ), - ); - } - } - // pg-delta may report status "error" without populating any issue arrays (e.g. an internal - // assertion in a future pg-delta release) — point the user at how to get more information - // rather than leaving them with just the bare status line. - if (errors.length === 0 && stuckStatements.length === 0 && validationErrors.length === 0) { - lines.push( - Buffer.from( - [ - "No per-statement diagnostics were reported by pg-delta.", - "Re-run with --debug to print the raw pg-delta payload, or open an issue at", - "https://github.com/supabase/pg-toolbelt/issues with the debug bundle attached.", - ].join("\n"), - "utf-8", - ), - ); - } - return legacyJoinLines(lines); -} - -/** - * Port of Go's `pgdelta.ApplyDeclarative` (`apps/cli-go/internal/pgdelta/apply.go:303-354`): - * applies `declarativeDirAbs` to `target` (the shadow's `contrib_regression` override - * database) via pg-delta's declarative apply engine. Unlike the diff/export/catalog scripts - * (`legacy-pgdelta.ts`), this binds the declarative directory itself read-only at - * `/declarative` rather than mounting the whole project at `/workspace` — Go's own - * `ApplyDeclarative` never needs the wider project tree, only the schema files. `target` is - * always a LOCAL shadow connection (never a remote/Supabase-hosted endpoint), so — unlike - * `legacyDiffPgDelta`'s SOURCE/TARGET — no SSL/CA-bundle preparation applies here, matching - * Go's own plain `"TARGET="+utils.ToPostgresURL(config)` (no TLS handling at all). - */ -export const legacyApplyDeclarativePgDelta = Effect.fnUntraced(function* ( - ctx: LegacyPgDeltaContext, - params: { - readonly fs: FileSystem.FileSystem; - /** Absolute host path to the declarative schema directory (stat/bind use this). */ - readonly declarativeDirAbs: string; - /** - * Go's `utils.GetDeclarativeDir()` (`apply.go:304`) — the config value verbatim - * (already `supabase/`-prefixed when relative) or the relative `supabase/schemas` - * default. Used ONLY in the not-found error message below: Go interpolates this - * relative value, never the `filepath.Abs`-resolved `absDir` it separately computes - * for the bind. - */ - readonly declarativeDirRel: string; - /** The shadow override database's Postgres URL. */ - readonly target: string; - }, -) { - const exists = yield* params.fs - .exists(params.declarativeDirAbs) - .pipe(Effect.orElseSucceed(() => false)); - if (!exists) { - return yield* Effect.fail( - new LegacyPgDeltaDeclarativeApplyError({ - message: `declarative schema directory not found: ${params.declarativeDirRel}`, - reason: "missing_schema_dir", - }), - ); - } - - const output = yield* Output; - const edgeRuntime = yield* LegacyEdgeRuntimeScript; - // Go's `pgdelta.ApplyDeclarative` reads `viper.GetBool("DEBUG")` (`apply.go:332,342`), which - // falls back to `SUPABASE_DEBUG` via `AutomaticEnv` when `--debug` itself is unset — - // `legacyResolveDebugWithProjectEnv` (not the bare `LegacyDebugFlag`) reproduces that (review: - // PRRT_kwDOErm0O86XDr4V). By the time either `db diff`/`db pull` reaches here, - // `ParseDatabaseConfig` has already run `Config.Load` -> `loadNestedEnv`, which really - // `os.Setenv`s the merged project `supabase/.env` into the process (`godotenv.Load`, - // `godotenv@v1.5.1/godotenv.go:184-200`) — unlike this port's own `legacyLoadProjectEnv`, - // which is deliberately pure — so a `SUPABASE_DEBUG` set only in `supabase/.env` is visible - // to Go's `viper.GetBool("DEBUG")` here. `legacyResolveDebugWithProjectEnv` reproduces that - // with `ctx.projectEnv` (`legacyReadDbToml`'s merged map, threaded by both `db diff` and - // `db pull`, review: PRRT_kwDOErm0O86XL_oz). - const debug = yield* legacyResolveDebugWithProjectEnv(ctx.projectEnv); - - yield* output.raw("Applying declarative schemas via pg-delta...\n", "stderr"); - - const env: Record = { - SCHEMA_PATH: LEGACY_PG_DELTA_APPLY_CONTAINER_SCHEMA_PATH, - TARGET: params.target, - }; - const binds = [ - `${legacyEdgeRuntimeId(ctx.projectId)}:/root/.cache/deno:rw`, - `${params.declarativeDirAbs}:${LEGACY_PG_DELTA_APPLY_CONTAINER_SCHEMA_PATH}:ro`, - ]; - const npm = legacyPgDeltaNpmRegistryOption(ctx.projectEnv); - const result = yield* edgeRuntime - .run({ - script: legacyInterpolatePgDeltaScript(legacyPgDeltaDeclarativeApplyScript, ctx.npmVersion), - env, - binds, - errPrefix: "error running pg-delta script", - extraFiles: npm.extraFiles, - extraEnv: npm.extraEnv, - denoVersion: ctx.denoVersion, - workdir: ctx.cwd, - }) - .pipe( - Effect.mapError( - (cause) => - new LegacyPgDeltaDeclarativeApplyError({ message: cause.message, reason: cause.docker }), - ), - ); - - const parsed = yield* Effect.try({ - try: () => { - const raw: unknown = JSON.parse(result.stdout); - // Go's `json.Unmarshal` accepts a top-level JSON `null` for the non-pointer - // `ApplyResult` destination and leaves it zero-valued, with no error (verified - // empirically) — so a `null` payload must fall through to the normal - // `status !== "success"` failure path below, not be misclassified as a parse - // failure. See {@link legacyIsPgDeltaApplyResult}'s own doc comment. - const normalized: unknown = raw === null ? {} : raw; - if (!legacyIsPgDeltaApplyResult(normalized)) { - throw new Error("pg-delta apply output was not a JSON object"); - } - return normalized; - }, - catch: (cause) => - new LegacyPgDeltaDeclarativeApplyError({ - message: debug - ? `failed to parse pg-delta apply output: ${errMessage(cause)}\nstdout: ${result.stdout}` - : `failed to parse pg-delta apply output: ${errMessage(cause)}`, - reason: "output_parse", - }), - }); - - if (parsed.status !== "success") { - // `output.rawBytes`, not `output.raw`: `legacyFormatApplyFailure` returns a `Buffer` that - // may contain intentionally-invalid trailing UTF-8 bytes (a truncated SQL statement cut - // mid-codepoint, matching Go's raw byte slice) — decoding it into a string here would - // corrupt exactly the bytes that Buffer exists to preserve. See its own doc comment. - yield* output.rawBytes( - Buffer.concat([legacyFormatApplyFailure(parsed, debug), Buffer.from("\n", "utf-8")]), - "stderr", - ); - if (debug) { - const debugJson = legacyFormatDebugJson(result.stdout); - if (debugJson.length > 0) { - yield* output.raw("pg-delta apply result:\n", "stderr"); - yield* output.raw(`${debugJson}\n`, "stderr"); - } - } - return yield* Effect.fail( - new LegacyPgDeltaDeclarativeApplyError({ - message: `pg-delta declarative apply failed with status: ${parsed.status ?? ""}`, - }), - ); - } - yield* output.raw( - `Applied ${parsed.totalApplied ?? 0} statements in ${parsed.totalRounds ?? 0} round(s).\n`, - "stderr", - ); -}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.unit.test.ts deleted file mode 100644 index 409eeac15f..0000000000 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.unit.test.ts +++ /dev/null @@ -1,460 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import { - legacyFormatApplyFailure, - legacyFormatDebugJson, - type LegacyPgDeltaApplyDiagnosis, - type LegacyPgDeltaApplyIssue, - type LegacyPgDeltaApplyResult, - type LegacyPgDeltaApplyStatementLocation, -} from "./legacy-pgdelta.apply.ts"; - -describe("legacyFormatApplyFailure", () => { - test("renders the status + counts summary line, with no per-statement sections when there are no issues", () => { - const result: LegacyPgDeltaApplyResult = { - status: "error", - totalStatements: 4, - totalRounds: 2, - totalApplied: 3, - totalSkipped: 1, - }; - const message = legacyFormatApplyFailure(result, false).toString("utf-8"); - expect(message).toContain('pg-delta apply returned status "error".'); - expect(message).toContain("3/4 statements applied in 2 round(s); 1 skipped."); - expect(message).toContain("No per-statement diagnostics were reported by pg-delta."); - expect(message).toContain("https://github.com/supabase/pg-toolbelt/issues"); - }); - - test("derives totalStatements from applied + skipped + stuck when omitted", () => { - const result: LegacyPgDeltaApplyResult = { - status: "error", - totalRounds: 1, - totalApplied: 2, - totalSkipped: 1, - stuckStatements: ["stuck one"], - }; - const message = legacyFormatApplyFailure(result, false).toString("utf-8"); - expect(message).toContain("2/4 statements applied in 1 round(s); 1 skipped."); - }); - - test("renders a structured issue with no `statement` field as its message, with SQLSTATE/position/dependency metadata appended", () => { - const issue: LegacyPgDeltaApplyIssue = { - message: "relation already exists", - code: "42P07", - position: 15, - isDependencyError: true, - }; - const result: LegacyPgDeltaApplyResult = { - status: "error", - totalApplied: 0, - totalRounds: 1, - totalSkipped: 0, - errors: [issue], - }; - const message = legacyFormatApplyFailure(result, false).toString("utf-8"); - expect(message).toContain("Errors:"); - expect(message).toContain( - "- relation already exists (SQLSTATE 42P07, position 15, dependency error)", - ); - }); - - test("renders a genuine bare string issue (Go's ApplyIssue string-arm) as its own message", () => { - const result: LegacyPgDeltaApplyResult = { - status: "error", - totalApplied: 0, - totalRounds: 1, - totalSkipped: 0, - errors: ["relation already exists"], - }; - const message = legacyFormatApplyFailure(result, false).toString("utf-8"); - expect(message).toContain("Errors:\n- relation already exists"); - }); - - test("renders a structured issue with its statement id/class, detail, hint, and truncated SQL", () => { - const issue: LegacyPgDeltaApplyIssue = { - message: "column does not exist", - statement: { - id: "001_add_column", - statementClass: "alter_table", - sql: "alter table t add column c int;", - }, - detail: "Column c was dropped earlier in this plan.", - hint: "Check the plan ordering.", - }; - const result: LegacyPgDeltaApplyResult = { - status: "error", - totalApplied: 0, - totalRounds: 1, - totalSkipped: 0, - errors: [issue], - }; - const message = legacyFormatApplyFailure(result, false).toString("utf-8"); - expect(message).toContain("- 001_add_column [alter_table]"); - expect(message).toContain(" column does not exist"); - expect(message).toContain(" Detail: Column c was dropped earlier in this plan."); - expect(message).toContain(" Hint: Check the plan ordering."); - expect(message).toContain(" SQL: alter table t add column c int;"); - }); - - test("truncates a multibyte SQL statement by UTF-8 bytes, not UTF-16 code units", () => { - // Go's `formatStatementSQL` (`apply.go:277-283`) truncates via `len(normalized)` and - // `normalized[:maxLen-3]`, both of which count/slice raw UTF-8 bytes. 70 repetitions of a - // single 3-byte CJK character is only 70 JS UTF-16 code units (well under the 120-char - // threshold a naive `.length`/`.slice()` guard would use — it would never truncate at all), - // but 210 UTF-8 bytes — well over Go's 120-byte limit. `117 / 3 === 39` lands the byte cut - // exactly on a codepoint boundary, so the expected output is unambiguous. - const sql = "字".repeat(70); - const issue: LegacyPgDeltaApplyIssue = { - message: "boom", - statement: { id: "001_a", sql }, - }; - const result: LegacyPgDeltaApplyResult = { - status: "error", - totalApplied: 0, - totalRounds: 1, - totalSkipped: 0, - errors: [issue], - }; - const message = legacyFormatApplyFailure(result, false).toString("utf-8"); - expect(sql.length).toBeLessThanOrEqual(120); - expect(Buffer.byteLength(sql, "utf-8")).toBe(210); - expect(message).toContain(` SQL: ${"字".repeat(39)}...`); - expect(message).not.toContain(sql); - }); - - test("collapses a NEL (U+0085) as whitespace, matching Go's unicode.IsSpace, unlike ECMAScript's `\\s`", () => { - // Go's `formatStatementSQL` (`apply.go:277-283`) normalizes via `strings.Fields`, which - // splits on `unicode.IsSpace` — and `unicode.IsSpace(0x85)` (NEL) is `true` (verified - // empirically), so a NEL embedded in a user's SQL statement is collapsed like any other - // run of whitespace. ECMAScript's `\s` does NOT match NEL, so a naive `.split(/\s+/u)` - // would preserve it verbatim instead of collapsing it. - const nel = String.fromCodePoint(0x85); - const sql = `select${nel}1;`; - const issue: LegacyPgDeltaApplyIssue = { - message: "boom", - statement: { id: "001_a", sql }, - }; - const result: LegacyPgDeltaApplyResult = { - status: "error", - totalApplied: 0, - totalRounds: 1, - totalSkipped: 0, - errors: [issue], - }; - const message = legacyFormatApplyFailure(result, false).toString("utf-8"); - expect(message).toContain(" SQL: select 1;"); - expect(message).not.toContain(nel); - }); - - test("preserves a BOM (U+FEFF) instead of treating it as whitespace, matching Go's unicode.IsSpace, unlike ECMAScript's `\\s`", () => { - // The opposite gap from the NEL case above: `unicode.IsSpace(0xFEFF)` (BOM) is `false` - // (verified empirically), so Go's `strings.Fields` keeps a BOM embedded mid-statement as - // part of the surrounding "word" rather than treating it as a separator. ECMAScript's `\s` - // DOES match a BOM, so a naive `.split(/\s+/u)` would incorrectly split on it. - const bom = String.fromCodePoint(0xfeff); - const sql = `select${bom}1;`; - const issue: LegacyPgDeltaApplyIssue = { - message: "boom", - statement: { id: "001_a", sql }, - }; - const result: LegacyPgDeltaApplyResult = { - status: "error", - totalApplied: 0, - totalRounds: 1, - totalSkipped: 0, - errors: [issue], - }; - const message = legacyFormatApplyFailure(result, false).toString("utf-8"); - expect(message).toContain(` SQL: select${bom}1;`); - }); - - test("preserves Go's exact (possibly invalid-UTF-8) truncated bytes when the byte cut lands mid-codepoint", () => { - // Unlike the boundary-aligned CJK-repeat case above, a single leading ASCII byte shifts - // every subsequent 3-byte CJK character by one, so the byte-117 cut now lands ONE byte - // into a character instead of exactly on a boundary — reproducing the pathological case - // where Go's raw `normalized[:117]` slice is intentionally invalid UTF-8. Verified against - // Go's own `formatStatementSQL` (`apply.go:277-283`): slicing this exact byte range - // produces a 120-byte result that `unicode/utf8.ValidString` reports as `false`. A naive - // `Buffer#toString("utf-8")` truncation would instead substitute U+FFFD for the incomplete - // trailing sequence, corrupting the byte-exact stderr contract. - const sql = `a${"字".repeat(60)}`; - const issue: LegacyPgDeltaApplyIssue = { - message: "boom", - statement: { id: "001_a", sql }, - }; - const result: LegacyPgDeltaApplyResult = { - status: "error", - totalApplied: 0, - totalRounds: 1, - totalSkipped: 0, - errors: [issue], - }; - const message = legacyFormatApplyFailure(result, false); - const normalizedBytes = Buffer.from(sql, "utf-8"); - const expectedTruncatedTail = Buffer.concat([ - normalizedBytes.subarray(0, 117), - Buffer.from("...", "utf-8"), - ]); - expect(expectedTruncatedTail.byteLength).toBe(120); - expect( - message.includes(Buffer.concat([Buffer.from(" SQL: ", "utf-8"), expectedTruncatedTail])), - ).toBe(true); - // No replacement character (the tell-tale sign of a lossy UTF-8 decode/re-encode - // round-trip) should ever appear in the output. - expect(message.includes(Buffer.from("�", "utf-8"))).toBe(false); - }); - - test("treats a null errors/stuckStatements/validationErrors/diagnostics array as empty, matching Go's nil-slice decode", () => { - // Go's `encoding/json` accepts a JSON `null` for a `[]T` slice field with no error, - // leaving a nil (zero-length) slice — verified empirically: - // `json.Unmarshal([]byte(\`{"status":"error","errors":null}\`), &r)` returns `err == nil` - // with `len(r.Errors) == 0`. `legacyFormatApplyFailure` itself already treats a JS `null`/ - // `undefined` array as empty via `?? []`; this exercises that the TYPE also tolerates it - // (the earlier structural-guard bug — `legacyIsPgDeltaApplyResult` — is covered by the - // integration test in `legacy-pgdelta.apply.integration.test.ts`, since it isn't exported). - const result: LegacyPgDeltaApplyResult = { - status: "error", - totalApplied: 0, - totalRounds: 1, - totalSkipped: 0, - errors: null, - stuckStatements: null, - validationErrors: null, - diagnostics: null, - }; - const message = legacyFormatApplyFailure(result, false).toString("utf-8"); - expect(message).toContain("No per-statement diagnostics were reported by pg-delta."); - expect(message).not.toContain("Errors:"); - expect(message).not.toContain("Stuck statements:"); - }); - - test("stuck statements and validation errors get their own labeled sections", () => { - const result: LegacyPgDeltaApplyResult = { - status: "error", - totalApplied: 0, - totalRounds: 1, - totalSkipped: 0, - stuckStatements: ["still stuck"], - validationErrors: ["bad function body"], - }; - const message = legacyFormatApplyFailure(result, false).toString("utf-8"); - expect(message).toContain("Stuck statements:\n- still stuck"); - expect(message).toContain( - "Validation errors (from check_function_bodies=on pass):\n- bad function body", - ); - }); - - test("diagnostics collapse to a one-line count unless verbose", () => { - const result: LegacyPgDeltaApplyResult = { - status: "error", - totalApplied: 1, - totalRounds: 1, - totalSkipped: 0, - errors: ["some error"], - diagnostics: [{ message: "unused index" }, { message: "missing default" }], - }; - const collapsed = legacyFormatApplyFailure(result, false).toString("utf-8"); - expect(collapsed).toContain("2 pg-topo diagnostic(s) omitted (re-run with --debug to view)."); - expect(collapsed).not.toContain("unused index"); - - const verbose = legacyFormatApplyFailure(result, true).toString("utf-8"); - expect(verbose).toContain("Diagnostics:"); - expect(verbose).toContain("- unused index"); - expect(verbose).toContain("- missing default"); - }); - - test("renders a partially-populated statement (missing sql/statementClass) without throwing", () => { - // Reproduces feeding a real pg-delta subprocess's malformed stdout - // (`{"errors":[{"message":"boom","statement":{"id":"s1"}}]}`) through - // `legacyApplyDeclarativePgDelta` — that function only validates the top-level shape - // (`{status: string}`), not nested fields, and this only ever runs on an - // ALREADY-FAILED apply, so a formatter crash here would turn an actionable SQL error - // into an unhandled defect. - const parsed = JSON.parse( - '{"status":"error","totalApplied":0,"totalRounds":1,"totalSkipped":0,"errors":[{"message":"boom","statement":{"id":"s1"}}]}', - ) as LegacyPgDeltaApplyResult; - expect(() => legacyFormatApplyFailure(parsed, false).toString("utf-8")).not.toThrow(); - const message = legacyFormatApplyFailure(parsed, false).toString("utf-8"); - expect(message).toContain("- s1"); - expect(message).toContain(" boom"); - expect(message).not.toContain("undefined"); - }); - - test("renders an issue with a null `statement` field as its message, without throwing", () => { - // Reproduces feeding a real pg-delta subprocess's stdout - // (`{"errors":[{"statement":null,"message":"failed"}]}`) through - // `legacyApplyDeclarativePgDelta` — Go's `Statement *ApplyStatement` is a pointer, so - // `"statement":null` unmarshals to `nil` and `formatApplyIssue`'s `issue.Statement == nil` - // (`apply.go:202`) treats it identically to a missing field. A no-statement guard that only - // checks `=== undefined` would fall through to `issue.statement.statementClass` on `null` - // and throw a `TypeError` instead of rendering the message. - const parsed = JSON.parse( - '{"status":"error","totalApplied":0,"totalRounds":1,"totalSkipped":0,"errors":[{"statement":null,"message":"failed"}]}', - ) as LegacyPgDeltaApplyResult; - expect(() => legacyFormatApplyFailure(parsed, false).toString("utf-8")).not.toThrow(); - const message = legacyFormatApplyFailure(parsed, false).toString("utf-8"); - expect(message).toContain("Errors:\n- failed"); - }); - - test("renders an issue whose detail/hint/sql/statementClass arrived as non-strings without throwing", () => { - // A malformed pg-delta payload can hand any of these fields a non-string value (e.g. a - // future release that reports a numeric `detail`) — a bare `?? ""` guard (rather than - // `String(x ?? "")`) would still pass the number straight to `.trim()`/`.split()` and throw. - const parsed = JSON.parse( - '{"status":"error","totalApplied":0,"totalRounds":1,"totalSkipped":0,"errors":[{"message":"boom","statement":{"id":"s1","statementClass":42,"sql":7},"detail":123,"hint":456}]}', - ) as LegacyPgDeltaApplyResult; - expect(() => legacyFormatApplyFailure(parsed, false).toString("utf-8")).not.toThrow(); - const message = legacyFormatApplyFailure(parsed, false).toString("utf-8"); - expect(message).toContain("- s1 [42]"); - expect(message).toContain(" Detail: 123"); - expect(message).toContain(" Hint: 456"); - expect(message).toContain(" SQL: 7"); - }); - - test("renders a diagnosis whose message/code/suggestedFix arrived as non-strings without throwing", () => { - const parsed = JSON.parse( - '{"status":"error","totalApplied":0,"totalRounds":1,"totalSkipped":0,"errors":["e"],"diagnostics":[{"message":123,"code":456,"suggestedFix":789}]}', - ) as LegacyPgDeltaApplyResult; - expect(() => legacyFormatApplyFailure(parsed, true).toString("utf-8")).not.toThrow(); - const message = legacyFormatApplyFailure(parsed, true).toString("utf-8"); - expect(message).toContain("[456] 123"); - expect(message).toContain("Suggested fix: 789"); - }); - - test("drops a diagnosis's statementId when a nested field is mistyped, matching Go's nil fallback", () => { - // Go's `(d *ApplyDiagnosis) UnmarshalJSON` (`apply.go:79-108`) tries decoding `statementId` - // as an `ApplyStatementLocation` object first; a mistyped `filePath` (a number, not a - // string) fails that decode, and its bare-string fallback ALSO fails since the value is an - // object, not a string — so Go silently leaves `StatementID` nil, never erroring the whole - // `ApplyResult` parse. Verified empirically against Go's real struct + fallback chain: - // `{"statementId":{"filePath":123,"statementIndex":1}}` decodes with `StatementID == nil`. - // Rendering the raw object anyway (coercing `filePath` via `String(123)`) would show a - // bogus `(123#1)` location Go never emits. - const parsed = JSON.parse( - '{"status":"error","totalApplied":0,"totalRounds":1,"totalSkipped":0,"errors":["e"],"diagnostics":[{"message":"d","statementId":{"filePath":123,"statementIndex":1}}]}', - ) as LegacyPgDeltaApplyResult; - expect(() => legacyFormatApplyFailure(parsed, true).toString("utf-8")).not.toThrow(); - const message = legacyFormatApplyFailure(parsed, true).toString("utf-8"); - expect(message).toContain("- d"); - expect(message).not.toContain("123#1"); - expect(message).not.toContain("(123"); - }); - - test("drops a diagnosis's statementId when sourceOffset is mistyped, even though the location renderer never reads it", () => { - // Go's struct-level `json.Unmarshal` into `ApplyStatementLocation` (`apply.go:73-77`) - // fails the moment ANY declared field has the wrong type — including `sourceOffset`, - // which `legacyFormatStatementLocation`/Go's own `formatStatementLocation` never - // display. Verified empirically against Go's real struct: - // `json.Unmarshal([]byte(\`{"filePath":"x.sql","sourceOffset":"bad"}\`), &loc)` returns a - // non-nil error even though `filePath` itself is well-typed, so the object-shape decode - // fails, the bare-string fallback also fails (the value is an object, not a string), and - // Go leaves `StatementID` nil — the location must be dropped, not rendered as `(x.sql)`, - // which would misattribute the diagnostic to a file Go never resolved. - const parsed = JSON.parse( - '{"status":"error","totalApplied":0,"totalRounds":1,"totalSkipped":0,"errors":["e"],"diagnostics":[{"message":"d","statementId":{"filePath":"x.sql","sourceOffset":"bad"}}]}', - ) as LegacyPgDeltaApplyResult; - expect(() => legacyFormatApplyFailure(parsed, true).toString("utf-8")).not.toThrow(); - const message = legacyFormatApplyFailure(parsed, true).toString("utf-8"); - expect(message).toContain("- d"); - expect(message).not.toContain("x.sql"); - }); - - test("renders a diagnosis with a null statementId as having no location, without throwing", () => { - // Reproduces a real pg-delta subprocess emitting - // `{"diagnostics":[{"message":"failed","statementId":null}]}` — Go's - // `(d *ApplyDiagnosis) UnmarshalJSON` (`apply.go:79-108`) explicitly maps a JSON - // `"statementId":null` to a nil `*ApplyStatementLocation`, and `formatStatementLocation` - // (`apply.go:263-274`) returns `""` for a nil pointer. A guard that only checked - // `resolved === undefined` (not `null`) would fall through to - // `legacyFormatStatementLocation`'s `resolved.filePath` and dereference a `null`, throwing a - // `TypeError` instead of rendering the rest of the diagnostic. - const parsed = JSON.parse( - '{"status":"error","totalApplied":0,"totalRounds":1,"totalSkipped":0,"errors":["e"],"diagnostics":[{"message":"failed","statementId":null}]}', - ) as LegacyPgDeltaApplyResult; - expect(() => legacyFormatApplyFailure(parsed, true).toString("utf-8")).not.toThrow(); - const message = legacyFormatApplyFailure(parsed, true).toString("utf-8"); - expect(message).toContain("- failed"); - expect(message).not.toContain("undefined"); - }); - - test("a diagnosis with a statementId location and suggestedFix renders both", () => { - const statementId: LegacyPgDeltaApplyStatementLocation = { - filePath: "001_a.sql", - statementIndex: 2, - }; - const diagnosis: LegacyPgDeltaApplyDiagnosis = { - code: "PGT001", - message: "circular dependency", - statementId, - suggestedFix: "Split the statement across two files.", - }; - const result: LegacyPgDeltaApplyResult = { - status: "error", - totalApplied: 1, - totalRounds: 1, - totalSkipped: 0, - errors: ["some error"], - diagnostics: [diagnosis], - }; - const message = legacyFormatApplyFailure(result, true).toString("utf-8"); - expect(message).toContain("- [PGT001] circular dependency (001_a.sql#2)"); - expect(message).toContain("Suggested fix: Split the statement across two files."); - }); -}); - -describe("legacyFormatDebugJson", () => { - test("pretty-prints valid JSON", () => { - expect(legacyFormatDebugJson('{"status":"error","totalApplied":1}')).toBe( - JSON.stringify({ status: "error", totalApplied: 1 }, null, 2), - ); - }); - - test("returns the trimmed raw string when it isn't valid JSON", () => { - expect(legacyFormatDebugJson(" not json ")).toBe("not json"); - }); - - test("returns empty for blank input", () => { - expect(legacyFormatDebugJson(" ")).toBe(""); - }); - - test("preserves an integer literal beyond Number.MAX_SAFE_INTEGER byte-for-byte", () => { - // Go's `json.Indent` (`encoding/json/indent.go`) only inserts whitespace between existing - // tokens — it never decodes a number into a value and re-encodes it. `JSON.parse` would - // decode this literal into a `float64`-backed JS number, silently rounding it (verified: - // `JSON.parse("9007199254740993").toString()` is `"9007199254740992"`), and - // `JSON.stringify` would then re-emit the ROUNDED value — corrupting the exact debug - // payload users are asked to attach to bug reports. - const raw = '{"id":9007199254740993}'; - expect(legacyFormatDebugJson(raw)).toBe('{\n "id": 9007199254740993\n}'); - }); - - test("preserves an existing string escape's exact representation (e.g. an escaped forward slash)", () => { - // Go's `json.Indent` copies string tokens byte-for-byte, so an existing `\/` escape stays - // `\/`. `JSON.stringify(JSON.parse(...))` would instead re-escape the decoded `/` using its - // own (unescaped) convention, changing the payload's exact bytes. - const raw = '{"path":"a\\/b"}'; - expect(legacyFormatDebugJson(raw)).toBe('{\n "path": "a\\/b"\n}'); - }); - - test("matches Go's json.Indent shape for nested objects/arrays, including empty ones", () => { - const raw = '{"a":1,"b":{"c":2,"d":[1,{"e":3}]},"empty":{},"emptyArr":[]}'; - expect(legacyFormatDebugJson(raw)).toBe( - [ - "{", - ' "a": 1,', - ' "b": {', - ' "c": 2,', - ' "d": [', - " 1,", - " {", - ' "e": 3', - " }", - " ]", - " },", - ' "empty": {},', - ' "emptyArr": []', - "}", - ].join("\n"), - ); - }); -}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.ts deleted file mode 100644 index f2a0a13a09..0000000000 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.ts +++ /dev/null @@ -1,72 +0,0 @@ -// Verbatim copies of the Go pg-delta Deno templates. These embed the scripts -// byte-for-byte; `legacy-pgdelta.deno-templates.unit.test.ts` asserts equality -// against the Go `.ts` sources. Do not hand-edit — regenerate from Go. -// -// Four templates back the in-scope flows: diff / declarative-export / catalog- -// export live in `apps/cli-go/internal/db/diff/templates/`, and the declarative -// *apply* template (used by `getDeclarativeCatalogRef` → `pgdelta.ApplyDeclarative` -// to build the declarative target catalog on the shadow database) lives in -// `apps/cli-go/internal/pgdelta/templates/`. The migra.* templates back the -// non-pgdelta diff path, which declarative commands never reach. -// -// Each template pins `npm:@supabase/pg-delta@1.0.0-alpha.20` as a placeholder -// that `legacyInterpolatePgDeltaScript` rewrites to the effective npm version -// (`apps/cli-go/pkg/config/pgdelta_version.go`). - -/** `templates/pgdelta.ts` — diffs SOURCE→TARGET and prints SQL statements. */ -export const legacyPgDeltaDiffScript = - 'import {\n createPlan,\n deserializeCatalog,\n renderPlanFiles,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20";\nimport { supabase } from "npm:@supabase/pg-delta@1.0.0-alpha.20/integrations/supabase";\n\nasync function resolveInput(ref: string | undefined) {\n if (!ref) {\n return null;\n }\n if (ref.startsWith("postgres://") || ref.startsWith("postgresql://")) {\n return ref;\n }\n const json = await Deno.readTextFile(ref);\n return deserializeCatalog(JSON.parse(json));\n}\n\nconst source = Deno.env.get("SOURCE");\nconst target = Deno.env.get("TARGET");\n\nconst includedSchemas = Deno.env.get("INCLUDED_SCHEMAS");\nif (includedSchemas) {\n const schemas = includedSchemas.split(",");\n const schemaFilter = {\n or: [{ "*/schema": schemas }, { "schema/name": schemas }],\n };\n // CompositionPattern `and` is valid FilterDSL; Deno\'s structural typing is strict on `or` branches.\n supabase.filter = {\n and: [supabase.filter!, schemaFilter],\n } as typeof supabase.filter;\n}\n\nconst formatOptionsRaw = Deno.env.get("FORMAT_OPTIONS");\nconst parsedFormatOptions = formatOptionsRaw ? JSON.parse(formatOptionsRaw) : undefined;\n// Format the emitted SQL by default with the same sensible settings the\n// declarative export uses (`exportDeclarativeSchema` in @supabase/pg-delta:\n// `{ ...DEFAULT_OPTIONS, maxWidth: 180, keywordCase: "upper", ...userOptions }`),\n// so `db pull` / `db diff` produce readable migrations even when config sets no\n// `[experimental.pgdelta] format_options`. The formatter fills DEFAULT_OPTIONS\n// for missing keys itself, so only the two overrides are passed here. Setting\n// `format_options = "null"` (parsed to `null`) is the explicit opt-out: raw,\n// unformatted statements, mirroring declarative export\'s `formatOptions === null`.\nconst sqlFormatOptions =\n parsedFormatOptions === null\n ? undefined\n : { maxWidth: 180, keywordCase: "upper", ...parsedFormatOptions };\n\ntry {\n const result = await createPlan(\n await resolveInput(source),\n await resolveInput(target),\n {\n ...supabase,\n skipDefaultPrivilegeSubtraction: true,\n },\n );\n // pg-delta >= 1.0.0-alpha.32 groups plan statements into execution-aware\n // `units` with transaction boundaries. `renderPlanFiles` turns those into one\n // numbered SQL file per unit (header comments included). `includeTransactions:\n // false` because the CLI appliers already wrap each migration file in a single\n // transaction (Go and TS implicit extended-protocol batches), so embedded\n // BEGIN/COMMIT would override that file-level boundary. Format options are\n // applied per unit here instead of a manual `formatSqlStatements` pass.\n const files = result\n ? renderPlanFiles(result.plan, {\n includeTransactions: false,\n sqlFormatOptions,\n })\n : [];\n const envelope = files.map((file, index) => ({\n order: index + 1,\n // The unit name is the rendered path minus its numeric prefix and `.sql`\n // extension (e.g. `001_after_enum_values.sql` -> `after_enum_values`).\n name: file.path.replace(/^\\d+_/, "").replace(/\\.sql$/, ""),\n transactionMode: file.unit.transactionMode,\n sql: file.sql,\n }));\n if (Deno.env.get("PGDELTA_DEBUG")) {\n console.error(\n JSON.stringify({\n statementCount: files.reduce((total, file) => total + file.unit.statements.length, 0),\n fileCount: files.length,\n source: source ? "connected" : "null",\n target: target ? "connected" : "null",\n includedSchemas: includedSchemas ?? null,\n skipDefaultPrivilegeSubtraction: true,\n }),\n );\n }\n console.log(JSON.stringify({ version: 1, files: envelope }));\n} catch (e) {\n console.error(e);\n // Emit a sentinel so the CLI runner can distinguish a real script crash from a\n // successful empty diff, even though the forced-exit non-zero code below is\n // suppressed by the "main worker has been destroyed" handling.\n console.error("PGDELTA_SCRIPT_ERROR");\n // Force close event loop\n throw new Error("");\n}\n// Force close the event loop on the success path too. When SOURCE/TARGET are\n// live database URLs the plan opens connections whose keepalive handles can keep\n// the Edge Runtime worker alive after the diff has been written, so the container\n// never exits and the CLI — which follows this container\'s logs — hangs\n// indefinitely at 0% CPU (supabase/pg-toolbelt#312).\nthrow new Error("");\n'; - -/** `templates/pgdelta_declarative_export.ts` — exports declarative file payloads. */ -export const legacyPgDeltaDeclarativeExportScript = - '// This script is executed inside Edge Runtime by the CLI to export a target\n// schema as declarative file payloads. It accepts either live DB URLs or\n// catalog-file references for SOURCE/TARGET, which enables cached sync flows.\nimport {\n createPlan,\n deserializeCatalog,\n exportDeclarativeSchema,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20";\nimport { supabase } from "npm:@supabase/pg-delta@1.0.0-alpha.20/integrations/supabase";\n\nasync function resolveInput(ref: string | undefined) {\n if (!ref) {\n return null;\n }\n if (ref.startsWith("postgres://") || ref.startsWith("postgresql://")) {\n return ref;\n }\n const json = await Deno.readTextFile(ref);\n return deserializeCatalog(JSON.parse(json));\n}\n\nconst source = Deno.env.get("SOURCE");\nconst target = Deno.env.get("TARGET");\n\nconst includedSchemas = Deno.env.get("INCLUDED_SCHEMAS");\nif (includedSchemas) {\n const schemas = includedSchemas.split(",");\n const schemaFilter = {\n or: [{ "*/schema": schemas }, { "schema/name": schemas }],\n };\n supabase.filter = {\n and: [supabase.filter!, schemaFilter],\n } as unknown as typeof supabase.filter;\n}\n\nconst formatOptionsRaw = Deno.env.get("FORMAT_OPTIONS");\nlet formatOptions = undefined;\nif (formatOptionsRaw) {\n formatOptions = JSON.parse(formatOptionsRaw);\n}\ntry {\n const result = await createPlan(\n await resolveInput(source),\n await resolveInput(target),\n {\n ...supabase,\n skipDefaultPrivilegeSubtraction: true,\n },\n );\n if (!result) {\n console.log(\n JSON.stringify({\n version: 1,\n mode: "declarative",\n files: [],\n }),\n );\n } else {\n const output = exportDeclarativeSchema(result, {\n integration: supabase,\n formatOptions,\n });\n console.log(\n JSON.stringify(output, (_key, value) =>\n typeof value === "bigint" ? Number(value) : value,\n ),\n );\n }\n} catch (e) {\n console.error(e);\n // Emit a sentinel so the CLI runner can distinguish a real script crash from a\n // successful empty export, even though the forced-exit non-zero code below is\n // suppressed by the "main worker has been destroyed" handling.\n console.error("PGDELTA_SCRIPT_ERROR");\n // Force close event loop\n throw new Error("");\n}\n// Force close the event loop on the success path too. When SOURCE/TARGET are\n// live database URLs the plan opens connections whose keepalive handles can keep\n// the Edge Runtime worker alive after the export has been written, so the\n// container never exits and the CLI — which follows this container\'s logs —\n// hangs indefinitely at 0% CPU (supabase/pg-toolbelt#312).\nthrow new Error("");\n'; - -/** `templates/pgdelta_catalog_export.ts` — serializes a catalog snapshot for caching. */ -export const legacyPgDeltaCatalogExportScript = - '// This script serializes a database catalog for caching/reuse in declarative\n// sync workflows, so later diff/export operations can run from file references.\nimport {\n createManagedPool,\n extractCatalog,\n serializeCatalog,\n stringifyCatalogSnapshot,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20";\n\nconst target = Deno.env.get("TARGET");\nconst role = Deno.env.get("ROLE") ?? undefined;\n\nif (!target) {\n console.error("TARGET is required");\n // Emit a sentinel so the CLI runner treats this as a real script crash rather\n // than a successful empty catalog, even though the forced-exit non-zero code is\n // suppressed by the "main worker has been destroyed" handling.\n console.error("PGDELTA_SCRIPT_ERROR");\n throw new Error("");\n}\nconst { pool, close } = await createManagedPool(target, { role });\n\ntry {\n const catalog = await extractCatalog(pool);\n console.log(stringifyCatalogSnapshot(serializeCatalog(catalog)));\n} catch (e) {\n console.error(e);\n // Emit a sentinel so the CLI runner can distinguish a real script crash from a\n // successful empty catalog, even though the forced-exit non-zero code below is\n // suppressed by the "main worker has been destroyed" handling.\n console.error("PGDELTA_SCRIPT_ERROR");\n // Force close event loop\n throw new Error("");\n} finally {\n await close();\n}\n// Force close the event loop on the success path too. The connection pool can\n// leave keepalive handles registered even after close() resolves, which keeps\n// the Edge Runtime worker (and therefore the container) alive after the catalog\n// has already been written to stdout. The CLI streams this container\'s logs with\n// Follow:true, so a worker that never exits hangs the parent `__catalog`\n// subprocess — and the declarative-sync command that spawned it — indefinitely\n// at 0% CPU (supabase/pg-toolbelt#312).\nthrow new Error("");\n'; - -/** `internal/pgdelta/templates/pgdelta_declarative_apply.ts` — applies declarative files to TARGET. */ -export const legacyPgDeltaDeclarativeApplyScript = - '// This script applies declarative schema files to a target database and emits\n// structured JSON so the Go caller can report success/failure deterministically.\nimport {\n applyDeclarativeSchema,\n loadDeclarativeSchema,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20/declarative";\n\nconst schemaPath = Deno.env.get("SCHEMA_PATH");\nconst target = Deno.env.get("TARGET");\n\nif (!schemaPath) {\n throw new Error("SCHEMA_PATH is required");\n}\nif (!target) {\n throw new Error("TARGET is required");\n}\n\ntry {\n const content = await loadDeclarativeSchema(schemaPath);\n if (content.length === 0) {\n console.log(JSON.stringify({ status: "success", totalStatements: 0 }));\n } else {\n const result = await applyDeclarativeSchema({\n content,\n targetUrl: target,\n });\n const apply = result?.apply;\n if (!apply) {\n throw new Error("pg-delta apply returned no result");\n }\n const payload = {\n status: apply.status,\n totalStatements: result.totalStatements ?? 0,\n totalRounds: apply.totalRounds ?? 0,\n totalApplied: apply.totalApplied ?? 0,\n totalSkipped: apply.totalSkipped ?? 0,\n errors: apply.errors ?? [],\n stuckStatements: apply.stuckStatements ?? [],\n // validationErrors is populated when the final\n // check_function_bodies=on pass catches issues that didn\'t surface during\n // the initial apply rounds (e.g. a function body that references a\n // column whose type changed). Without surfacing this field, callers see\n // status=error with empty errors/stuckStatements and no actionable info.\n validationErrors: apply.validationErrors ?? [],\n diagnostics: result.diagnostics ?? [],\n };\n console.log(JSON.stringify(payload));\n if (apply.status !== "success") {\n throw new Error("pg-delta apply failed with status: " + apply.status);\n }\n }\n} catch (e) {\n throw e instanceof Error ? e : new Error(String(e));\n}\n// Force close the event loop on the success path. applyDeclarativeSchema opens a\n// connection to TARGET whose keepalive handles can keep the Edge Runtime worker\n// alive after the result JSON has been written, so the container never exits and\n// the CLI — which follows this container\'s logs — hangs indefinitely at 0% CPU\n// (supabase/pg-toolbelt#312). The catch above re-throws the real error, so this\n// only runs once a successful apply has been reported on stdout.\nthrow new Error("");\n'; - -/** - * The npm dist-tag/version used for `@supabase/pg-delta` when - * `supabase/.temp/pgdelta-version` (the `[experimental.pgdelta].npm_version` - * config field) is absent or empty. Mirrors Go's `DefaultPgDeltaNpmVersion` - * (`apps/cli-go/pkg/config/pgdelta_version.go:7`). - */ -export const LEGACY_DEFAULT_PG_DELTA_NPM_VERSION = "1.0.0-alpha.33"; - -/** - * The literal version baked into the embedded templates above, replaced by - * `legacyInterpolatePgDeltaScript`. Mirrors Go's `pgDeltaNpmVersionPlaceholder` - * (`apps/cli-go/pkg/config/pgdelta_version.go:9`). - */ -export const LEGACY_PG_DELTA_NPM_VERSION_PLACEHOLDER = "1.0.0-alpha.20"; - -/** - * Returns the pg-delta npm version from config, or the default when unset. - * Mirrors Go's `EffectivePgDeltaNpmVersion` - * (`apps/cli-go/pkg/config/pgdelta_version.go:13`). - */ -export function legacyEffectivePgDeltaNpmVersion(npmVersion: string | undefined): string { - const trimmed = npmVersion?.trim(); - return trimmed !== undefined && trimmed.length > 0 - ? trimmed - : LEGACY_DEFAULT_PG_DELTA_NPM_VERSION; -} - -/** - * Substitutes the pg-delta npm version placeholder in an embedded template. - * Mirrors Go's `InterpolatePgDeltaScript` - * (`apps/cli-go/pkg/config/pgdelta_version.go:26`). - */ -export function legacyInterpolatePgDeltaScript( - script: string, - npmVersion: string | undefined, -): string { - return script.replaceAll( - LEGACY_PG_DELTA_NPM_VERSION_PLACEHOLDER, - legacyEffectivePgDeltaNpmVersion(npmVersion), - ); -} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.unit.test.ts deleted file mode 100644 index c26287ee4b..0000000000 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.unit.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; - -import { - LEGACY_DEFAULT_PG_DELTA_NPM_VERSION, - LEGACY_PG_DELTA_NPM_VERSION_PLACEHOLDER, - legacyEffectivePgDeltaNpmVersion, - legacyInterpolatePgDeltaScript, - legacyPgDeltaCatalogExportScript, - legacyPgDeltaDeclarativeApplyScript, - legacyPgDeltaDeclarativeExportScript, - legacyPgDeltaDiffScript, -} from "./legacy-pgdelta.deno-templates.ts"; - -// Resolve the Go template sources relative to this file so the byte-equality -// assertion fails loudly if the embedded copies drift from upstream. -const goDiffTemplatesDir = fileURLToPath( - new URL("../../../../../../cli-go/internal/db/diff/templates/", import.meta.url), -); -const goPgDeltaTemplatesDir = fileURLToPath( - new URL("../../../../../../cli-go/internal/pgdelta/templates/", import.meta.url), -); -const readGoTemplate = (name: string) => readFileSync(`${goDiffTemplatesDir}${name}`, "utf8"); - -describe("embedded pg-delta Deno templates", () => { - it("match the Go sources byte-for-byte", () => { - expect(legacyPgDeltaDiffScript).toBe(readGoTemplate("pgdelta.ts")); - expect(legacyPgDeltaDeclarativeExportScript).toBe( - readGoTemplate("pgdelta_declarative_export.ts"), - ); - expect(legacyPgDeltaCatalogExportScript).toBe(readGoTemplate("pgdelta_catalog_export.ts")); - expect(legacyPgDeltaDeclarativeApplyScript).toBe( - readFileSync(`${goPgDeltaTemplatesDir}pgdelta_declarative_apply.ts`, "utf8"), - ); - }); - - it("pin the placeholder npm version that interpolation rewrites", () => { - expect(legacyPgDeltaDiffScript).toContain( - `npm:@supabase/pg-delta@${LEGACY_PG_DELTA_NPM_VERSION_PLACEHOLDER}`, - ); - expect(legacyPgDeltaDeclarativeExportScript).toContain( - `npm:@supabase/pg-delta@${LEGACY_PG_DELTA_NPM_VERSION_PLACEHOLDER}`, - ); - expect(legacyPgDeltaCatalogExportScript).toContain( - `npm:@supabase/pg-delta@${LEGACY_PG_DELTA_NPM_VERSION_PLACEHOLDER}`, - ); - }); -}); - -describe("legacyEffectivePgDeltaNpmVersion", () => { - it("returns the default when the version is unset, empty, or whitespace", () => { - expect(legacyEffectivePgDeltaNpmVersion(undefined)).toBe(LEGACY_DEFAULT_PG_DELTA_NPM_VERSION); - expect(legacyEffectivePgDeltaNpmVersion("")).toBe(LEGACY_DEFAULT_PG_DELTA_NPM_VERSION); - expect(legacyEffectivePgDeltaNpmVersion(" ")).toBe(LEGACY_DEFAULT_PG_DELTA_NPM_VERSION); - }); - - it("trims and returns a configured version", () => { - expect(legacyEffectivePgDeltaNpmVersion(" 1.2.3 ")).toBe("1.2.3"); - }); -}); - -describe("legacyInterpolatePgDeltaScript", () => { - it("rewrites every placeholder occurrence to the effective version", () => { - const out = legacyInterpolatePgDeltaScript(legacyPgDeltaDiffScript, "9.9.9"); - expect(out).not.toContain(`npm:@supabase/pg-delta@${LEGACY_PG_DELTA_NPM_VERSION_PLACEHOLDER}`); - expect(out).toContain("npm:@supabase/pg-delta@9.9.9"); - expect(out).toContain("npm:@supabase/pg-delta@9.9.9/integrations/supabase"); - }); - - it("rewrites to the default version when unset", () => { - const out = legacyInterpolatePgDeltaScript(legacyPgDeltaCatalogExportScript, undefined); - expect(out).toContain(`npm:@supabase/pg-delta@${LEGACY_DEFAULT_PG_DELTA_NPM_VERSION}`); - }); -}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.ts index 798efd2980..e3953372b0 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.ts @@ -6,33 +6,6 @@ import { ErrorActionabilityId, } from "../../../../shared/telemetry/error-actionability.ts"; -/** - * The pg-delta edge-runtime script failed. Byte-matches Go's - * `": :\n"` wrapping in `RunEdgeRuntimeScript` - * (`apps/cli-go/internal/utils/edgeruntime.go`), where `errPrefix` is e.g. - * `"error diffing schema"` / `"error exporting declarative schema"` / - * `"error exporting pg-delta catalog"`. - */ -export class LegacyDeclarativeEdgeRuntimeError extends Data.TaggedError( - "LegacyDeclarativeEdgeRuntimeError", -)<{ - readonly message: string; - readonly docker?: "daemon" | "inspect" | "pull"; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - if (this.docker === "daemon") { - return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; - } - if (this.docker === "pull") { - return { ...actionability.externalNetwork, fingerprint_suffix: "registry_pull" }; - } - if (this.docker === "inspect") { - return { ...actionability.invalidConfig, fingerprint_suffix: "image_inspect" }; - } - return actionability.dbFinding; - } -} - /** * Setting up / connecting to / migrating the throwaway shadow database failed. * Wraps the errors from `CreateShadowDatabase` / `ConnectShadowDatabase` / @@ -56,50 +29,6 @@ export class LegacyDeclarativeShadowDbError extends Data.TaggedError( } } -/** - * Exporting declarative schema produced no output. Byte-matches Go's - * `"error exporting declarative schema: edge-runtime script produced no output:\n"` - * and the catalog variant `"error exporting pg-delta catalog: edge-runtime script - * produced no output:\n"` (`apps/cli-go/internal/db/diff/pgdelta.go:188,222`). - */ -export class LegacyDeclarativeEmptyOutputError extends Data.TaggedError( - "LegacyDeclarativeEmptyOutputError", -)<{ - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.impossibleState; - } -} - -/** - * Parsing the declarative export envelope failed. Byte-matches Go's - * `"failed to parse declarative export output: " + err` - * (`apps/cli-go/internal/db/diff/pgdelta.go:192`). - */ -export class LegacyDeclarativeParseOutputError extends Data.TaggedError( - "LegacyDeclarativeParseOutputError", -)<{ - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.impossibleState; - } -} - -/** - * Parsing the pg-delta diff envelope failed. Byte-matches Go's - * `"failed to parse pg-delta diff output: " + err + ":\n" + stderr` - * (`apps/cli-go/internal/db/diff/pgdelta.go`, `parsePgDeltaDiffOutput`). - */ -export class LegacyPgDeltaDiffParseError extends Data.TaggedError("LegacyPgDeltaDiffParseError")<{ - readonly message: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.impossibleState; - } -} - /** * Materializing the declarative export on disk failed. Byte-matches Go's * `WriteDeclarativeSchemas` errors (`declarative.go:239`): diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.unit.test.ts index a0158f4d43..01da35ea63 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.unit.test.ts @@ -1,33 +1,8 @@ import { describe, expect, it } from "vitest"; import { classifyCliErrorActionability } from "../../../../shared/telemetry/error-actionability.ts"; -import { - LegacyDeclarativeEdgeRuntimeError, - LegacyDeclarativeShadowDbError, -} from "./legacy-pgdelta.errors.ts"; +import { LegacyDeclarativeShadowDbError } from "./legacy-pgdelta.errors.ts"; describe("pg-delta error actionability", () => { - it.each([ - ["daemon", "user_actionable", "docker_not_running", "docker_not_running"], - ["pull", "external_service", "network", "registry_pull"], - ["inspect", "user_actionable", "invalid_config", "image_inspect"], - ] as const)("classifies edge-runtime docker %s failures", (docker, kind, category, suffix) => { - const result = classifyCliErrorActionability( - new LegacyDeclarativeEdgeRuntimeError({ message: "redacted", docker }), - ); - expect(result.error_kind).toBe(kind); - expect(result.error_category).toBe(category); - expect(result.error_fingerprint).toBe(`tag:LegacyDeclarativeEdgeRuntimeError:${suffix}`); - }); - - it("keeps non-docker edge-runtime failures in the database family", () => { - const result = classifyCliErrorActionability( - new LegacyDeclarativeEdgeRuntimeError({ message: "redacted" }), - ); - expect(result.error_kind).toBe("user_actionable"); - expect(result.error_category).toBe("invalid_config"); - expect(result.error_fingerprint).toBe("tag:LegacyDeclarativeEdgeRuntimeError"); - }); - it("distinguishes an unreachable Docker daemon from a missing shadow stack", () => { const daemon = classifyCliErrorActionability( new LegacyDeclarativeShadowDbError({ message: "redacted", docker: "daemon" }), diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.integration.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.integration.test.ts index 3132d010a5..15e3e039e3 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.integration.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; @@ -38,11 +38,8 @@ import { LegacyDeclarativeSeam } from "./legacy-pgdelta.seam.service.ts"; * Integration coverage for the fully-native `legacyDeclarativeSeamLayer` (CLI-1970) — * `generate`/`sync`'s own integration tests stub `LegacyDeclarativeSeam` entirely * (per its own service doc comment), so this file is the only place the real - * shadow-provisioning composition (`legacy-pgdelta.cache.ts`'s - * `legacyExportBaselineCatalogRef`/`legacyExportDeclarativeCatalogRef`) gets - * exercised end-to-end. Mirrors `declarative.orchestrate.integration.test.ts`'s - * real-shadow-stack pattern (`mockLegacyShadowContainerCliSpawner` + a fake - * `LegacyDbConnection`/`LegacyDockerRun`/`LegacyEdgeRuntimeScript`). + * local-database bring-up composition gets exercised end-to-end, with a fake + * `LegacyDbConnection`/`LegacyDockerRun`/`LegacyEdgeRuntimeScript`. */ const alwaysReadyHttpClientLayer = Layer.succeed( @@ -178,71 +175,6 @@ function setup( const failError = (exit: Exit.Exit) => Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isFailReason)?.error : undefined; -describe("legacyDeclarativeSeamLayer.exportCatalog", () => { - it.effect( - "provisions a shadow on a baseline cache miss, then reuses the cached catalog with no further container work", - () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-pgdelta-seam-")); - const { layer, out, shadowSpawned } = setup(dir); - return Effect.gen(function* () { - const seam = yield* LegacyDeclarativeSeam; - - const firstRef = yield* seam.exportCatalog({ mode: "baseline", noCache: false }); - expect(firstRef).toMatch(/^supabase[/\\]\.temp[/\\]pgdelta[/\\]catalog-baseline-.*\.json$/); - expect(readFileSync(join(dir, firstRef), "utf8")).toBe('{"schemas":[]}'); - expect(out.stderrText).toContain("Creating shadow database...\n"); - expect(shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); - expect(shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); - - // Cache hit: same ref, zero additional container work. - const secondRef = yield* seam.exportCatalog({ mode: "baseline", noCache: false }); - expect(secondRef).toBe(firstRef); - expect(shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); - expect(shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); - - rmSync(dir, { recursive: true, force: true }); - }).pipe(Effect.provide(layer)); - }, - ); - - it.effect( - "writes catalog-nocache-declarative.json on --no-cache, applying the declarative directory first", - () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-pgdelta-seam-")); - const declDir = join(dir, "supabase", "schemas"); - mkdirSync(declDir, { recursive: true }); - writeFileSync(join(declDir, "public.sql"), "create table t ();"); - const { layer, edgeCalls, shadowSpawned } = setup(dir); - return Effect.gen(function* () { - const seam = yield* LegacyDeclarativeSeam; - const ref = yield* seam.exportCatalog({ mode: "declarative", noCache: true }); - expect(ref).toBe(join("supabase", ".temp", "pgdelta", "catalog-nocache-declarative.json")); - expect(readFileSync(join(dir, ref), "utf8")).toBe('{"schemas":[]}'); - expect(edgeCalls.some((c) => c.errPrefix === "error running pg-delta script")).toBe(true); - expect(shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); - expect(shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); - rmSync(dir, { recursive: true, force: true }); - }).pipe(Effect.provide(layer)); - }, - ); - - it.effect("maps a shadow-provisioning failure to LegacyDeclarativeShadowDbError", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-pgdelta-seam-")); - const { layer } = setup(dir, { failCreate: true }); - return Effect.gen(function* () { - const seam = yield* LegacyDeclarativeSeam; - const exit = yield* seam.exportCatalog({ mode: "baseline", noCache: true }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - const error = failError(exit); - expect(error).toBeInstanceOf(LegacyDeclarativeShadowDbError); - expect((error as LegacyDeclarativeShadowDbError).message).toContain( - "failed to provision the shadow database:", - ); - rmSync(dir, { recursive: true, force: true }); - }).pipe(Effect.provide(layer)); - }); -}); - describe("legacyDeclarativeSeamLayer.ensureLocalDatabaseStarted", () => { it.effect( "carries the inspect failure's daemon marker AND recovery suggestion onto the seam error", diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts index f4be9856ba..3bf559415c 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts @@ -9,10 +9,6 @@ import { legacyGetRegistryImageUrl } from "../../../shared/legacy-docker-registr import { legacyIsDockerDaemonUnreachable } from "../../../shared/legacy-docker-suggest.ts"; import { legacyIsLocalDbRunning } from "../../../shared/db-bootstrap/local-db-running.ts"; import { legacyStartLocalDatabase } from "../../../shared/db-bootstrap/start-local-database.ts"; -import { - legacyExportBaselineCatalogRef, - legacyExportDeclarativeCatalogRef, -} from "../../../shared/legacy-pgdelta.cache.ts"; import { legacyResolveLocalProjectId, localDbContainerId, @@ -29,8 +25,7 @@ const legacyShadowDockerCause = ( * Whether an underlying failure signals the Docker daemon is unreachable, across every tagged * error class this seam composes over — `LegacyShadowDbError.reason === "docker_daemon"`, * `LegacyImagePrepullError.reason === "docker_daemon"`, `LegacyLocalDbRunningError.daemonDown`, - * `LegacyPgDeltaDeclarativeApplyError.reason === "daemon"`, and every `*.docker === "daemon"` - * field (`LegacyDeclarativeEdgeRuntimeError`, …). Checked structurally rather than per-tag so a + * and every `*.docker === "daemon"` field. Checked structurally rather than per-tag so a * new error class in the union doesn't silently drop its own daemon signal. */ function legacyHasDaemonSignal(cause: { @@ -48,13 +43,10 @@ function legacyHasDaemonSignal(cause: { } /** - * Maps any failure from the native shadow-provisioning stack (`legacy-pgdelta.cache.ts`'s - * `legacyExportBaselineCatalogRef`/`legacyExportDeclarativeCatalogRef`, and everything they - * compose — shadow create/setup, health checks, the pg-delta edge-runtime scripts, the - * declarative-apply engine, config loading) into the seam's own - * {@link LegacyDeclarativeShadowDbError}, carrying the underlying message. Every component error - * class in that stack declares `message: string`, so this accepts the whole union structurally - * rather than enumerating each tag. + * Maps any failure from the native local-database bring-up stack (shadow create/setup, health + * checks, config loading) into the seam's own {@link LegacyDeclarativeShadowDbError}, carrying + * the underlying message. Every component error class in that stack declares `message: string`, + * so this accepts the whole union structurally rather than enumerating each tag. */ export const legacyToShadowDbError = (cause: { readonly message: string; @@ -70,11 +62,9 @@ export const legacyToShadowDbError = (cause: { }); /** - * Real `LegacyDeclarativeSeam`: fully native. `exportCatalog` composes the shadow-database - * platform-baseline/declarative catalog export (`legacy-pgdelta.cache.ts`'s - * `legacyExportBaselineCatalogRef`/`legacyExportDeclarativeCatalogRef`); `ensureLocalDatabaseStarted` - * shares the same `legacyStartLocalDatabase` bring-up `db start` uses; - * `ensureLocalPostgresImageCurrent` was already native (CLI-1956) and is unchanged here. + * Real `LegacyDeclarativeSeam`: fully native. `ensureLocalDatabaseStarted` shares the same + * `legacyStartLocalDatabase` bring-up `db start` uses; `ensureLocalPostgresImageCurrent` was + * already native (CLI-1956) and is unchanged here. */ export const legacyDeclarativeSeamLayer = Layer.effect( LegacyDeclarativeSeam, @@ -83,42 +73,16 @@ export const legacyDeclarativeSeamLayer = Layer.effect( const spawner = yield* ChildProcessSpawner; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - // Captures every OTHER service `legacyExportBaselineCatalogRef`/ - // `legacyExportDeclarativeCatalogRef`/`legacyStartLocalDatabase` need internally (Output, - // RuntimeInfo, HttpClient, LegacyDbConnection, LegacyEdgeRuntimeScript, LegacyDockerRun, - // LegacyPgDeltaSslProbe, LegacyNetworkIdFlag, the `--experimental`/CliArgs global-flag - // machinery, …) into a plain `Context` so each closure below can `Effect.provideContext` it - // and satisfy `LegacyDeclarativeSeamShape`'s `Effect` (no leftover requirements) - // without hand-enumerating every transitive dependency — mirrors - // `legacy-platform-api-factory.layer.ts`'s identical capture-and-provide shape. - // `legacyExportDeclarativeCatalogRef`'s requirements are today identical to - // `LegacyExportBaselineCatalogDeps`; if they ever diverge, the declarative - // closure below stops typechecking and the new deps must be added here. - const context = yield* Effect.context< - LegacyExportBaselineCatalogDeps | LegacyStartLocalDatabaseDeps - >(); + // Captures every OTHER service `legacyStartLocalDatabase` needs internally (Output, + // RuntimeInfo, HttpClient, LegacyDbConnection, LegacyDockerRun, LegacyNetworkIdFlag, the + // `--experimental`/CliArgs global-flag machinery, …) into a plain `Context` so each closure + // below can `Effect.provideContext` it and satisfy `LegacyDeclarativeSeamShape`'s + // `Effect` (no leftover requirements) without hand-enumerating every transitive + // dependency — mirrors `legacy-platform-api-factory.layer.ts`'s identical + // capture-and-provide shape. + const context = yield* Effect.context(); return LegacyDeclarativeSeam.of({ - exportCatalog: ({ mode, noCache, projectRef }) => - (mode === "baseline" - ? legacyExportBaselineCatalogRef(fs, path, cliSettings.workdir, cliSettings.projectId, { - noCache, - projectRef, - }) - : legacyExportDeclarativeCatalogRef( - fs, - path, - cliSettings.workdir, - cliSettings.projectId, - { - noCache, - projectRef, - }, - ) - ).pipe( - Effect.provideContext(context), - Effect.catch((cause) => Effect.fail(legacyToShadowDbError(cause))), - ), ensureLocalDatabaseStarted: () => Effect.gen(function* () { const running = yield* legacyIsLocalDbRunning( @@ -276,15 +240,6 @@ export const legacyDeclarativeSeamLayer = Layer.effect( }), ); -type LegacyExportBaselineCatalogDeps = - ReturnType extends Effect.Effect< - infer _A, - infer _E, - infer R - > - ? R - : never; - type LegacyStartLocalDatabaseDeps = ReturnType extends Effect.Effect ? R diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts index 87afbaf407..2e48fb1c42 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts @@ -2,33 +2,7 @@ import { Context, type Effect } from "effect"; import type { LegacyDeclarativeShadowDbError } from "./legacy-pgdelta.errors.ts"; -/** - * Which shadow-database catalog `exportCatalog` should produce: the Supabase platform baseline - * (auth/storage/realtime) with nothing else applied, or that same baseline with the declarative - * directory applied on top. Local migrations never go through this seam — `db diff`'s explicit - * `--from/--to migrations` and `db schema declarative sync`'s migrations-catalog diff source both - * resolve their own shadow natively (`legacy-pgdelta.cache.ts`'s `legacyResolveMigrationsCatalogRef` - * and `legacyGetMigrationsCatalogRef`). - */ -export type LegacyCatalogMode = "baseline" | "declarative"; - interface LegacyDeclarativeSeamShape { - /** - * Provisions a shadow database with the Supabase platform baseline (and, for `declarative`, - * applies the declarative directory on top), exports its pg-delta catalog, and returns the - * workdir-relative path of the persisted snapshot (cached under `supabase/.temp/pgdelta/`). - * Progress ("Creating shadow database...") is written to stderr. - */ - readonly exportCatalog: (opts: { - readonly mode: LegacyCatalogMode; - readonly noCache: boolean; - /** - * Resolved linked project ref for `generate --linked`: the config read this builds the - * baseline/declarative catalog from merges the matching `[remotes.]` override when - * set. Absent → base config only. - */ - readonly projectRef?: string; - }) => Effect.Effect; /** * For the `--local` declarative paths: when the local Postgres container is not already * running, starts it (the same DB-only bring-up `db start` uses) so 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 a01b105325..5266f39dfd 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 @@ -3,8 +3,7 @@ * `PrepareShadowSource`/`PrepareRawShadow` (`apps/cli-go/internal/db/diff/shadow.go`), built * on top of `shared/db-bootstrap/shadow-database.ts`'s lower-level primitives plus the * `--target-local` declarative-schema branch (Go's `loadDeclaredSchemas`/ - * `shouldApplyDeclarativeWithPgDelta`/`migrateBaseDatabase`, `internal/db/diff/diff.go:52-115, - * 261-274`) and pg-delta's declarative apply engine (`legacy-pgdelta.apply.ts`). + * `migrateBaseDatabase`, `internal/db/diff/diff.go:52-115, 261-274`). * * Go's `PrepareShadowSource(ctx, schema []string, targetLocal, usePgDelta bool, fsys, * options...)` takes a `schema` parameter that is NEVER referenced anywhere in the function @@ -55,12 +54,7 @@ import { type LegacyShadowSourceResult, } from "../../../shared/db-bootstrap/shadow-database.ts"; import type { LegacyStartSetupLocalDatabaseError } from "../../../shared/db-bootstrap/db-setup.ts"; -import { - LegacyPgDeltaDeclarativeApplyError, - legacyApplyDeclarativePgDelta, -} from "./legacy-pgdelta.apply.ts"; import { LegacyDeclarativeShadowDbError } from "./legacy-pgdelta.errors.ts"; -import type { LegacyPgDeltaContext } from "../../../shared/legacy-pgdelta.ts"; type Spawner = ChildProcessSpawner["Service"]; @@ -73,15 +67,11 @@ export type { LegacyShadowSourceResult }; 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`. */ - readonly usePgDelta: boolean; - /** Selects the shadow baseline and whether a local target may use the legacy declarative override. */ + /** Selects the shadow baseline and whether a local target may use the migra declarative override. */ readonly migrationMode?: "legacy" | "pgdelta-next"; /** `db.migrations.schema_paths`, RAW (unresolved) — Go's `Config.Db.Migrations.SchemaPaths` pre-`config.go:976-979`-resolution form. */ readonly schemaPaths: ReadonlyArray; readonly pgDelta: LegacyPgDeltaTomlConfig; - /** Ambient pg-delta edge-runtime context, only read on the pg-delta declarative-apply sub-branch. */ - readonly ctx: LegacyPgDeltaContext; } /** Every failure {@link legacyPrepareShadowSource} can produce, beyond its own `E` (JWKS resolution). */ @@ -90,8 +80,7 @@ export type LegacyPrepareShadowSourceError = | LegacyDeclarativeShadowDbError | LegacyHealthCheckTimeoutError | LegacyStartSetupLocalDatabaseError - | LegacyImagePrepullError - | LegacyPgDeltaDeclarativeApplyError; + | LegacyImagePrepullError; /** * Port of Go's `PrepareShadowSource` (`apps/cli-go/internal/db/diff/shadow.go:37-91`): @@ -198,41 +187,13 @@ export const legacyPrepareShadowSource = ( ); if (declared.length > 0) { const overrideConn: LegacyPgConnInput = { ...connConfig, database: "contrib_regression" }; - const useDeclarativePgDelta = legacyShouldApplyDeclarativeWithPgDelta( + yield* legacyMigrateBaseDatabase( + input.fs, input.path, - input.usePgDelta, - input.schemaPaths, - input.pgDelta, + input.workdir, + overrideConn, + declared, ); - let appliedViaPgDelta = false; - if (useDeclarativePgDelta) { - const declDirRel = legacyResolveDeclarativeDir(input.path, input.pgDelta); - const declDirAbs = legacyResolveUnderWorkdir(input.path, input.workdir, declDirRel); - // Go's `afero.DirExists` (`shadow.go:72`) — a non-directory path is treated as - // absent here too, same reasoning as `legacyLoadDeclaredSchemas` below. - const declDirExists = yield* input.fs.stat(declDirAbs).pipe( - Effect.map((info) => info.type === "Directory"), - Effect.orElseSucceed(() => false), - ); - if (declDirExists) { - yield* legacyApplyDeclarativePgDelta(input.ctx, { - fs: input.fs, - declarativeDirAbs: declDirAbs, - declarativeDirRel: declDirRel, - target: legacyToPostgresURL(overrideConn), - }); - appliedViaPgDelta = true; - } - } - if (!appliedViaPgDelta) { - yield* legacyMigrateBaseDatabase( - input.fs, - input.path, - input.workdir, - overrideConn, - declared, - ); - } targetUrlOverride = legacyToPostgresURL(overrideConn); } } @@ -595,31 +556,6 @@ export function legacyCleanSchemaPath( return volume + (isAbsolute ? "/" : "") + joined; } -/** - * Port of Go's `shouldApplyDeclarativeWithPgDelta` (`apps/cli-go/internal/db/diff/diff.go: - * 103-115`): `usePgDelta` false -> false; zero `schema_paths` -> true; more than one - * `schema_paths` entry -> false; exactly one entry -> true only when it resolves (Go's - * `config.go:976-979` resolution, matching `legacyResolveSeedSqlPath`) to the SAME cleaned - * path as the effective declarative dir. - */ -export function legacyShouldApplyDeclarativeWithPgDelta( - path: Path.Path, - usePgDelta: boolean, - schemaPaths: ReadonlyArray, - pgDelta: LegacyPgDeltaTomlConfig, - platform: NodeJS.Platform = process.platform, -): boolean { - if (!usePgDelta) return false; - if (schemaPaths.length === 0) return true; - if (schemaPaths.length !== 1) return false; - const resolvedSchema = legacyCleanSchemaPath( - legacyResolveSeedSqlPath(path, schemaPaths[0]!), - platform, - ); - const declDir = legacyCleanSchemaPath(legacyResolveDeclarativeDir(path, pgDelta), platform); - return resolvedSchema === declDir; -} - /** * Port of Go's `migrateBaseDatabase` (`apps/cli-go/internal/db/diff/diff.go:261-274`): prints * the declarative-schema file list, connects to `config` (the shadow's `contrib_regression` diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.unit.test.ts index 4bc35fd47f..49d25b85e3 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.unit.test.ts @@ -5,11 +5,7 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, FileSystem, Layer, Option, Path, PlatformError } from "effect"; -import { - legacyCleanSchemaPath, - legacyLoadDeclaredSchemas, - legacyShouldApplyDeclarativeWithPgDelta, -} from "./legacy-shadow-source.ts"; +import { legacyCleanSchemaPath, legacyLoadDeclaredSchemas } from "./legacy-shadow-source.ts"; import type { LegacyPgDeltaTomlConfig } from "../../../shared/legacy-db-config.toml-read.ts"; function pgDelta(overrides: Partial = {}): LegacyPgDeltaTomlConfig { @@ -17,7 +13,6 @@ function pgDelta(overrides: Partial = {}): LegacyPgDelt enabled: false, declarativeSchemaPath: Option.none(), formatOptions: Option.none(), - npmVersion: Option.none(), ...overrides, }; } @@ -29,90 +24,6 @@ function makeWorkdir(): string { // Root bypasses POSIX permission bits, so chmod 000 wouldn't block readdir() there. const isRoot = typeof process.getuid === "function" && process.getuid() === 0; -describe("legacyShouldApplyDeclarativeWithPgDelta", () => { - it.effect("is false whenever usePgDelta is false, regardless of schema_paths", () => - Effect.gen(function* () { - const path = yield* Path.Path; - expect(legacyShouldApplyDeclarativeWithPgDelta(path, false, [], pgDelta())).toBe(false); - expect( - legacyShouldApplyDeclarativeWithPgDelta(path, false, ["schemas/x.sql"], pgDelta()), - ).toBe(false); - }).pipe(Effect.provide(BunServices.layer)), - ); - - it.effect("is true when usePgDelta and zero schema_paths are configured", () => - Effect.gen(function* () { - const path = yield* Path.Path; - expect(legacyShouldApplyDeclarativeWithPgDelta(path, true, [], pgDelta())).toBe(true); - }).pipe(Effect.provide(BunServices.layer)), - ); - - it.effect("is false when more than one schema_paths entry is configured", () => - Effect.gen(function* () { - const path = yield* Path.Path; - expect( - legacyShouldApplyDeclarativeWithPgDelta(path, true, ["a.sql", "b.sql"], pgDelta()), - ).toBe(false); - }).pipe(Effect.provide(BunServices.layer)), - ); - - it.effect( - "is true when exactly one schema_paths entry resolves to the effective declarative dir", - () => - Effect.gen(function* () { - const path = yield* Path.Path; - expect(legacyShouldApplyDeclarativeWithPgDelta(path, true, ["schemas"], pgDelta())).toBe( - true, - ); - }).pipe(Effect.provide(BunServices.layer)), - ); - - it.effect("is false when the single schema_paths entry does not match the declarative dir", () => - Effect.gen(function* () { - const path = yield* Path.Path; - expect(legacyShouldApplyDeclarativeWithPgDelta(path, true, ["database"], pgDelta())).toBe( - false, - ); - }).pipe(Effect.provide(BunServices.layer)), - ); - - it.effect("matches a configured (non-default) declarative_schema_path the same way", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const configured = pgDelta({ declarativeSchemaPath: Option.some("supabase/custom-decl") }); - expect(legacyShouldApplyDeclarativeWithPgDelta(path, true, ["custom-decl"], configured)).toBe( - true, - ); - }).pipe(Effect.provide(BunServices.layer)), - ); - - it.effect( - "on POSIX, a backslash in schema_paths is a literal character, not a path separator", - () => - Effect.gen(function* () { - const path = yield* Path.Path; - // Go's `filepath.Clean`/`ToSlash` only treat `\` as a separator on a Windows build — - // on darwin/linux it's untouched, so a `foo\bar` schema_paths entry (which - // `legacyResolveSeedSqlPath` joins under `supabase/` unresolved) must NOT be treated - // as equivalent to the slash-separated declarative dir `supabase/foo/bar`. - const configured = pgDelta({ declarativeSchemaPath: Option.some("supabase/foo/bar") }); - expect( - legacyShouldApplyDeclarativeWithPgDelta(path, true, ["foo\\bar"], configured, "darwin"), - ).toBe(false); - }).pipe(Effect.provide(BunServices.layer)), - ); - - it.effect("on win32, a backslash in schema_paths normalizes as a path separator", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const configured = pgDelta({ declarativeSchemaPath: Option.some("supabase/foo/bar") }); - expect( - legacyShouldApplyDeclarativeWithPgDelta(path, true, ["foo\\bar"], configured, "win32"), - ).toBe(true); - }).pipe(Effect.provide(BunServices.layer)), - ); -}); - describe("legacyCleanSchemaPath", () => { // Go's `filepath.Clean` (windows build) never cleans INTO a leading UNC volume — verified // empirically against a standalone extraction of Go's own windows `internal/filepathlite` diff --git a/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md index 1cb7f5c644..17de064eef 100644 --- a/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/start/SIDE_EFFECTS.md @@ -72,13 +72,12 @@ volume was confirmed fresh this run). ## Files Written -| Path | Format | When | -| ---------------------------------------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/.branches/_current_branch` | text | only if absent — writes `"main"` (see the step-by-step sequence above for exactly when) | -| `/supabase/.temp/pgdelta/catalog-local-migrations--.json` | JSON | best-effort, on a fresh volume with no `--from-backup`, after `MigrateAndSeed`, when pg-delta is enabled (`[experimental.pgdelta] enabled` or `SUPABASE_EXPERIMENTAL_PG_DELTA`) AND the legacy engine is selected (`SUPABASE_USE_PG_DELTA_NEXT=false`); the default next engine skips this warmup entirely; a failure only warns on stderr and never fails `db start` | -| local Docker volume `supabase_db_` | — | the Postgres data volume, created on first start (or first `--from-backup` restore) | -| local Docker network `supabase_network_` (or `--network-id`) | — | created if it doesn't already exist | -| `~/.supabase/telemetry.json` | JSON | always — telemetry flush (`Effect.ensuring(telemetryState.flush)`), success and failure | +| Path | Format | When | +| --------------------------------------------------------------------- | ------ | --------------------------------------------------------------------------------------- | +| `/supabase/.branches/_current_branch` | text | only if absent — writes `"main"` (see the step-by-step sequence above for exactly when) | +| local Docker volume `supabase_db_` | — | the Postgres data volume, created on first start (or first `--from-backup` restore) | +| local Docker network `supabase_network_` (or `--network-id`) | — | created if it doesn't already exist | +| `~/.supabase/telemetry.json` | JSON | always — telemetry flush (`Effect.ensuring(telemetryState.flush)`), success and failure | ## Subprocesses @@ -106,26 +105,24 @@ native container command in this codebase — never `supabase-go`. ## Environment Variables -| Variable | Purpose | Required? | -| -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | -| `SUPABASE_PROJECT_ID` | overrides the local container id | no | -| `SUPABASE_DB_PORT` | overrides `db.port` (the published host port) | no | -| `SUPABASE_DB_MAJOR_VERSION` | overrides `db.major_version` (image selection, schema branch) | no | -| `SUPABASE_DB_HEALTH_TIMEOUT` | overrides `db.health_timeout` | no | -| `SUPABASE_DB_SETTINGS_*` | overrides individual `[db.settings]` fields | no | -| `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION` | overrides `experimental.orioledb_version` (image + env) | no | -| `SUPABASE_EXPERIMENTAL_S3_{HOST,REGION,ACCESS_KEY,SECRET_KEY}` | OrioleDB S3 env overrides | no | -| `SUPABASE_REALTIME_ENABLED` | gates the fresh-volume realtime migrate job | no | -| `SUPABASE_REALTIME_IP_VERSION` / `_MAX_HEADER_LENGTH` | realtime migrate job env overrides | no | -| `SUPABASE_STORAGE_ENABLED` | gates the fresh-volume storage migrate job | no | -| `SUPABASE_STORAGE_FILE_SIZE_LIMIT` | storage migrate job env override | no | -| `SUPABASE_AUTH_ENABLED` | gates the fresh-volume auth migrate job | no | -| `SUPABASE_AUTH_EXTERNAL_URL` / `SUPABASE_AUTH_SITE_URL` | auth migrate job env overrides | no | -| `SUPABASE_AUTH_JWT_EXPIRY` | Postgres's `JWT_EXP` env / signing | no | -| `SUPABASE_EXPERIMENTAL` (or `--experimental`) | fresh volume + no pg-delta: applies `db.migrations.schema_paths` files instead of `migrations/*.sql` | no | -| `SUPABASE_EXPERIMENTAL_PG_DELTA` | enables the post-`MigrateAndSeed` migrations-catalog cache warmup when `[experimental.pgdelta].enabled` is unset | no | -| `SUPABASE_USE_PG_DELTA_NEXT` | selects the pg-delta implementation; `false` selects the legacy edge-runtime engine and thereby restores the migrations-catalog cache warmup (unset/unrecognized defaults to the next engine, which skips it) | no | -| `DOCKER_HOST` / `DOCKER_CONTEXT` / `DOCKER_TLS_VERIFY` / `DOCKER_CERT_PATH` / `DOCKER_API_VERSION` / `DOCKER_CONFIG` | Read (ambient shell OR a project `.env`/`.env.`/`.env.local` file, installed into the process environment before any Docker work) to pick the Docker daemon this whole command talks to | no | +| Variable | Purpose | Required? | +| -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | +| `SUPABASE_PROJECT_ID` | overrides the local container id | no | +| `SUPABASE_DB_PORT` | overrides `db.port` (the published host port) | no | +| `SUPABASE_DB_MAJOR_VERSION` | overrides `db.major_version` (image selection, schema branch) | no | +| `SUPABASE_DB_HEALTH_TIMEOUT` | overrides `db.health_timeout` | no | +| `SUPABASE_DB_SETTINGS_*` | overrides individual `[db.settings]` fields | no | +| `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION` | overrides `experimental.orioledb_version` (image + env) | no | +| `SUPABASE_EXPERIMENTAL_S3_{HOST,REGION,ACCESS_KEY,SECRET_KEY}` | OrioleDB S3 env overrides | no | +| `SUPABASE_REALTIME_ENABLED` | gates the fresh-volume realtime migrate job | no | +| `SUPABASE_REALTIME_IP_VERSION` / `_MAX_HEADER_LENGTH` | realtime migrate job env overrides | no | +| `SUPABASE_STORAGE_ENABLED` | gates the fresh-volume storage migrate job | no | +| `SUPABASE_STORAGE_FILE_SIZE_LIMIT` | storage migrate job env override | no | +| `SUPABASE_AUTH_ENABLED` | gates the fresh-volume auth migrate job | no | +| `SUPABASE_AUTH_EXTERNAL_URL` / `SUPABASE_AUTH_SITE_URL` | auth migrate job env overrides | no | +| `SUPABASE_AUTH_JWT_EXPIRY` | Postgres's `JWT_EXP` env / signing | no | +| `SUPABASE_EXPERIMENTAL` (or `--experimental`) | fresh volume + no pg-delta: applies `db.migrations.schema_paths` files instead of `migrations/*.sql` | no | +| `DOCKER_HOST` / `DOCKER_CONTEXT` / `DOCKER_TLS_VERIFY` / `DOCKER_CERT_PATH` / `DOCKER_API_VERSION` / `DOCKER_CONFIG` | Read (ambient shell OR a project `.env`/`.env.`/`.env.local` file, installed into the process environment before any Docker work) to pick the Docker daemon this whole command talks to | no | `--network-id` (a global CLI flag, not an environment variable — `shared/legacy/global-flags.ts`) forces every created container/network onto that Docker network instead of the generated diff --git a/apps/cli/src/legacy/commands/db/start/start.integration.test.ts b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts index fafda720a6..a6ad5dc3ac 100644 --- a/apps/cli/src/legacy/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; @@ -33,11 +33,7 @@ import { type LegacyDbSession, } from "../../../shared/legacy-db-connection.service.ts"; import { legacyDockerRunLayer } from "../../../shared/legacy-docker-run.layer.ts"; -import { LegacyEdgeRuntimeScriptError } from "../../../shared/legacy-edge-runtime-script.errors.ts"; -import { - LegacyEdgeRuntimeScript, - type LegacyEdgeRuntimeRunOpts, -} from "../../../shared/legacy-edge-runtime-script.service.ts"; +import { LegacyEdgeRuntimeScript } from "../../../shared/legacy-edge-runtime-script.service.ts"; import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; import { legacyDbStart } from "./start.handler.ts"; import type { LegacyDbStartFlags } from "./start.command.ts"; @@ -280,10 +276,6 @@ interface SetupOpts { readonly experimental?: boolean; /** `--debug`. Defaults to `false`. */ readonly debug?: boolean; - /** `LegacyEdgeRuntimeScript`'s mocked stdout for the pg-delta catalog-export call (`db-setup.ts`'s `legacyTryCacheMigrationsCatalog`). Only ever reached on a fresh volume with pg-delta enabled. */ - readonly catalogStdout?: string; - /** Fails the mocked catalog-export call with this message instead of succeeding. */ - readonly catalogExportFailWith?: string; /** Number of initial `LegacyDbConnection.connect` attempts that fail before succeeding. */ readonly connectFailures?: number; /** Whether the mocked connect failures are dial-level (`retryable`). Defaults to `true`. */ @@ -310,17 +302,8 @@ function setup(opts: SetupOpts = {}) { : baseRoute; const child = mockContainerCliSpawner(route); const dbSession = fakeDbSession(); - const edgeRunCalls: Array = []; const edgeRuntime = Layer.succeed(LegacyEdgeRuntimeScript, { - run: (runOpts: LegacyEdgeRuntimeRunOpts) => { - edgeRunCalls.push(runOpts); - if (opts.catalogExportFailWith !== undefined) { - return Effect.fail( - new LegacyEdgeRuntimeScriptError({ message: opts.catalogExportFailWith }), - ); - } - return Effect.succeed({ stdout: opts.catalogStdout ?? '{"version":1}', stderr: "" }); - }, + run: () => Effect.succeed({ stdout: '{"version":1}', stderr: "" }), }); const sslProbe = Layer.succeed(LegacyPgDeltaSslProbe, { requireSsl: () => Effect.succeed(false), @@ -376,7 +359,6 @@ function setup(opts: SetupOpts = {}) { telemetry, child, dbSession, - edgeRunCalls, get connectAttempts() { return connectAttempts; }, @@ -534,64 +516,6 @@ describe("legacy db start", () => { }, ); - it.live( - "caches the migrations catalog after a fresh-volume setup with the legacy pg-delta engine", - () => { - const { layer, out, edgeRunCalls } = setup({ - configContents: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', - projectEnvContents: "SUPABASE_USE_PG_DELTA_NEXT=false\n", - route: freshVolumeRoute(defaultRoute()), - catalogStdout: '{"snapshot":"ok"}', - }); - return Effect.gen(function* () { - yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - // Runs once, AFTER the fresh-volume migrate+seed pipeline — the - // catalog cache runs immediately after the migrate-and-seed step. - expect(edgeRunCalls).toHaveLength(1); - const tempDir = join(tempRoot.current, "supabase", ".temp", "pgdelta"); - const catalogFiles = readdirSync(tempDir).filter((name) => - name.startsWith("catalog-local-migrations-"), - ); - expect(catalogFiles).toHaveLength(1); - expect(readFileSync(join(tempDir, catalogFiles[0]!), "utf8")).toBe('{"snapshot":"ok"}'); - }); - }, - ); - - it.live( - "warns without failing db start when the legacy migrations-catalog export fails on a fresh volume", - () => { - const { layer, out } = setup({ - configContents: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', - projectEnvContents: "SUPABASE_USE_PG_DELTA_NEXT=false\n", - route: freshVolumeRoute(defaultRoute()), - catalogExportFailWith: "edge-runtime script produced no output", - }); - return Effect.gen(function* () { - const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - expect(out.stderrText).toContain( - "Warning: failed to cache migrations catalog: edge-runtime script produced no output", - ); - expect(readFileSync(currentBranchPath(tempRoot.current), "utf8")).toBe("main"); - }); - }, - ); - - it.live( - "does not attempt to cache the migrations catalog on a fresh volume when pg-delta is disabled", - () => { - const { layer, out, edgeRunCalls } = setup({ route: freshVolumeRoute(defaultRoute()) }); - return Effect.gen(function* () { - yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - expect(edgeRunCalls).toHaveLength(0); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - expect(existsSync(join(tempRoot.current, "supabase", ".temp", "pgdelta"))).toBe(false); - }); - }, - ); - it.live( "restarts against an existing volume: skips the SetupLocalDatabase-equivalent pipeline but still writes _current_branch", () => { diff --git a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md index 57ab6738e6..de4e0be48d 100644 --- a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md @@ -93,11 +93,10 @@ command. ## Files Written -| Path | Format | When | -| ---------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `/supabase/.branches/_current_branch` | text | on every start, only if absent — writes `"main"` | -| `/supabase/.temp/start-secrets//{env,multiline-env}/` | varies | Edge Runtime's own JWT/service-role-key/secret env artifacts — see below | -| `/supabase/.temp/pgdelta/catalog-local-migrations--.json` | JSON | best-effort, on a fresh volume, after `MigrateAndSeed`, when pg-delta is enabled (`[experimental.pgdelta] enabled` or `SUPABASE_EXPERIMENTAL_PG_DELTA`) AND the legacy engine is selected (`SUPABASE_USE_PG_DELTA_NEXT=false`); the default next engine skips this warmup entirely; a failure only warns on stderr and never fails `start` | +| Path | Format | When | +| ---------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------ | +| `/supabase/.branches/_current_branch` | text | on every start, only if absent — writes `"main"` | +| `/supabase/.temp/start-secrets//{env,multiline-env}/` | varies | Edge Runtime's own JWT/service-role-key/secret env artifacts — see below | Kong's `custom_nginx.template`, Vector's `vector.yaml`, and Postgres's own bootstrap script (`postgresql.conf`-equivalent setup) are all rendered in memory and injected @@ -163,8 +162,6 @@ not implemented. | -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | | `SUPABASE_*` (any dotted config field) | Generic Viper-style `AutomaticEnv` override of any `config.toml` field (e.g. `SUPABASE_AUTH_ENABLED`, `SUPABASE_API_PORT`) | no | | `SUPABASE_EXPERIMENTAL` (or `--experimental`) | Fresh volume + no pg-delta: applies `db.migrations.schema_paths` files instead of `migrations/*.sql` (see "Fresh-volume DB setup" above) | no | -| `SUPABASE_EXPERIMENTAL_PG_DELTA` | Enables the post-`MigrateAndSeed` migrations-catalog cache warmup when `[experimental.pgdelta].enabled` is unset | no | -| `SUPABASE_USE_PG_DELTA_NEXT` | Selects the pg-delta implementation; `false` selects the legacy edge-runtime engine and thereby restores the migrations-catalog cache warmup (unset/unrecognized defaults to the next engine, which skips it) | no | | `SUPABASE_INTERNAL_IMAGE_REGISTRY` | Overrides the image registry used to resolve every service's image | no | | `SUPABASE_PROJECT_ID` | Overrides the resolved local project id (env → config.toml → workdir basename) | no | | `SUPABASE_WORKDIR` | Resolves `LegacyCliSettings.workdir` | no | diff --git a/apps/cli/src/legacy/commands/start/start.integration.test.ts b/apps/cli/src/legacy/commands/start/start.integration.test.ts index 10de9bd618..972af58f14 100644 --- a/apps/cli/src/legacy/commands/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/start/start.integration.test.ts @@ -1,5 +1,5 @@ import { generateKeyPairSync } from "node:crypto"; -import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; @@ -42,11 +42,7 @@ import { type LegacyDbSession, } from "../../shared/legacy-db-connection.service.ts"; import { legacyDockerRunLayer } from "../../shared/legacy-docker-run.layer.ts"; -import { LegacyEdgeRuntimeScriptError } from "../../shared/legacy-edge-runtime-script.errors.ts"; -import { - LegacyEdgeRuntimeScript, - type LegacyEdgeRuntimeRunOpts, -} from "../../shared/legacy-edge-runtime-script.service.ts"; +import { LegacyEdgeRuntimeScript } from "../../shared/legacy-edge-runtime-script.service.ts"; import { LegacyPgDeltaSslProbe } from "../../shared/legacy-pgdelta-ssl-probe.service.ts"; import { LEGACY_START_EXCLUDABLE_KEYS } from "./start.exclude.ts"; import type { LegacyStartFlags } from "./start.command.ts"; @@ -413,10 +409,6 @@ interface SetupOpts { readonly networkId?: Option.Option; /** `--experimental`/`SUPABASE_EXPERIMENTAL`. Defaults to `false`. */ readonly experimental?: boolean; - /** `LegacyEdgeRuntimeScript`'s mocked stdout for the pg-delta catalog-export call (`db-setup.ts`'s `legacyTryCacheMigrationsCatalog`). Only ever reached on a fresh volume with pg-delta enabled. */ - readonly catalogStdout?: string; - /** Fails the mocked catalog-export call with this message instead of succeeding. */ - readonly catalogExportFailWith?: string; } function setup(opts: SetupOpts = {}) { @@ -436,17 +428,8 @@ function setup(opts: SetupOpts = {}) { onSecretCopy: opts.onSecretCopy, }); const dbSession = fakeDbSession(); - const edgeRunCalls: Array = []; const edgeRuntime = Layer.succeed(LegacyEdgeRuntimeScript, { - run: (runOpts: LegacyEdgeRuntimeRunOpts) => { - edgeRunCalls.push(runOpts); - if (opts.catalogExportFailWith !== undefined) { - return Effect.fail( - new LegacyEdgeRuntimeScriptError({ message: opts.catalogExportFailWith }), - ); - } - return Effect.succeed({ stdout: opts.catalogStdout ?? '{"version":1}', stderr: "" }); - }, + run: () => Effect.succeed({ stdout: '{"version":1}', stderr: "" }), }); const sslProbe = Layer.succeed(LegacyPgDeltaSslProbe, { requireSsl: () => Effect.succeed(false), @@ -492,7 +475,7 @@ function setup(opts: SetupOpts = {}) { sslProbe, ); - return { workdir, out, telemetry, analytics, child, dbSession, edgeRunCalls, layer }; + return { workdir, out, telemetry, analytics, child, dbSession, layer }; } /** @@ -2408,61 +2391,6 @@ content_path = "./supabase/templates/custom_notice.html" }, ); - it.live( - "caches the migrations catalog after a fresh-volume setup for the legacy engine", - () => { - const { layer, out, workdir, edgeRunCalls } = setup({ - configContents: 'project_id = "demo"\n[experimental.pgdelta]\nenabled = true\n', - route: freshVolumeRoute(defaultRoute()), - catalogStdout: '{"snapshot":"ok"}', - }); - writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_USE_PG_DELTA_NEXT=false\n"); - return Effect.gen(function* () { - yield* legacyStart(flags({ exclude: ["edge-runtime"] })); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - // Runs once, immediately AFTER the fresh-volume migrate+seed pipeline. - expect(edgeRunCalls).toHaveLength(1); - const tempDir = join(workdir, "supabase", ".temp", "pgdelta"); - const catalogFiles = readdirSync(tempDir).filter((name) => - name.startsWith("catalog-local-migrations-"), - ); - expect(catalogFiles).toHaveLength(1); - expect(readFileSync(join(tempDir, catalogFiles[0]!), "utf8")).toBe('{"snapshot":"ok"}'); - }).pipe(Effect.provide(layer)); - }, - ); - - it.live( - "warns without failing supabase start when the migrations-catalog export fails on a fresh volume", - () => { - const { layer, out, workdir } = setup({ - configContents: 'project_id = "demo"\n[experimental.pgdelta]\nenabled = true\n', - route: freshVolumeRoute(defaultRoute()), - catalogExportFailWith: "edge-runtime script produced no output", - }); - writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_USE_PG_DELTA_NEXT=false\n"); - return Effect.gen(function* () { - const exit = yield* legacyStart(flags({ exclude: ["edge-runtime"] })).pipe(Effect.exit); - expect(Exit.isSuccess(exit)).toBe(true); - expect(out.stderrText).toContain( - "Warning: failed to cache migrations catalog: edge-runtime script produced no output", - ); - }).pipe(Effect.provide(layer)); - }, - ); - - it.live( - "does not attempt to cache the migrations catalog on a fresh volume when pg-delta is disabled", - () => { - const { layer, out, edgeRunCalls } = setup({ route: freshVolumeRoute(defaultRoute()) }); - return Effect.gen(function* () { - yield* legacyStart(flags({ exclude: ["edge-runtime"] })); - expect(edgeRunCalls).toHaveLength(0); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - }).pipe(Effect.provide(layer)); - }, - ); - it.live( "resolves an excluded service's migrate-job image through a project-dotenv-only registry override", () => { diff --git a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts index a48d38eead..3822672cd6 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts @@ -60,40 +60,6 @@ * `--no-seed`/`--sql-paths` overrides on top of the loaded `[db.seed]` config first * (a no-op for `db start`, which has neither flag) — see * {@link legacyResolveResetSeedConfig}. - * 7. **`pgcache.TryCacheMigrationsCatalog`** (`start.go:371-379`) — a best-effort - * warmup of the `catalog-local-migrations-*` snapshot subsequent pg-delta - * workflows (`db diff`/`db push`) consume, via the already-ported - * `legacyTryCacheMigrationsCatalog` ({@link legacy-pgdelta.cache.ts}, the exact - * same function `db push` already calls after its own migration apply). Gated - * identically to Go's `ShouldCacheMigrationsCatalog()` (`pgcache/cache.go:93-95`): - * `input.version.length === 0` AND (`toml.pgDelta.enabled` OR - * `SUPABASE_EXPERIMENTAL_PG_DELTA`) — reached by BOTH real Go callers of this - * shared function, `db start` (always `version: ""`) and `db reset`'s PG15 - * recreate (its own resolved reset version, usually also `""`). A failure prints - * Go's exact warning (`Warning: failed to cache migrations catalog: `, - * `start.go:378`) to stderr and is otherwise swallowed, reusing the identical - * best-effort catch/warn shape `legacy-db-push-core.ts` already established for - * its own call — this step never fails {@link legacyStartSetupLocalDatabase} or - * the caller's `start`/`db start`/`db reset` run. Requires - * `LegacyEdgeRuntimeScript`/`LegacyPgDeltaSslProbe` in this function's own effect - * environment (widened accordingly below), so `start.command.ts`, - * `db/start/start.layers.ts`, AND `db/reset/reset.layers.ts` all compose - * `legacyEdgeRuntimeScriptLayer`/`legacyPgDeltaSslProbeLayer`, matching `db - * push`'s own layer composition (`push.layers.ts`). The underlying - * `legacyExportCatalogPgDelta` reads `PGDELTA_NPM_REGISTRY` straight off bare - * `process.env` ({@link legacy-pgdelta.ts}'s `legacyPgDeltaNpmRegistryOption`) — - * Go's `Config.Load` already `os.Setenv`'d the project `.env` into the process - * before `start`/`db start`/`db reset` ever reaches this call (`loadNestedEnv`, - * `config.go:788`), so a registry override set only in `supabase/.env` (not the - * shell) must be visible here too. This module never mutates `process.env` - * globally the way `start`/`db start`'s own config resolution does — every other - * Go env override is threaded explicitly via `projectEnvValues` — so this ONE - * call is scoped with `legacyApplyProjectEnv` (the same opt-in helper `db - * push`/`db pull`/`db dump`/`bootstrap` already use around their own pg-delta/ - * image work) for just its own duration, then reverted. `legacySetupDatabase` - * (CLI-1956's extraction of steps 1-4 above, reused by shadow-database - * provisioning) never reaches this step at all — only this function's own - * trailing `MigrateAndSeed` + pgcache tail does. * * Go's `initCurrentBranch` (`start.go:233-241`, writes `supabase/.branches/ * _current_branch` = `"main"` if absent) is NOT part of this pipeline, even though @@ -129,28 +95,13 @@ import { import { LegacyDbConnection, type LegacyDbSession } from "../legacy-db-connection.service.ts"; import type { LegacyDbConnectError } from "../legacy-db-connection.errors.ts"; import { LegacyDbConfigLoadError } from "../legacy-db-config.errors.ts"; -import { redactLegacyConnectionString } from "../legacy-db-config.parse.ts"; -import { - legacyApplyProjectEnv, - legacyCheckDbToml, - legacyResolveSeedSqlPath, -} from "../legacy-db-config.toml-read.ts"; -import { legacyParseBoolEnv } from "../legacy-diff-engine.ts"; +import { legacyCheckDbToml, legacyResolveSeedSqlPath } from "../legacy-db-config.toml-read.ts"; import { LEGACY_CLI_PROJECT_LABEL, localDbContainerId } from "../legacy-docker-ids.ts"; import { LegacyDockerRun, type LegacyDockerRunOpts } from "../legacy-docker-run.service.ts"; -import { LegacyEdgeRuntimeScript } from "../legacy-edge-runtime-script.service.ts"; import { legacyMigrateAndSeed } from "../legacy-migrate-and-seed.ts"; import { LegacyMigrationApplyError, legacyExecSqlFile } from "../legacy-migration-apply.ts"; import { legacyReadMigrationTable } from "../legacy-migration-history.ts"; import { legacyStatementInstallsPgNet } from "../legacy-pg-net-guidance.ts"; -import { legacyTryCacheMigrationsCatalog } from "../legacy-pgdelta.cache.ts"; -import { - LEGACY_PG_DELTA_NEXT_FLAG_NAME, - legacyPgDeltaImplementationFlag, - legacyResolvePgDeltaImplementation, -} from "../legacy-pgdelta-next-flag.ts"; -import type { LegacyPgDeltaContext } from "../legacy-pgdelta.ts"; -import { LegacyPgDeltaSslProbe } from "../legacy-pgdelta-ssl-probe.service.ts"; import type { LegacyMigrationSeedError, LegacySeedConfig } from "../legacy-seed.ts"; import { ramInBytes } from "../legacy-size-units.ts"; import { @@ -1142,19 +1093,7 @@ export const legacyStartSetupLocalDatabase = ( // because a batch that cannot check a connection out of the pool fails with the // driver's connect error verbatim, suggestion included. LegacyStartSetupLocalDatabaseError | LegacyDbConnectError, - | Output - | LegacyDockerRun - | RuntimeInfo - | LegacyEdgeRuntimeScript - | LegacyPgDeltaSslProbe - // `legacyTryCacheMigrationsCatalog`'s own pg-delta export call resolves - // `FileSystem.FileSystem`/`Path.Path` from the effect context itself (not from - // the `fs`/`path` values this function already threads through as plain data — - // see `legacy-pgdelta.ts`'s `legacyExportCatalogPgDelta`), so both must be - // ambient here too; every real caller already gets them from `BunServices.layer` - // at the CLI root runtime, same as `db push`'s own composition. - | FileSystem.FileSystem - | Path.Path + Output | LegacyDockerRun | RuntimeInfo | FileSystem.FileSystem | Path.Path > => Effect.gen(function* () { const { session, fs, path, workdir } = input; @@ -1205,80 +1144,6 @@ export const legacyStartSetupLocalDatabase = ( localDatabaseWebhooksEnabled: toml.webhooksEnabled, }); - const output = yield* Output; - - // pgcache.TryCacheMigrationsCatalog(ctx, pgconn.Config{Host: Config.Hostname, - // Port: Config.Db.Port, User: "postgres", Password: Config.Db.Password, Database: - // "postgres"}, "local", version, fsys, ...) (start.go:371-379): best-effort, run - // immediately after MigrateAndSeed above, for BOTH real Go callers of this shared - // function — `db start` (always `version: ""`) and `db reset`'s PG15 recreate - // (its own resolved reset `input.version`, usually also `""`). `cacheEnabled` - // reproduces Go's `ShouldCacheMigrationsCatalog()` gate exactly - // (`pgcache/cache.go:93-95`): `len(version) == 0` AND (`toml.pgDelta.enabled` OR - // `SUPABASE_EXPERIMENTAL_PG_DELTA`) — the same formula `legacy-db-push-core.ts` - // already uses for its own call. `input.dbUrl` is already the HOST-facing - // `postgresql://postgres:@:/postgres` address (see its - // own doc comment) — the exact same shape Go's `utils.ToPostgresURL(config)` builds - // from that literal `pgconn.Config` here, so it's reused directly as `targetUrl` - // rather than re-derived. `conn`'s fields are only ever read by - // `legacyCatalogPrefixFromConfig` on a non-local prefix fallback, unreachable here - // since `isLocal` is always `true`. - const cacheEnabled = - input.version.length === 0 && - (toml.pgDelta.enabled || - legacyParseBoolEnv(toml.envLookup("SUPABASE_EXPERIMENTAL_PG_DELTA"))); - const pgDeltaImplementation = legacyResolvePgDeltaImplementation( - legacyPgDeltaImplementationFlag( - process.env[LEGACY_PG_DELTA_NEXT_FLAG_NAME], - toml.projectEnv[LEGACY_PG_DELTA_NEXT_FLAG_NAME], - ), - ); - const pgDeltaCtx: LegacyPgDeltaContext = { - projectId: input.projectId, - cwd: workdir, - npmVersion: Option.getOrUndefined(toml.pgDelta.npmVersion), - denoVersion: toml.denoVersion, - projectEnv: toml.projectEnv, - }; - const hostDbUrl = new URL(input.dbUrl); - // Scope the `PGDELTA_NPM_REGISTRY`-from-project-`.env` apply to just this call: - // `legacyExportCatalogPgDelta` reads it off bare `process.env` - // (`legacyPgDeltaNpmRegistryOption`), same as `db push`/`db pull`/`db dump`/ - // `bootstrap`'s own calls into pg-delta — Go's `loadNestedEnv` already made it - // process-wide by this point (`config.go:788`), but this module otherwise threads - // every override through `projectEnvValues` explicitly rather than mutating - // `process.env`, so this one shared-code call needs the same opt-in helper those - // other commands use. `legacyApplyProjectEnv` registers a finalizer that reverts it. - yield* Effect.scoped( - Effect.gen(function* () { - yield* legacyApplyProjectEnv(input.projectEnvValues ?? {}); - yield* legacyTryCacheMigrationsCatalog(fs, path, pgDeltaCtx, { - // The catalog is a legacy-engine artifact with no in-process consumer. - enabled: cacheEnabled && pgDeltaImplementation === "legacy", - targetUrl: input.dbUrl, - conn: { - host: hostDbUrl.hostname, - port: Number(hostDbUrl.port), - user: "postgres", - database: "postgres", - }, - isLocal: true, - migrationsDir: path.join(workdir, "supabase", "migrations"), - }).pipe( - // Best-effort: Go's own `TryCacheMigrationsCatalog` failure only ever warns - // (`fmt.Fprintln(os.Stderr, "Warning: failed to cache migrations catalog:", err)`, - // start.go:378) and never fails `legacyStartSetupLocalDatabase` — same shape - // `legacy-db-push-core.ts` already established for this exact call. - Effect.catch((error) => - output.raw( - `Warning: failed to cache migrations catalog: ${redactLegacyConnectionString(error.message)}\n`, - "stderr", - ), - ), - ); - }), - ); - // `initCurrentBranch` (start.go:233-241) is NOT called here — see this // module's header for why it moved to the caller instead. }); @@ -1430,14 +1295,7 @@ export const legacyRunFreshDbSetup = ( ): Effect.Effect< void, LegacyStartSetupLocalDatabaseError | LegacyDbConnectError | LegacyImagePrepullError | E, - | Output - | LegacyDbConnection - | LegacyDockerRun - | RuntimeInfo - | LegacyEdgeRuntimeScript - | LegacyPgDeltaSslProbe - | FileSystem.FileSystem - | Path.Path + Output | LegacyDbConnection | LegacyDockerRun | RuntimeInfo | FileSystem.FileSystem | Path.Path > => Effect.scoped( Effect.gen(function* () { diff --git a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts index 5af2ee4ed0..f156a4a4fd 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { CliConfig } from "@supabase/config"; @@ -653,206 +653,6 @@ describe("legacyStartSetupLocalDatabase", () => { }, ); }); - - describe("pgcache migrations-catalog warmup (start.go:371-379)", () => { - it.effect("does not attempt to cache the migrations catalog when pg-delta is disabled", () => { - const workdir = makeWorkdir(); - const { session } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - const edgeRuntime = mockEdgeRuntime(); - return run(baseInput(workdir, session, { majorVersion: 14 }), out, docker, edgeRuntime).pipe( - Effect.map(() => { - expect(edgeRuntime.calls).toHaveLength(0); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - rmSync(workdir, { recursive: true, force: true }); - }), - ); - }); - - it.effect("skips the legacy catalog when the default next engine is enabled", () => { - const workdir = makeWorkdir(); - writeConfigToml(workdir, "[experimental.pgdelta]\nenabled = true\n"); - const { session } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - const edgeRuntime = mockEdgeRuntime({ stdout: '{"snapshot":"ok"}' }); - return run(baseInput(workdir, session, { majorVersion: 14 }), out, docker, edgeRuntime).pipe( - Effect.map(() => { - expect(edgeRuntime.calls).toHaveLength(0); - rmSync(workdir, { recursive: true, force: true }); - }), - ); - }); - - it.effect("caches the migrations catalog for the legacy engine after MigrateAndSeed", () => { - const workdir = makeWorkdir(); - writeConfigToml(workdir, "[experimental.pgdelta]\nenabled = true\n"); - writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_USE_PG_DELTA_NEXT=false\n"); - const { session } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - const edgeRuntime = mockEdgeRuntime({ stdout: '{"snapshot":"ok"}' }); - return run(baseInput(workdir, session, { majorVersion: 14 }), out, docker, edgeRuntime).pipe( - Effect.map(() => { - expect(edgeRuntime.calls).toHaveLength(1); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - const tempDir = join(workdir, "supabase", ".temp", "pgdelta"); - const catalogFiles = readdirSync(tempDir).filter((name) => - name.startsWith("catalog-local-migrations-"), - ); - expect(catalogFiles).toHaveLength(1); - expect(readFileSync(join(tempDir, catalogFiles[0]!), "utf8")).toBe('{"snapshot":"ok"}'); - rmSync(workdir, { recursive: true, force: true }); - }), - ); - }); - - it.effect( - "skips the legacy catalog when an empty shell value shadows a project .env false (godotenv parity)", - () => { - // godotenv.Load never replaces a shell value, including an empty one, so - // an empty `SUPABASE_USE_PG_DELTA_NEXT` in the shell must suppress the - // `supabase/.env` fallback below and resolve to the next implementation — - // matching the engine-selector layer's own precedence rather than - // `toml.envLookup`'s (which treats an empty shell value as unset). - const prev = process.env["SUPABASE_USE_PG_DELTA_NEXT"]; - process.env["SUPABASE_USE_PG_DELTA_NEXT"] = ""; - const workdir = makeWorkdir(); - writeConfigToml(workdir, "[experimental.pgdelta]\nenabled = true\n"); - writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_USE_PG_DELTA_NEXT=false\n"); - const { session } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - const edgeRuntime = mockEdgeRuntime({ stdout: '{"snapshot":"ok"}' }); - return run( - baseInput(workdir, session, { majorVersion: 14 }), - out, - docker, - edgeRuntime, - ).pipe( - Effect.map(() => { - expect(edgeRuntime.calls).toHaveLength(0); - rmSync(workdir, { recursive: true, force: true }); - }), - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_USE_PG_DELTA_NEXT"]; - else process.env["SUPABASE_USE_PG_DELTA_NEXT"] = prev; - }), - ), - ); - }, - ); - - it.effect( - "caches the migrations catalog when SUPABASE_EXPERIMENTAL_PG_DELTA is enabled via project .env", - () => { - const workdir = makeWorkdir(); - mkdirSync(join(workdir, "supabase"), { recursive: true }); - writeFileSync( - join(workdir, "supabase", ".env"), - "SUPABASE_EXPERIMENTAL_PG_DELTA=true\nSUPABASE_USE_PG_DELTA_NEXT=false\n", - ); - const { session } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - const edgeRuntime = mockEdgeRuntime({ stdout: '{"snapshot":"ok"}' }); - return run( - baseInput(workdir, session, { majorVersion: 14 }), - out, - docker, - edgeRuntime, - ).pipe( - Effect.map(() => { - expect(edgeRuntime.calls).toHaveLength(1); - rmSync(workdir, { recursive: true, force: true }); - }), - ); - }, - ); - - it.effect( - "applies PGDELTA_NPM_REGISTRY from the project .env for the catalog export, then reverts it", - () => { - // Go's `Config.Load` already `os.Setenv`'d the project `.env` into the process - // (`loadNestedEnv`, config.go:788) long before `SetupLocalDatabase` runs, so a - // PGDELTA_NPM_REGISTRY set only in supabase/.env (not the shell) reaches - // `PgDeltaNpmRegistryOption` there. This module threads config overrides via - // `projectEnvValues` rather than mutating `process.env` globally, so the - // cache-warmup step must scope-apply it around just `legacyExportCatalogPgDelta`'s - // call (`legacyPgDeltaNpmRegistryOption` reads bare `process.env`) and revert - // afterwards — mirroring `db push`/`db pull`/`db dump`/`bootstrap`'s own use of - // `legacyApplyProjectEnv` for the same shared pg-delta code. - const previous = process.env["PGDELTA_NPM_REGISTRY"]; - delete process.env["PGDELTA_NPM_REGISTRY"]; - const workdir = makeWorkdir(); - writeConfigToml(workdir, "[experimental.pgdelta]\nenabled = true\n"); - mkdirSync(join(workdir, "supabase"), { recursive: true }); - writeFileSync( - join(workdir, "supabase", ".env"), - "PGDELTA_NPM_REGISTRY=https://registry.example.com/supabase\nSUPABASE_USE_PG_DELTA_NEXT=false\n", - ); - const { session } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - const edgeRuntime = mockEdgeRuntime({ stdout: '{"snapshot":"ok"}' }); - return run( - baseInput(workdir, session, { - majorVersion: 14, - projectEnvValues: { PGDELTA_NPM_REGISTRY: "https://registry.example.com/supabase" }, - }), - out, - docker, - edgeRuntime, - ).pipe( - Effect.map(() => { - expect(edgeRuntime.calls).toHaveLength(1); - expect(edgeRuntime.calls[0]?.extraEnv?.["PGDELTA_NPM_REGISTRY"]).toBe( - "https://registry.example.com/supabase", - ); - expect(edgeRuntime.calls[0]?.extraEnv?.["NPM_CONFIG_REGISTRY"]).toBe( - "https://registry.example.com/supabase", - ); - // Reverted: the scope closes once the cache-warmup call completes, so it - // never leaks into subsequent steps or other tests. - expect(process.env["PGDELTA_NPM_REGISTRY"]).toBeUndefined(); - if (previous === undefined) delete process.env["PGDELTA_NPM_REGISTRY"]; - else process.env["PGDELTA_NPM_REGISTRY"] = previous; - rmSync(workdir, { recursive: true, force: true }); - }), - ); - }, - ); - - it.effect( - "warns without failing legacyStartSetupLocalDatabase when the catalog export fails", - () => { - const workdir = makeWorkdir(); - writeConfigToml(workdir, "[experimental.pgdelta]\nenabled = true\n"); - writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_USE_PG_DELTA_NEXT=false\n"); - const { session } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - const edgeRuntime = mockEdgeRuntime({ - failWith: "edge-runtime script produced no output", - }); - return run( - baseInput(workdir, session, { majorVersion: 14 }), - out, - docker, - edgeRuntime, - ).pipe( - Effect.map(() => { - expect(out.stderrText).toContain( - "Warning: failed to cache migrations catalog: edge-runtime script produced no output", - ); - rmSync(workdir, { recursive: true, force: true }); - }), - ); - }, - ); - }); }); describe("legacyResolveDbSetupPrelude", () => { diff --git a/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.ts index a6f79c3c30..d4f74fcd3d 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.ts @@ -95,14 +95,12 @@ import { LegacyDbConnection, type LegacyDbSession } from "../legacy-db-connectio import { LegacyDbExecError, type LegacyDbConnectError } from "../legacy-db-connection.errors.ts"; import { LEGACY_CLI_PROJECT_LABEL } from "../legacy-docker-ids.ts"; import type { LegacyDockerRun } from "../legacy-docker-run.service.ts"; -import type { LegacyEdgeRuntimeScript } from "../legacy-edge-runtime-script.service.ts"; import { legacyMigrateAndSeed } from "../legacy-migrate-and-seed.ts"; import { legacyFormatExecBatchError, type LegacyMigrationApplyError, } from "../legacy-migration-apply.ts"; import { legacyErrorMessage } from "../legacy-error-message.ts"; -import type { LegacyPgDeltaSslProbe } from "../legacy-pgdelta-ssl-probe.service.ts"; import type { LegacyMigrationSeedError } from "../legacy-seed.ts"; import { legacyEnsureNetwork, @@ -364,8 +362,6 @@ const legacyRecreateLocalDatabase15 = ( | LegacyDockerRun | RuntimeInfo | HttpClient.HttpClient - | LegacyEdgeRuntimeScript - | LegacyPgDeltaSslProbe | FileSystem.FileSystem | Path.Path > => @@ -434,8 +430,6 @@ const legacyRecreateLocalDatabase14 = ( | LegacyDockerRun | RuntimeInfo | HttpClient.HttpClient - | LegacyEdgeRuntimeScript - | LegacyPgDeltaSslProbe | FileSystem.FileSystem | Path.Path > => @@ -540,8 +534,6 @@ export const legacyRecreateLocalDatabase = ( | LegacyDockerRun | RuntimeInfo | HttpClient.HttpClient - | LegacyEdgeRuntimeScript - | LegacyPgDeltaSslProbe | FileSystem.FileSystem | Path.Path > => diff --git a/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.ts b/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.ts index eeea2b044c..77b71146e1 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/shadow-cache.ts @@ -879,8 +879,8 @@ const legacyWarmShadow = ( /** * `Effect.acquireUseRelease`'s `acquire` for every shadow-provisioning call site that runs the - * platform baseline (`db diff`'s migra/pg-delta branch, `db pull`'s migration diff, - * `legacy-pgdelta.cache.ts`'s catalog export, and pg-delta next's scoped shadows) — see + * platform baseline (`db diff`'s migra/pg-delta branch, `db pull`'s migration diff, and + * pg-delta's scoped shadows) — see * {@link legacyWithShadowDatabase} for the acquire/use/release wrapper, and * `legacy-pgdelta-next-shadow.layer.ts` for the scoped `acquireRelease` form next uses so the * container outlives provision (the engine keeps using the URL after this returns). 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 1d243bc4ec..c8b644468e 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts @@ -76,8 +76,6 @@ import { } from "./container-lifecycle.ts"; import type { LegacyStartContainerSpec } from "./docker-create-args.ts"; import type { LegacyImagePrepullError } from "./image-prepull.ts"; -import type { LegacyHealthCheckTimeoutError } from "./health-check.ts"; -import { legacyWaitForShadowReady } 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"; @@ -260,7 +258,7 @@ export interface LegacyShadowDatabaseHandle { * Leak window (deliberate Go parity, not a bug — the canonical explanation every call site * below cross-references): every real caller runs this whole function as the `acquire` of an * `Effect.acquireUseRelease` whose `release` is {@link legacyRemoveShadowDatabase} (see - * `diff.handler.ts`/`pull.handler.ts`/`legacy-pgdelta.cache.ts`'s call sites). Effect only + * `diff.handler.ts`/`pull.handler.ts`'s call sites). Effect only * registers `release` once `acquire` itself resolves successfully; an `acquire` that fails * partway through — `docker create` having already succeeded, but the LATER `docker * cp`/`docker start` step inside {@link legacyCreateContainer} then failing @@ -387,7 +385,7 @@ export interface LegacyShadowSourceResult { readonly targetUrlOverride: string | undefined; } -/** Fields shared by `legacy-shadow-source.ts`'s `LegacyPrepareShadowSourceInput`/{@link LegacyPrepareRawShadowInput}. */ +/** Fields shared by `legacy-shadow-source.ts`'s `LegacyPrepareShadowSourceInput` and the shadow readiness probes. */ export interface LegacyShadowConnectionInput extends LegacyCreateShadowDatabaseInput { readonly fs: FileSystem.FileSystem; readonly path: Path.Path; @@ -397,8 +395,6 @@ export interface LegacyShadowConnectionInput extends LegacyCreateShadowDatabaseI readonly healthTimeoutSeconds: number; } -export type LegacyPrepareRawShadowInput = LegacyShadowConnectionInput; - /** * {@link LegacyShadowConnectionInput} plus the platform-baseline setup fields * {@link legacySetupDatabase}/`legacyMigrateShadowDatabase`/`legacySetupShadowDatabase` need — @@ -559,54 +555,6 @@ export const legacyShadowConnConfig = (input: LegacyShadowConnFields): LegacyPgC database: "postgres", }); -/** - * Port of Go's `PrepareRawShadow` (`apps/cli-go/internal/db/diff/shadow.go:93-116`): readiness - * wait against an already-{@link legacyCreateShadowDatabase}-created shadow (created + accepting - * connections, no platform baseline or migrations applied) — used inline (`db pull - * --declarative`'s empty declarative-export source), not the `ok`-sentinel error-path pattern - * `legacy-shadow-source.ts`'s `legacyPrepareShadowSource` uses, since there is only ONE step - * here that can fail (the readiness wait) rather than several. Lives here (not - * `legacy-shadow-source.ts`) because it has zero pg-delta/declarative dependency — see this - * module's own header. - * - * Gates on {@link legacyWaitForShadowReady}, NOT on the Docker-health - * `legacyWaitForHealthyServices` the long-running `db` container still uses: the shadow's - * own healthcheck cannot report `healthy` before its first 10-second-interval probe, ~6.5s after - * Postgres is already connectable — see that function's own doc comment. - * - * Deliberately does NOT call {@link legacyCreateShadowDatabase} itself — the caller does, as the - * `acquire` of an `Effect.acquireUseRelease` whose `use` phase is this function (see - * `diff.handler.ts`/`pull.handler.ts`'s call sites). Go's `PrepareRawShadow` threads a single - * cancellable `ctx` through both creation and the readiness wait, so a SIGINT can interrupt - * either; an earlier shape here instead passed the WHOLE create-then-wait effect as `acquire`, - * which Effect's `uninterruptibleMask` (`acquireUseRelease(acquire, use, release) => - * uninterruptibleMask(restore => flatMap(acquire, a => onExitPrimitive(restore(use(a)), ...)))`) - * makes entirely uninterruptible — a SIGINT during the readiness wait (which can run for up to - * `healthTimeoutSeconds`) was silently swallowed until the wait finished or timed out on its - * own, unlike Go. Splitting `legacyCreateShadowDatabase` out as the (brief, Docker-API-bound) - * `acquire` and keeping this wait as part of the interruptible `use` restores that parity - * — a SIGINT here now lands immediately, while `legacyRemoveShadowDatabase` still - * runs as the `release` finalizer regardless of how `use` exits. - */ -export const legacyPrepareRawShadow = ( - spawner: Spawner, - handle: LegacyShadowDatabaseHandle, - input: LegacyPrepareRawShadowInput, -): Effect.Effect => - Effect.gen(function* () { - const { containerId } = handle; - const connConfig = legacyShadowConnConfig(input); - yield* legacyWaitForShadowReady(spawner, containerId, connConfig, { - timeoutSeconds: input.healthTimeoutSeconds, - image: input.image, - }); - return { - container: containerId, - sourceUrl: legacyToPostgresURL(connConfig), - targetUrlOverride: undefined, - }; - }); - /** * Port of Go's `setupShadowConn` (`apps/cli-go/internal/db/diff/diff.go:171-179`): * {@link legacySetupDatabase} (Go's `SetupDatabase`) against an already-connected shadow, diff --git a/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts index 19bfb04569..5ec49c05d6 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts @@ -87,8 +87,6 @@ import { legacyWaitForHealthyServices, type LegacyHealthCheckTimeoutError, } from "./health-check.ts"; -import type { LegacyEdgeRuntimeScript } from "../legacy-edge-runtime-script.service.ts"; -import type { LegacyPgDeltaSslProbe } from "../legacy-pgdelta-ssl-probe.service.ts"; import { LEGACY_START_STARTING_DATABASE_FROM_BACKUP_MESSAGE, LEGACY_START_STARTING_DATABASE_MESSAGE, @@ -184,8 +182,6 @@ export const legacyStartDatabase = ( | LegacyDockerRun | RuntimeInfo | HttpClient.HttpClient - | LegacyEdgeRuntimeScript - | LegacyPgDeltaSslProbe | FileSystem.FileSystem | Path.Path > => diff --git a/apps/cli/src/legacy/shared/db-bootstrap/start-local-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/start-local-database.ts index 3a6d929adb..2f00f78b5d 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/start-local-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/start-local-database.ts @@ -137,9 +137,7 @@ export const legacyStartLocalDatabase = Effect.fnUntraced(function* (fromBackupF // Realtime/Storage/Auth migrate job tees its own stderr (`db-setup.ts`'s // `legacyRunStartMigrateJob`). Resolved with the `SUPABASE_DEBUG` shell/project-`.env` // fallback, not the bare flag: every Go debug read on this path went through - // `viper.GetBool("DEBUG")` under `AutomaticEnv`, and the sibling shadow-provision path - // (`legacy-pgdelta.cache.ts`'s `legacyBuildShadowCatalogInputs`) already resolves it the - // same way. + // `viper.GetBool("DEBUG")` under `AutomaticEnv`. const debug = yield* legacyResolveDebugWithProjectEnv(dbTomlValues.projectEnv); // The rest of config loading — full config decode/resolution (`legacyLoadLocalProjectContext`) diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts index 114bffc012..96f7d1c816 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts @@ -165,10 +165,9 @@ interface LegacyDbVaultSecretToml { /** * Cache-key inputs from `[auth]`/`[storage]`/`[realtime]`/`[api]`/`[db.vault]`. - * Exported so callers that build this cache-key subset directly (e.g. - * `legacyResolveSetupInputs` in `legacy-pgdelta.cache.ts`) reference this shape - * instead of re-declaring it inline, making field drift a compile error rather - * than a silent cache-key gap. + * Exported so callers that build this cache-key subset directly reference this + * shape instead of re-declaring it inline, making field drift a compile error + * rather than a silent cache-key gap. */ export interface LegacyBaselineTomlConfig { /** `[auth] enabled`, default true. Gates `initSchema`'s auth service migration. */ @@ -187,10 +186,7 @@ export interface LegacyBaselineTomlConfig { readonly vaultNames: ReadonlyArray; } -/** - * The `[experimental.pgdelta]` subtree. `npmVersion` is sourced from - * `supabase/.temp/pgdelta-version` (not the TOML), matching `config.Load`. - */ +/** The `[experimental.pgdelta]` subtree. */ export interface LegacyPgDeltaTomlConfig { /** `[experimental.pgdelta] enabled`, default false. `IsPgDeltaEnabled`. */ readonly enabled: boolean; @@ -202,8 +198,6 @@ export interface LegacyPgDeltaTomlConfig { readonly declarativeSchemaPath: Option.Option; /** `[experimental.pgdelta] format_options`, a JSON string passed to pg-delta. */ readonly formatOptions: Option.Option; - /** `@supabase/pg-delta` npm version from `.temp/pgdelta-version`. */ - readonly npmVersion: Option.Option; } const DEFAULT_PORT = 54322; @@ -979,13 +973,10 @@ const DEFAULT_SUPABASE_ENV = "development"; * Keys {@link legacyApplyProjectEnv} copies from the project `.env` into * `process.env`. Kept to an allowlist of values that are read *only* via * `process.env` (no project-env map path) and must reflect `supabase/.env`: - * `SUPABASE_INTERNAL_IMAGE_REGISTRY` (`legacyGetRegistryImageUrl`) and - * `PGDELTA_NPM_REGISTRY` (`legacyPgDeltaNpmRegistryOption`, read straight from - * `process.env` for legacy-opt-out pg-delta edge-runtime invocations). The bundled - * next implementation never consults it. Go's + * `SUPABASE_INTERNAL_IMAGE_REGISTRY` (`legacyGetRegistryImageUrl`). Go's * `godotenv.Load` (`loadNestedEnv`) `os.Setenv`s every key from the project - * `.env`, so both readers see a `.env`-only value there; omitting either here - * would leave that one process.env-only reader blind to a project-`.env`-scoped + * `.env`, so the reader sees a `.env`-only value there; omitting it here + * would leave that process.env-only reader blind to a project-`.env`-scoped * override the shell never set. * Everything else is read from {@link legacyLoadProjectEnv}'s returned map * (`envLookup`, `legacyResolveYesWithProjectEnv`, `resolveDbPassword`) or resolved @@ -994,10 +985,7 @@ const DEFAULT_SUPABASE_ENV = "development"; * writing them here would let our lazily-built resolvers diverge from Go (retarget * the project, switch the env-file set, or leak into the Go `--experimental` proxy). */ -const LEGACY_PROCESS_ENV_APPLY_KEYS = [ - "SUPABASE_INTERNAL_IMAGE_REGISTRY", - "PGDELTA_NPM_REGISTRY", -] as const; +const LEGACY_PROCESS_ENV_APPLY_KEYS = ["SUPABASE_INTERNAL_IMAGE_REGISTRY"] as const; /** * Load the project's nested `.env` files into a lookup map. **Pure**: it reads the @@ -1069,10 +1057,10 @@ export const legacyLoadProjectEnv = Effect.fnUntraced(function* ( * Apply the allowlisted project-`.env` keys (see {@link LEGACY_PROCESS_ENV_APPLY_KEYS}) * to `process.env` **for the duration of the current scope**, then revert. This is * the opt-in counterpart to the pure {@link legacyLoadProjectEnv}: `bootstrap` / - * `db push` / `db pull` / `db dump` run it around their pg_dump / migration / pg-delta - * container work so a `SUPABASE_INTERNAL_IMAGE_REGISTRY` or `PGDELTA_NPM_REGISTRY` set - * only in `supabase/.env` still reaches `legacyGetRegistryImageUrl` / - * `legacyPgDeltaNpmRegistryOption` (both read `process.env` synchronously) — mirroring + * `db push` / `db pull` / `db dump` run it around their pg_dump / migration + * container work so a `SUPABASE_INTERNAL_IMAGE_REGISTRY` set + * only in `supabase/.env` still reaches `legacyGetRegistryImageUrl` (which reads + * `process.env` synchronously) — mirroring * the `os.Setenv` half of `loadNestedEnv`. Kept out of the shared loader so * SUPABASE_YES / db-password reads stay side-effect-free. * @@ -1519,17 +1507,6 @@ const readDbTomlCore = Effect.fnUntraced(function* ( .readFileString(poolerUrlPath) .pipe(Effect.map(nonEmptyString), Effect.orElseSucceed(Option.none)); - // The legacy pg-delta npm version is read from - // `.temp/pgdelta-version` (trimmed, non-empty) during Load, never from the - // TOML. An absent/empty file leaves it `None` (callers fall back to the - // default via `legacyEffectivePgDeltaNpmVersion`). The bundled next engine is - // fixed at CLI build time and ignores this compatibility setting. - const pgDeltaVersionPath = path.join(supabaseDir, ".temp", "pgdelta-version"); - const pgDeltaNpmVersion = yield* fs.readFileString(pgDeltaVersionPath).pipe( - Effect.map((content) => nonEmptyString(content.trim())), - Effect.orElseSucceed(Option.none), - ); - // `SUPABASE_DB_*` env vars override the matching `[db]` field before the TOML // value/default. An empty env value is ignored, and the project `.env` files are // loaded into the environment first, so consult both. @@ -2694,7 +2671,6 @@ const readDbTomlCore = Effect.fnUntraced(function* ( enabled, declarativeSchemaPath, formatOptions, - npmVersion: pgDeltaNpmVersion, }, webhooksEnabled, baseline: { diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts index d8c886fa6e..df06cb9df3 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts @@ -2635,40 +2635,32 @@ describe("legacyReadDbToml", () => { // Go's loadNestedEnv os.Setenv's the project .env, but its root globals // (project-ref, SUPABASE_ENV, workdir/profile) are resolved from the shell // BEFORE loadNestedEnv. Our resolvers read process.env lazily, so we apply only - // the allowlisted `SUPABASE_INTERNAL_IMAGE_REGISTRY` / `PGDELTA_NPM_REGISTRY` - // (the two process.env-only readers): a .env project-ref must not retarget the + // the allowlisted `SUPABASE_INTERNAL_IMAGE_REGISTRY` (the process.env-only + // reader): a .env project-ref must not retarget the // lazy ref/pooler resolvers, and a .env SUPABASE_ENV must not switch the // env-file set. const saved: Record = {}; - for (const k of [ - "SUPABASE_INTERNAL_IMAGE_REGISTRY", - "PGDELTA_NPM_REGISTRY", - "SUPABASE_PROJECT_ID", - "SUPABASE_ENV", - ]) { + for (const k of ["SUPABASE_INTERNAL_IMAGE_REGISTRY", "SUPABASE_PROJECT_ID", "SUPABASE_ENV"]) { saved[k] = process.env[k]; delete process.env[k]; } const loaded = { SUPABASE_INTERNAL_IMAGE_REGISTRY: "my-mirror.example.com", - PGDELTA_NPM_REGISTRY: "https://npm.example.com", SUPABASE_PROJECT_ID: "envonlyref", SUPABASE_ENV: "staging", }; return Effect.gen(function* () { - // Inside the scope: only the registry keys are applied; the ref/env selector are not. + // Inside the scope: only the registry key is applied; the ref/env selector are not. yield* Effect.scoped( Effect.gen(function* () { yield* legacyApplyProjectEnv(loaded); expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBe("my-mirror.example.com"); - expect(process.env["PGDELTA_NPM_REGISTRY"]).toBe("https://npm.example.com"); expect(process.env["SUPABASE_PROJECT_ID"]).toBeUndefined(); expect(process.env["SUPABASE_ENV"]).toBeUndefined(); }), ); // After the scope closes the applied keys are reverted (no test-worker leak). expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBeUndefined(); - expect(process.env["PGDELTA_NPM_REGISTRY"]).toBeUndefined(); // An existing process.env value is never overridden, and is NOT deleted on close. process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"] = "shell-wins.example.com"; @@ -2729,7 +2721,6 @@ describe("legacyReadDbToml [experimental.pgdelta]", () => { expect(v.pgDelta.enabled).toBe(false); expect(Option.isNone(v.pgDelta.declarativeSchemaPath)).toBe(true); expect(Option.isNone(v.pgDelta.formatOptions)).toBe(true); - expect(Option.isNone(v.pgDelta.npmVersion)).toBe(true); rmSync(dir, { recursive: true, force: true }); }), ), @@ -2776,34 +2767,6 @@ describe("legacyReadDbToml [experimental.pgdelta]", () => { ), ); }); - - it.effect("reads the npm version from .temp/pgdelta-version (trimmed)", () => { - const dir = withConfig(["[experimental.pgdelta]", "enabled = true", ""].join("\n")); - mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); - writeFileSync(join(dir, "supabase", ".temp", "pgdelta-version"), " 9.9.9-test \n"); - return read(dir).pipe( - Effect.tap((v) => - Effect.sync(() => { - expect(Option.getOrNull(v.pgDelta.npmVersion)).toBe("9.9.9-test"); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); - - it.effect("leaves npm version None for an empty .temp/pgdelta-version", () => { - const dir = withConfig(["[experimental.pgdelta]", "enabled = true", ""].join("\n")); - mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); - writeFileSync(join(dir, "supabase", ".temp", "pgdelta-version"), " \n"); - return read(dir).pipe( - Effect.tap((v) => - Effect.sync(() => { - expect(Option.isNone(v.pgDelta.npmVersion)).toBe(true); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); }); describe("legacyResolveDeclarativeDir", () => { @@ -2815,7 +2778,6 @@ describe("legacyResolveDeclarativeDir", () => { enabled: false, declarativeSchemaPath: Option.none(), formatOptions: Option.none(), - npmVersion: Option.none(), }), ).toBe(join("supabase", "schemas")); }).pipe(Effect.provide(BunServices.layer)), @@ -2829,7 +2791,6 @@ describe("legacyResolveDeclarativeDir", () => { enabled: true, declarativeSchemaPath: Option.some(join("supabase", "db", "decl")), formatOptions: Option.none(), - npmVersion: Option.none(), }), ).toBe(join("supabase", "db", "decl")); }).pipe(Effect.provide(BunServices.layer)), diff --git a/apps/cli/src/legacy/shared/legacy-db-push-core.ts b/apps/cli/src/legacy/shared/legacy-db-push-core.ts index 073f663f31..aea0b1dbd2 100644 --- a/apps/cli/src/legacy/shared/legacy-db-push-core.ts +++ b/apps/cli/src/legacy/shared/legacy-db-push-core.ts @@ -3,17 +3,7 @@ import { Effect, FileSystem, Option, Path } from "effect"; import { legacyPromptYesNo } from "../../shared/legacy/legacy-prompt-yes-no.ts"; import { CONTEXT_CANCELED_MESSAGE } from "../../shared/output/errors.ts"; import { Output } from "../../shared/output/output.service.ts"; -import { - legacyListLocalMigrations, - legacyTryCacheMigrationsCatalog, -} from "./legacy-pgdelta.cache.ts"; -import { type LegacyPgDeltaContext } from "./legacy-pgdelta.ts"; -import { legacyParseBoolEnv } from "./legacy-diff-engine.ts"; -import { - LEGACY_PG_DELTA_NEXT_FLAG_NAME, - legacyPgDeltaImplementationFlag, - legacyResolvePgDeltaImplementation, -} from "./legacy-pgdelta-next-flag.ts"; +import { legacyListLocalMigrations } from "./legacy-migration-list.ts"; import { LEGACY_ERR_MISSING_LOCAL, LEGACY_ERR_MISSING_REMOTE, @@ -31,15 +21,12 @@ import { } from "../commands/db/push/push.errors.ts"; import { legacyAqua, legacyBold } from "./legacy-colors.ts"; import type { LegacyDbTomlValues } from "./legacy-db-config.toml-read.ts"; -import { redactLegacyConnectionString } from "./legacy-db-config.parse.ts"; import { LegacyDbConnection, type LegacyPgConnInput } from "./legacy-db-connection.service.ts"; -import { legacyResolveLocalProjectId, legacySanitizeProjectId } from "./legacy-docker-ids.ts"; import { legacyApplyMigrations, legacySeedGlobals } from "./legacy-migration-apply.ts"; import { legacyListRemoteMigrations, legacySuggestRevertHistory, } from "./legacy-migration-history.ts"; -import { legacyToPostgresURL } from "./legacy-postgres-url.ts"; import { legacyUpsertVaultSecrets } from "./legacy-vault.ts"; const CUSTOM_ROLES_PATH = "supabase/roles.sql"; @@ -169,7 +156,6 @@ export const legacyDbPushCore = Effect.fnUntraced(function* (input: LegacyDbPush includeSeed, includeVault, dnsResolver, - projectId, toml, yes, emitStructuredResult, @@ -324,55 +310,6 @@ export const legacyDbPushCore = Effect.fnUntraced(function* (input: LegacyDbPush yield* legacyUpsertVaultSecrets(session, vaultSecrets); } yield* legacyApplyMigrations(session, fs, path, pending, applyError); - const cacheEnabled = - toml.pgDelta.enabled || - legacyParseBoolEnv(toml.envLookup("SUPABASE_EXPERIMENTAL_PG_DELTA")); - const pgDeltaImplementation = legacyResolvePgDeltaImplementation( - legacyPgDeltaImplementationFlag( - process.env[LEGACY_PG_DELTA_NEXT_FLAG_NAME], - toml.projectEnv[LEGACY_PG_DELTA_NEXT_FLAG_NAME], - ), - ); - const pgDeltaCtx: LegacyPgDeltaContext = { - // `flags.LoadConfig` seeds `Config.ProjectId = ProjectRef` before - // `Config.Load` runs, so an absent config.toml `project_id` retains the - // linked ref, not the workdir basename — that fallback only applies when - // `flags.ProjectRef` is unset (`--local`/`--db-url`, where `projectRef` is - // `""` here too, see `LegacyDbPushCoreInput.projectId`'s doc comment). - // `legacyResolveLocalProjectId` itself only knows the env/toml/basename - // tiers, so splice this third tier in by feeding it as `tomlProjectId`'s - // own fallback rather than widening that helper's signature for its two - // other (local-only, `projectRef`-less) callers. - projectId: legacySanitizeProjectId( - legacyResolveLocalProjectId( - Option.getOrUndefined(projectId), - Option.getOrUndefined(toml.projectId) ?? - (projectRef !== "" ? projectRef : undefined), - workdir, - ), - ), - cwd: workdir, - npmVersion: Option.getOrUndefined(toml.pgDelta.npmVersion), - denoVersion: toml.denoVersion, - projectEnv: toml.projectEnv, - }; - yield* legacyTryCacheMigrationsCatalog(fs, path, pgDeltaCtx, { - // The catalog is an alpha.33-only artifact with no next-engine - // consumer. Default-next commands deliberately skip this obsolete - // warmup so a successful push/bootstrap cannot start edge-runtime. - enabled: cacheEnabled && pgDeltaImplementation === "legacy", - targetUrl: legacyToPostgresURL(conn), - conn, - isLocal, - migrationsDir: path.join(workdir, "supabase", "migrations"), - }).pipe( - Effect.catch((error) => - output.raw( - `Warning: failed to cache migrations catalog: ${redactLegacyConnectionString(error.message)}\n`, - "stderr", - ), - ), - ); } else { yield* output.raw("Schema migrations are up to date.\n", "stderr"); } diff --git a/apps/cli/src/legacy/shared/legacy-glob.ts b/apps/cli/src/legacy/shared/legacy-glob.ts index 20e2b2d51f..7c76f91f5e 100644 --- a/apps/cli/src/legacy/shared/legacy-glob.ts +++ b/apps/cli/src/legacy/shared/legacy-glob.ts @@ -115,7 +115,7 @@ export const legacyGlobPattern = ( * supplementary-plane codepoint (`>= U+10000 > U+FFFF`) AFTER it. Verified empirically: * `["a\u{1F600}.sql","a.sql"].sort()` (default) disagrees with `Buffer.compare` on the * same two strings' UTF-8 bytes. Used for every `sort.Strings` this module (and its callers - * across `legacy-shadow-source.ts`/`legacy-pgdelta.cache.ts`) ports, so a directory with such + * across `legacy-shadow-source.ts`/`legacy-migration-list.ts`) ports, so a directory with such * filenames applies/lists in the same order Go would. */ export function legacyCompareUtf8Bytes(a: string, b: string): number { diff --git a/apps/cli/src/legacy/shared/legacy-migration-history.ts b/apps/cli/src/legacy/shared/legacy-migration-history.ts index 3242eef54a..e6ac4e378a 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-history.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-history.ts @@ -1,6 +1,6 @@ import { Effect, type FileSystem, Option, type Path } from "effect"; -import { legacyListLocalMigrations } from "./legacy-pgdelta.cache.ts"; +import { legacyListLocalMigrations } from "./legacy-migration-list.ts"; import { legacyBold } from "./legacy-colors.ts"; import { legacyCompareUtf8Bytes } from "./legacy-glob.ts"; import type { LegacyDbExecError } from "./legacy-db-connection.errors.ts"; diff --git a/apps/cli/src/legacy/shared/legacy-migration-list.ts b/apps/cli/src/legacy/shared/legacy-migration-list.ts new file mode 100644 index 0000000000..8bb168a6a9 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-migration-list.ts @@ -0,0 +1,96 @@ +import { Effect, Option, type FileSystem, type Path } from "effect"; + +import { Output } from "../../shared/output/output.service.ts"; +import { legacyCompareUtf8Bytes } from "./legacy-glob.ts"; +import { LegacyMigrationsReadError } from "./legacy-migration.errors.ts"; + +// `pkg/migration/list.go` — `<14-digit>_init.sql` first migrations (pre-2021-12-09) are skipped. +const INIT_SCHEMA_PATTERN = /([0-9]{14})_init\.sql/; +const INIT_SCHEMA_CUTOFF = 20211209000000; +// `pkg/migration/file.go` — valid migration filenames. +const MIGRATE_FILE_PATTERN = /^([0-9]+)_(.*)\.sql$/; + +/** + * Lists local migration file paths under `migrationsDir`. Mirrors Go's + * `migration.ListLocalMigrations` (`pkg/migration/list.go:33`): entries are sorted by name — Go's + * `fs.ReadDir` byte-wise UTF-8 order, via {@link legacyCompareUtf8Bytes}, not JS's default + * UTF-16-code-unit `Array.prototype.sort()` — directories skipped, a deprecated + * `<14-digit>_init.sql` first migration (pre-2021-12-09) is skipped, and names must match + * `_*.sql`. + * + * Each skipped file emits a byte-exact stderr warning matching Go's + * `fmt.Fprintf(os.Stderr, …)` (`list.go:45-53`) — same wording for both the + * deprecated-init and misnamed-file cases. Because this is the shared lister, + * the warning fires for the `db diff/pull/schema declarative` and pgcache paths + * too, not only the `migration` commands, exactly as in Go. + */ +export const legacyListLocalMigrations = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + migrationsDir: string, +) { + const output = yield* Output; + // Mirror Go's single `fs.ReadDir` (`pkg/migration/list.go:34-37`): only a + // not-exist directory is "no migrations"; every other read error (the path is a + // file → `ENOTDIR`, permission denied, …) aborts rather than silently letting + // smart generate/sync believe there are no local migrations. Effect surfaces + // "not found" as a `PlatformError` with a `SystemError` reason tagged `"NotFound"`. + const names = yield* fs.readDirectory(migrationsDir).pipe( + Effect.catchTag("PlatformError", (error) => + error.reason._tag === "NotFound" + ? Effect.succeed([] as ReadonlyArray) + : Effect.fail( + new LegacyMigrationsReadError({ + message: `failed to read directory: ${error.message}`, + }), + ), + ), + ); + if (names.length === 0) return [] as ReadonlyArray; + // Go's `fs.ReadDir` (`pkg/migration/list.go:34`) returns entries sorted byte-wise over each + // name's UTF-8 encoding — NOT JS's default `Array.prototype.sort()`, which compares UTF-16 code + // units and disagrees with byte/codepoint order for a supplementary-plane filename character + // alongside a BMP private-use one (see {@link legacyCompareUtf8Bytes}'s own doc comment, + // verified empirically there against both Go's `sort.Strings` and `os.ReadDir`). Left + // uncorrected, such a migrations directory would replay in a different order than Go, and a + // dependent migration could fail or produce a different shadow schema (review: + // PRRT_kwDOErm0O86W3OyD). + const sorted = [...names].sort(legacyCompareUtf8Bytes); + const result: Array = []; + for (let index = 0; index < sorted.length; index++) { + const name = sorted[index]!; + const entryPath = path.join(migrationsDir, name); + // Go's `os.ReadDir`/`DirEntry.IsDir()` (`pkg/migration/list.go:34-43`) classifies a + // directory entry from its own type without following symlinks (verified empirically: + // `DirEntry.IsDir()` reports `false` for a `.sql` symlink whose target is a directory) — + // so a symlinked migration is never skipped as a directory in Go, only later, when + // `ApplyMigrations` fails to read it as a regular file. `fs.stat` below follows + // symlinks, so it would misclassify a symlink-to-directory as a plain directory and + // silently skip it here instead. Check `readLink` (which only succeeds for a symlink) + // first and skip the directory check entirely for symlinks, matching Go's `IsDir()`. + const isSymlink = Option.isSome(yield* fs.readLink(entryPath).pipe(Effect.option)); + if (!isSymlink) { + const stat = yield* fs.stat(entryPath).pipe(Effect.option); + if (Option.isSome(stat) && stat.value.type === "Directory") continue; + } + if (index === 0) { + const init = INIT_SCHEMA_PATTERN.exec(name); + if (init !== null && Number(init[1]) < INIT_SCHEMA_CUTOFF) { + yield* output.raw( + `Skipping migration ${name}... (replace "init" with a different file name to apply this migration)\n`, + "stderr", + ); + continue; + } + } + if (!MIGRATE_FILE_PATTERN.test(name)) { + yield* output.raw( + `Skipping migration ${name}... (file name must match pattern "_name.sql")\n`, + "stderr", + ); + continue; + } + result.push(entryPath); + } + return result as ReadonlyArray; +}); diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta-next-flag.ts b/apps/cli/src/legacy/shared/legacy-pgdelta-next-flag.ts deleted file mode 100644 index 2d2ce09f5c..0000000000 --- a/apps/cli/src/legacy/shared/legacy-pgdelta-next-flag.ts +++ /dev/null @@ -1,41 +0,0 @@ -export type LegacyPgDeltaImplementation = "next" | "legacy"; - -/** The env var name for the pg-delta implementation rollout flag. */ -export const LEGACY_PG_DELTA_NEXT_FLAG_NAME = "SUPABASE_USE_PG_DELTA_NEXT"; - -/** - * Combines the shell and project-`.env` values of the pg-delta rollout flag - * into the one raw value `legacyResolvePgDeltaImplementation` consumes. - * - * godotenv.Load never replaces a shell value, including an empty or invalid - * one, so presence in `process.env` must suppress the project-file fallback — - * this is the single source of truth for that precedence; every reader of - * this flag must combine its shell/project values through this function - * rather than reimplementing the rule (e.g. via `envLookup`, which treats an - * empty shell value as unset and does not apply here). - */ -export const legacyPgDeltaImplementationFlag = ( - shellValue: string | undefined, - projectValue: string | undefined, -) => shellValue ?? projectValue; - -/** - * Resolves the pg-delta implementation rollout flag from one raw environment - * value. Defaults to the next implementation when unset or not an explicit - * false; only known false spellings select the legacy implementation. - * - * The caller owns reading `process.env`, allowing the strategy boundary to - * resolve the selection exactly once per command invocation. - */ -export function legacyResolvePgDeltaImplementation( - raw: string | undefined, -): LegacyPgDeltaImplementation { - switch (raw?.toLowerCase()) { - case "0": - case "f": - case "false": - return "legacy"; - default: - return "next"; - } -} diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta-next-flag.unit.test.ts b/apps/cli/src/legacy/shared/legacy-pgdelta-next-flag.unit.test.ts deleted file mode 100644 index b638a0c7ad..0000000000 --- a/apps/cli/src/legacy/shared/legacy-pgdelta-next-flag.unit.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - legacyPgDeltaImplementationFlag, - legacyResolvePgDeltaImplementation, -} from "./legacy-pgdelta-next-flag.ts"; - -describe("legacyPgDeltaImplementationFlag", () => { - it("prefers shell presence and otherwise uses the project value", () => { - expect(legacyPgDeltaImplementationFlag("true", "false")).toBe("true"); - expect(legacyPgDeltaImplementationFlag("", "false")).toBe(""); - expect(legacyPgDeltaImplementationFlag(undefined, "false")).toBe("false"); - }); -}); - -describe("legacyResolvePgDeltaImplementation", () => { - it("defaults to the next implementation when unset", () => { - expect(legacyResolvePgDeltaImplementation(undefined)).toBe("next"); - }); - - it.each(["1", "t", "TRUE", "true", "True", "yes", "on", "", "garbage"])( - "selects the next implementation for %j", - (raw) => { - expect(legacyResolvePgDeltaImplementation(raw)).toBe("next"); - }, - ); - - it.each(["0", "f", "F", "FALSE", "false", "False"])( - "selects the legacy implementation for %s", - (raw) => { - expect(legacyResolvePgDeltaImplementation(raw)).toBe("legacy"); - }, - ); -}); diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta-ssl.ts b/apps/cli/src/legacy/shared/legacy-pgdelta-ssl.ts index 0c27703319..b925ce4164 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta-ssl.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta-ssl.ts @@ -1,115 +1,8 @@ -import { Effect, type FileSystem, type Path } from "effect"; - -import { LegacyPgDeltaSslProbe } from "./legacy-pgdelta-ssl-probe.service.ts"; - /** - * pg-delta SSL handling for remote Postgres endpoints. Ported from Go's - * `internal/gen/types/pgdelta_conn.go` + `types.go`. pg-delta (Deno) disables - * TLS when `sslmode` is absent and only reads `PGDELTA_*_SSLROOTCERT` for - * verify-ca/verify-full, so a TLS-requiring endpoint needs a CA bundle written - * into the workspace and the URL rewritten to `sslmode=verify-ca`. - * - * Mirroring Go's `pgDeltaRootCA`, the decision runs for EVERY postgres URL (not - * just Supabase hosts): a live `SSLRequest` probe (`isRequireSSL`) determines - * whether the server speaks TLS; if it does, the bundle is injected. Supabase-hosted - * URLs additionally get the bundle as a fallback even if the probe reports no TLS. - * Only a non-URL ref (a catalog-file path) or a server that refuses TLS (e.g. a - * plain local DB) passes through unchanged. + * The Supabase CA bundle for TLS-requiring remote Postgres endpoints + * (concatenation of Go's embedded `caStaging + caProd + caSnap` bundles, + * verbatim). The migra engine passes it to its edge-runtime script as `SSL_CA` + * when the `LegacyPgDeltaSslProbe` reports the server requires TLS. */ - -const PG_DELTA_CA_BUNDLE_DIR_SEGMENTS = ["supabase", ".temp", "pgdelta"] as const; - -/** Concatenation of Go's embedded `caStaging + caProd + caSnap` bundles (verbatim). */ export const LEGACY_PG_DELTA_CA_BUNDLE = "-----BEGIN CERTIFICATE-----\nMIID1DCCArygAwIBAgIUbYRdq/8/uNq8G9stMCdOFSBgA2MwDQYJKoZIhvcNAQEL\nBQAwczELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l\ndyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEmMCQGA1UEAwwdU3VwYWJh\nc2UgU3RhZ2luZyBSb290IDIwMjEgQ0EwHhcNMjEwNDI4MTAzNjEzWhcNMzEwNDI2\nMTAzNjEzWjBzMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRGVsd2FyZTETMBEGA1UE\nBwwKTmV3IENhc3RsZTEVMBMGA1UECgwMU3VwYWJhc2UgSW5jMSYwJAYDVQQDDB1T\ndXBhYmFzZSBTdGFnaW5nIFJvb3QgMjAyMSBDQTCCASIwDQYJKoZIhvcNAQEBBQAD\nggEPADCCAQoCggEBAN0AKRE8a56O8LaZxiOAcHFUFnwiKUvPoXPq26Ifw+Nv+7zg\nN2V5WnMZbbw24q61Os60ZUn0XmbVtuIeJ+stPHsO7qxxuL+bmPR+qU5tkDrIOyEe\nYD/2u8/q6ssVv42k4XcXbhM6RVz7CkCDY0TiBm1bMtRZso3xB6E9wAjxDf43XfV5\nPAGs3JI+Zo/vyqCDlN0hHOrB/aBl01JXqQWI84Gia5ooucq4SjA1CyawBcQ2IAvG\nrXuy1BouY+xM3zRuNvtfFP6rb5Mta+jCYEMh1AZ8yP8sYUWAyhxX6k9EbOb009wQ\naZljbUCh/UglGWuBxdzePavx+zPjzWXB1NyVkpkCAwEAAaNgMF4wCwYDVR0PBAQD\nAgEGMB0GA1UdDgQWBBQFx+PHLf27iIo/PMfIfGqXF7Zb+DAfBgNVHSMEGDAWgBQF\nx+PHLf27iIo/PMfIfGqXF7Zb+DAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEB\nCwUAA4IBAQB/xIiz5dDqzGXjqYqXZYx4iSfSxsVayeOPDMfmaiCfSMJEUG4cUiwG\nOvMPGztaUEYeip5SCvSKuAAjVkXyP7ahKR7t7lZ9mErVXyxSZoVLbOd578CuYiZk\nOgT17UjPv66WMzEKEr8wGpomTYWWfEkuqt8ENdiM1Z4LNFahdKj36+jm6/a+9R8K\n25VIL68DTaQpBxFWG6ixC1HRMHJ12lDhKsshIi099BVpkGibESlxPrQOdKKqBB/J\nvIX+/Hb+mS4H5zYMeK2wX0onp+GBcD6X9L1UJuXMVd+BRan8RFidXL5s3++xXjQq\nNzbc6lnA69urKffvcT07YwMsY/OmHzVa\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIDxDCCAqygAwIBAgIUbLxMod62P2ktCiAkxnKJwtE9VPYwDQYJKoZIhvcNAQEL\nBQAwazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l\ndyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJh\nc2UgUm9vdCAyMDIxIENBMB4XDTIxMDQyODEwNTY1M1oXDTMxMDQyNjEwNTY1M1ow\nazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5ldyBD\nYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJhc2Ug\nUm9vdCAyMDIxIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqQXW\nQyHOB+qR2GJobCq/CBmQ40G0oDmCC3mzVnn8sv4XNeWtE5XcEL0uVih7Jo4Dkx1Q\nDmGHBH1zDfgs2qXiLb6xpw/CKQPypZW1JssOTMIfQppNQ87K75Ya0p25Y3ePS2t2\nGtvHxNjUV6kjOZjEn2yWEcBdpOVCUYBVFBNMB4YBHkNRDa/+S4uywAoaTWnCJLUi\ncvTlHmMw6xSQQn1UfRQHk50DMCEJ7Cy1RxrZJrkXXRP3LqQL2ijJ6F4yMfh+Gyb4\nO4XajoVj/+R4GwywKYrrS8PrSNtwxr5StlQO8zIQUSMiq26wM8mgELFlS/32Uclt\nNaQ1xBRizkzpZct9DwIDAQABo2AwXjALBgNVHQ8EBAMCAQYwHQYDVR0OBBYEFKjX\nuXY32CztkhImng4yJNUtaUYsMB8GA1UdIwQYMBaAFKjXuXY32CztkhImng4yJNUt\naUYsMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAB8spzNn+4VU\ntVxbdMaX+39Z50sc7uATmus16jmmHjhIHz+l/9GlJ5KqAMOx26mPZgfzG7oneL2b\nVW+WgYUkTT3XEPFWnTp2RJwQao8/tYPXWEJDc0WVQHrpmnWOFKU/d3MqBgBm5y+6\njB81TU/RG2rVerPDWP+1MMcNNy0491CTL5XQZ7JfDJJ9CCmXSdtTl4uUQnSuv/Qx\nCea13BX2ZgJc7Au30vihLhub52De4P/4gonKsNHYdbWjg7OWKwNv/zitGDVDB9Y2\nCMTyZKG3XEu5Ghl1LEnI3QmEKsqaCLv12BnVjbkSeZsMnevJPs1Ye6TjjJwdik5P\no/bKiIz+Fq8=\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIDxzCCAq+gAwIBAgIUeX+gpfmsRW9asFkRvjyXjHxbfgcwDQYJKoZIhvcNAQEL\nBQAwazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l\ndyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJh\nc2UgUm9vdCAyMDIxIENBMB4XDTI1MDkwMzA4MDEyNVoXDTM1MDkwMTA4MDEyNVow\nazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5ldyBD\nYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJhc2Ug\nUm9vdCAyMDIxIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5Ve7\ni9UAmc7luUilELPtqzEk8nGHxg7nY0aCStr625M7+K4OPO6RUllTsHh47k1jWyzm\nLXLlyYwCsYCjQp+3vn06H+F/HRUxBt6CK2B7bNng230exTunk0xFvfkX6YgHR7B3\n1B7L25Rq3PhuRFPV4hnGYRam2XBZC4UNPqoAgrhV0HOYzXXAVoTr2yaBTMnB331Z\nRwOmINh7eqTCk/JRZbb6vfZOhZRAVAe9AoRLoG8aKwmeoLGwlu0UuFx6z3E+6bmA\nfSNa8Lx02GEoCdPLw9IRKUFq/SgBpQUKm44H1fDwTjH2CMM0N4p0mL/6wXnNeHvt\nC40MmKZ0RcVmHE5wBwIDAQABo2MwYTAdBgNVHQ4EFgQUjvEE541toZcwtXQlZlcB\nYOBRTnowHwYDVR0jBBgwFoAUjvEE541toZcwtXQlZlcBYOBRTnowDwYDVR0TAQH/\nBAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQELBQADggEBACD5IcGP\nXKvS9qg0CgEQPFqYavt5c7P+0xxFgiZe+xoG8fUw58yNeK2APtgGPRpxEOGfAlNx\nz9HDt4gcyHEE00B3qAVDm49pqNxioFWzNqU2LGfM/HL1QmN6urR7hCOkVCJddvOc\nFhFX4nZDuRfaBboDvS5HlK3Pzxddp9hvrJi2bemr8HLqYc3HzmVckgPGSLML6t+h\n4LRCXSlQsDgQ1LZ4KHsl4cq7K51N6FOXQBLB5q4lMKhs0VUhCT8Pdsj12+84laCV\nc22q6p2mdT9SaernCSRnWazXWisgpjv3H7Ex4S1DCYjJIwn3PUToGFv1r8YRN2/S\nO19yVSxxCIf64Sg=\n-----END CERTIFICATE-----\n"; - -/** Source/target distinct CA filenames (Go's `caBundleFilename`). */ -export const LEGACY_PG_DELTA_SOURCE_SSL_ENV = "PGDELTA_SOURCE_SSLROOTCERT"; -export const LEGACY_PG_DELTA_TARGET_SSL_ENV = "PGDELTA_TARGET_SSLROOTCERT"; - -const caBundleFilename = (sslRootCertEnv: string): string => - sslRootCertEnv === LEGACY_PG_DELTA_SOURCE_SSL_ENV - ? "pgdelta-source-ca.crt" - : sslRootCertEnv === LEGACY_PG_DELTA_TARGET_SSL_ENV - ? "pgdelta-target-ca.crt" - : "pgdelta-ca.crt"; - -/** Mirrors Go's `isPostgresURL`. */ -const legacyIsPostgresUrl = (ref: string): boolean => - ref.startsWith("postgres://") || ref.startsWith("postgresql://"); - -/** Mirrors Go's `isSupabaseHostedPostgresURL`. */ -export function legacyIsSupabaseHostedPostgresUrl(dbUrl: string): boolean { - let host: string; - try { - host = new URL(dbUrl).hostname.toLowerCase(); - } catch { - return false; - } - return ( - host.endsWith(".supabase.co") || - host === "pooler.supabase.com" || - host.endsWith(".pooler.supabase.com") - ); -} - -/** Mirrors Go's `ensurePgDeltaSSL`: force `sslmode=verify-ca` (unless already verify-*) + `sslrootcert`. */ -export function legacyEnsurePgDeltaSsl(dbUrl: string, sslRootCertPath: string): string { - let parsed: URL; - try { - parsed = new URL(dbUrl); - } catch { - return dbUrl; - } - const sslmode = parsed.searchParams.get("sslmode"); - if (sslmode !== "verify-ca" && sslmode !== "verify-full") { - parsed.searchParams.set("sslmode", "verify-ca"); - } - if (sslRootCertPath.length > 0) parsed.searchParams.set("sslrootcert", sslRootCertPath); - return parsed.toString(); -} - -/** - * Mirrors Go's `pgDeltaRootCA` (`internal/gen/types/pgdelta_conn.go:37`): probe the - * endpoint for TLS (`GetRootCA` → `isRequireSSL`); if it speaks TLS, the embedded - * bundle is needed. A Supabase-hosted URL gets the bundle regardless (fallback for - * when the probe is skipped or reports no TLS). Otherwise no bundle. - */ -const legacyPgDeltaNeedsRootCa = Effect.fnUntraced(function* (ref: string) { - const probe = yield* LegacyPgDeltaSslProbe; - const requireSsl = yield* probe.requireSsl(ref); - return requireSsl || legacyIsSupabaseHostedPostgresUrl(ref); -}); - -/** - * Prepares a SOURCE/TARGET ref + its SSL env for pg-delta. Catalog-file refs pass - * through unchanged; a postgres URL is probed for TLS (Go's `pgDeltaRootCA`) and, - * when TLS is required (or it is a Supabase-hosted host), gets the embedded CA bundle - * written under `supabase/.temp/pgdelta/` and the URL rewritten to `sslmode=verify-ca`. - * Mirrors Go's `PreparePgDeltaPostgresRef`. - */ -export const legacyPreparePgDeltaRef = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - cwd: string, - ref: string, - sslRootCertEnv: string, -) { - // Go only short-circuits on a non-postgres ref (`if !isPostgresURL(ref)`); a - // catalog-file path needs no SSL handling. - if (!legacyIsPostgresUrl(ref)) { - return { ref, sslEnv: {} as Record }; - } - if (!(yield* legacyPgDeltaNeedsRootCa(ref))) { - return { ref, sslEnv: {} as Record }; - } - const relPath = path.join(...PG_DELTA_CA_BUNDLE_DIR_SEGMENTS, caBundleFilename(sslRootCertEnv)); - const absPath = path.join(cwd, relPath); - yield* fs.makeDirectory(path.dirname(absPath), { recursive: true }).pipe(Effect.ignore); - yield* fs.writeFileString(absPath, LEGACY_PG_DELTA_CA_BUNDLE); - const containerCertPath = `/workspace/${relPath.split("\\").join("/")}`; - return { - ref: legacyEnsurePgDeltaSsl(ref, containerCertPath), - sslEnv: { [sslRootCertEnv]: LEGACY_PG_DELTA_CA_BUNDLE } as Record, - }; -}); diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta-ssl.unit.test.ts b/apps/cli/src/legacy/shared/legacy-pgdelta-ssl.unit.test.ts index c91cbc1afe..8cae53e9fb 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta-ssl.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta-ssl.unit.test.ts @@ -1,157 +1,6 @@ -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { BunServices } from "@effect/platform-bun"; -import { describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Path } from "effect"; +import { describe, expect, it } from "vitest"; -import { - LegacyPgDeltaSslProbe, - LegacyPgDeltaSslProbeError, -} from "./legacy-pgdelta-ssl-probe.service.ts"; -import { - LEGACY_PG_DELTA_CA_BUNDLE, - LEGACY_PG_DELTA_TARGET_SSL_ENV, - legacyEnsurePgDeltaSsl, - legacyIsSupabaseHostedPostgresUrl, - legacyPreparePgDeltaRef, -} from "./legacy-pgdelta-ssl.ts"; - -describe("legacyIsSupabaseHostedPostgresUrl", () => { - it("recognizes Supabase-hosted hosts", () => { - expect( - legacyIsSupabaseHostedPostgresUrl("postgresql://x@db.abc.supabase.co:5432/postgres"), - ).toBe(true); - expect( - legacyIsSupabaseHostedPostgresUrl("postgresql://x@pooler.supabase.com:6543/postgres"), - ).toBe(true); - expect( - legacyIsSupabaseHostedPostgresUrl("postgresql://x@abc.pooler.supabase.com:6543/postgres"), - ).toBe(true); - }); - - it("rejects local + non-Supabase hosts and unparseable URLs", () => { - expect(legacyIsSupabaseHostedPostgresUrl("postgresql://x@127.0.0.1:54322/postgres")).toBe( - false, - ); - expect(legacyIsSupabaseHostedPostgresUrl("postgresql://x@db.example.com:5432/postgres")).toBe( - false, - ); - expect(legacyIsSupabaseHostedPostgresUrl("not a url")).toBe(false); - }); -}); - -describe("legacyEnsurePgDeltaSsl", () => { - it("forces sslmode=verify-ca and sets sslrootcert", () => { - const out = legacyEnsurePgDeltaSsl( - "postgresql://u:p@db.abc.supabase.co:5432/postgres?connect_timeout=10", - "/workspace/supabase/.temp/pgdelta/pgdelta-target-ca.crt", - ); - expect(out).toContain("sslmode=verify-ca"); - expect(out).toContain( - "sslrootcert=%2Fworkspace%2Fsupabase%2F.temp%2Fpgdelta%2Fpgdelta-target-ca.crt", - ); - expect(out).toContain("connect_timeout=10"); - }); - - it("preserves an existing verify-full sslmode", () => { - const out = legacyEnsurePgDeltaSsl("postgresql://h/db?sslmode=verify-full", ""); - expect(out).toContain("sslmode=verify-full"); - }); -}); - -// Stub the live TLS probe so `legacyPreparePgDeltaRef` is testable without a server. -// `requireSsl` is what Go's `isRequireSSL` returns: true → server speaks TLS, -// false → server refused TLS, or a probe error (propagated like Go's `return false, err`). -const probeLayer = (requireSsl: boolean | "error") => - Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => - requireSsl === "error" - ? Effect.fail(new LegacyPgDeltaSslProbeError({ message: "connection refused" })) - : Effect.succeed(requireSsl), - requireSslForHost: () => - requireSsl === "error" - ? Effect.fail(new LegacyPgDeltaSslProbeError({ message: "connection refused" })) - : Effect.succeed(requireSsl), - }); - -const prepare = (cwd: string, ref: string, requireSsl: boolean | "error" = false) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - return yield* legacyPreparePgDeltaRef(fs, path, cwd, ref, LEGACY_PG_DELTA_TARGET_SSL_ENV); - }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, probeLayer(requireSsl)))); - -describe("legacyPreparePgDeltaRef", () => { - it.effect("passes through catalog-file refs without probing", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-ssl-")); - return Effect.gen(function* () { - const file = yield* prepare(dir, "supabase/.temp/pgdelta/catalog.json", "error"); - expect(file).toEqual({ ref: "supabase/.temp/pgdelta/catalog.json", sslEnv: {} }); - }).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); - }); - - it.effect("passes through a URL when the server refuses TLS (probe → not required)", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-ssl-")); - return Effect.gen(function* () { - const local = yield* prepare(dir, "postgresql://u:p@127.0.0.1:54322/postgres", false); - expect(local.ref).toBe("postgresql://u:p@127.0.0.1:54322/postgres"); - expect(local.sslEnv).toEqual({}); - }).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); - }); - - it.effect( - "injects the CA bundle for a non-Supabase remote that requires TLS (probe → required)", - () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-ssl-")); - return Effect.gen(function* () { - const prepared = yield* prepare(dir, "postgresql://u:p@db.example.com:5432/postgres", true); - expect(prepared.ref).toContain("sslmode=verify-ca"); - expect(prepared.ref).toContain("pgdelta-target-ca.crt"); - expect(prepared.sslEnv[LEGACY_PG_DELTA_TARGET_SSL_ENV]).toBe(LEGACY_PG_DELTA_CA_BUNDLE); - }).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); - }, - ); - - it.effect("propagates a probe connection error (Go's `return false, err`)", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-ssl-")); - return Effect.gen(function* () { - const exit = yield* prepare( - dir, - "postgresql://u:p@db.example.com:5432/postgres", - "error", - ).pipe(Effect.exit); - expect(exit._tag).toBe("Failure"); - }).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); - }); - - it.effect( - "writes the CA bundle for a Supabase-hosted remote even when the probe reports no TLS", - () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-ssl-")); - return Effect.gen(function* () { - // probe=false exercises Go's `pgDeltaRootCA` Supabase fallback branch. - const prepared = yield* prepare( - dir, - "postgresql://u:p@db.abc.supabase.co:5432/postgres", - false, - ); - expect(prepared.ref).toContain("sslmode=verify-ca"); - // sslrootcert is percent-encoded in the query string (matches Go's url.Values.Encode). - expect(prepared.ref).toContain("pgdelta-target-ca.crt"); - expect( - decodeURIComponent(new URL(prepared.ref).searchParams.get("sslrootcert") ?? ""), - ).toBe("/workspace/supabase/.temp/pgdelta/pgdelta-target-ca.crt"); - expect(prepared.sslEnv[LEGACY_PG_DELTA_TARGET_SSL_ENV]).toBe(LEGACY_PG_DELTA_CA_BUNDLE); - const written = readFileSync( - join(dir, "supabase", ".temp", "pgdelta", "pgdelta-target-ca.crt"), - "utf8", - ); - expect(written).toBe(LEGACY_PG_DELTA_CA_BUNDLE); - }).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); - }, - ); -}); +import { LEGACY_PG_DELTA_CA_BUNDLE } from "./legacy-pgdelta-ssl.ts"; describe("LEGACY_PG_DELTA_CA_BUNDLE", () => { it("concatenates the three Supabase CA certificates", () => { diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts b/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts deleted file mode 100644 index c9160d15ab..0000000000 --- a/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts +++ /dev/null @@ -1,1327 +0,0 @@ -import { createHash } from "node:crypto"; -import { Clock, Effect, type FileSystem, Option, type Path } from "effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import type { ChildProcessSpawner as ChildProcessSpawnerType } from "effect/unstable/process/ChildProcessSpawner"; - -import { - LegacyNetworkIdFlag, - legacyResolveDebugWithProjectEnv, -} from "../../shared/legacy/global-flags.ts"; -import { Output } from "../../shared/output/output.service.ts"; -import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; -import type { LegacyPgConnInput } from "./legacy-db-connection.service.ts"; -import { - type LegacyBaselineTomlConfig, - type LegacyDbTomlValues, - legacyReadDbToml, - legacyResolveDeclarativeDir, -} from "./legacy-db-config.toml-read.ts"; -import { legacyWalkSqlFiles } from "./legacy-glob.ts"; -import { legacyResolveDbImage } from "./legacy-db-image.ts"; -import { - legacyBuildLocalDbContainerInputs, - type LegacyLocalDbContainerInputs, -} from "./db-bootstrap/local-container-inputs.ts"; -import { legacyWaitForShadowReady } from "./db-bootstrap/health-check.ts"; -import { - legacyWithShadowDatabase, - type LegacyShadowAcquiredHandle, - type LegacyShadowCacheOpts, -} from "./db-bootstrap/shadow-cache.ts"; -import { - legacySetupShadowDatabase, - legacyShadowRunInputFromLocalContainerInputs, - type LegacyShadowSetupInput, -} from "./db-bootstrap/shadow-database.ts"; -import { legacyPgDeltaTempPath } from "./legacy-pgdelta.paths.ts"; -import { legacyCompareUtf8Bytes } from "./legacy-glob.ts"; -import { LegacyMigrationsReadError } from "./legacy-migration.errors.ts"; -import { legacyToPostgresURL } from "./legacy-postgres-url.ts"; -import { - type LegacyPgDeltaContext, - legacyExportCatalogPgDelta, - legacyResolvePgDeltaProjectId, -} from "./legacy-pgdelta.ts"; -import { legacyApplyDeclarativePgDelta } from "../commands/db/shared/legacy-pgdelta.apply.ts"; -import { LegacyDbConfigLoadError } from "./legacy-db-config.errors.ts"; -import { legacyPrepareShadowSource } from "../commands/db/shared/legacy-shadow-source.ts"; - -type Spawner = ChildProcessSpawnerType["Service"]; - -/** - * Declarative catalog-cache key builders + on-disk catalog resolution, based on - * Go (`apps/cli-go/internal/db/declarative/declarative.go` + - * `internal/db/pgcache/cache.go`). Byte-stable keys still matter for this CLI's - * own cache reuse under `supabase/.temp/pgdelta/` across runs — a drifting key - * would silently miss (re-provision) or over-hit (reuse a stale snapshot). Keys - * now intentionally diverge from the old Go binary for configs with - * `api.auto_expose_new_tables` unset: the effective value flipped to `true`, the - * baked cluster genuinely differs, and reusing a Go-era snapshot would mean - * reusing one with revoked grants. - * - * Beyond the pure key/path builders, this file also owns the migrations-catalog - * RESOLUTION path for both `db diff --from/--to migrations` and `db schema - * declarative sync` ({@link legacyResolveMigrationsCatalogRef}, - * {@link legacyGetMigrationsCatalogRef}) — including NATIVE shadow-database - * provisioning/removal (CLI-1956, {@link exportViaShadowCatalog}, the same - * `legacyCreateShadowDatabase`/`legacyPrepareShadowSource`/ - * `legacyRemoveShadowDatabase` primitives `db diff`/`db pull` use for their own - * shadow — no seam/subprocess involved) and the "Creating shadow database..." - * stderr side effect the latter prints on a cache miss. It is not a pure module. - */ - -const CATALOG_PREFIX_PATTERN = /[^a-zA-Z0-9._-]+/g; -const CATALOG_RETENTION_COUNT = 2; -// `pkg/migration/list.go` — `<14-digit>_init.sql` first migrations (pre-2021-12-09) are skipped. -const INIT_SCHEMA_PATTERN = /([0-9]{14})_init\.sql/; -const INIT_SCHEMA_CUTOFF = 20211209000000; -// `pkg/migration/file.go` — valid migration filenames. -const MIGRATE_FILE_PATTERN = /^([0-9]+)_(.*)\.sql$/; -// `internal/utils/misc.go` — `ProjectHostPattern`, matches a direct `db..supabase.{co,red}` host. -const PROJECT_HOST_PATTERN = /^(db\.)([a-z]{20})\.supabase\.(co|red)$/; - -/** Inputs that shape the legacy `WithLegacyPgNetBaseline` shadow setup. */ -export interface LegacySetupInputs { - /** The resolved Postgres image (`Config.Db.Image`); only its tag is used. */ - readonly image: string; - readonly majorVersion: number; - readonly authEnabled: boolean; - readonly storageEnabled: boolean; - readonly realtimeEnabled: boolean; - /** Effective `api.auto_expose_new_tables` (unset and `true` both → `true`). */ - readonly autoExpose: boolean; - /** `[db.vault]` secret names (sorted before hashing). */ - readonly vaultNames: ReadonlyArray; - /** Contents of `supabase/roles.sql` (empty string when absent). */ - readonly rolesSql: string; -} - -/** Mirrors Go's `sanitizedCatalogPrefix` (`declarative.go:765`). */ -export function legacySanitizedCatalogPrefix(prefix: string): string { - const trimmed = prefix.trim(); - if (trimmed.length === 0) return "local"; - return trimmed.replace(CATALOG_PREFIX_PATTERN, "-"); -} - -/** - * Mirrors Go's `pgcache.CatalogPrefixFromConfig` (`pgcache/cache.go`): `"local"` - * for the local dev database, the project ref for a direct `db..supabase.*` - * host, else a stable `url-` derived from the connection. - */ -export function legacyCatalogPrefixFromConfig( - conn: { - readonly host: string; - readonly port: number; - readonly user: string; - readonly database: string; - }, - isLocal: boolean, -): string { - if (isLocal) return "local"; - const match = PROJECT_HOST_PATTERN.exec(conn.host); - if (match?.[2] !== undefined) return match[2]; - const key = `${conn.user}@${conn.host}:${conn.port}/${conn.database}`; - const digest = createHash("sha256").update(key, "utf8").digest("hex"); - return `url-${digest.slice(0, 12)}`; -} - -/** Mirrors Go's `baselineVersionToken` (`declarative.go:665`): the image tag, or `pg`. */ -export function legacyBaselineVersionToken(image: string, majorVersion: number): string { - let tag = image.trim(); - const colon = tag.lastIndexOf(":"); - if (colon >= 0 && colon + 1 < tag.length) tag = tag.slice(colon + 1); - if (tag.trim().length === 0) tag = `pg${majorVersion}`; - return tag.replace(CATALOG_PREFIX_PATTERN, "-"); -} - -const boolToken = (value: boolean) => (value ? "true" : "false"); - -/** - * Mirrors Go's `setupInputsToken` (`declarative.go:688`): a 12-char hex digest of - * the platform-baseline inputs. The hashed byte sequence reproduces Go's - * `fmt.Fprintln`/`fmt.Fprintf` writes exactly so the key matches the Go binary's. - */ -export function legacySetupInputsToken(inputs: LegacySetupInputs): string { - const versionToken = legacyBaselineVersionToken(inputs.image, inputs.majorVersion); - let payload = `${versionToken}\n`; - payload += `auth=${boolToken(inputs.authEnabled)} storage=${boolToken( - inputs.storageEnabled, - )} realtime=${boolToken(inputs.realtimeEnabled)}\n`; - payload += `auto_expose_new_tables=${boolToken(inputs.autoExpose)}\n`; - for (const name of [...inputs.vaultNames].sort()) payload += `vault=${name}\n`; - payload += inputs.rolesSql; - return createHash("sha256").update(payload, "utf8").digest("hex").slice(0, 12); -} - -/** Mirrors Go's `baselineCatalogKey` (`declarative.go:729`): `-`. */ -export function legacyBaselineCatalogKey(inputs: LegacySetupInputs): string { - return `${legacyBaselineVersionToken(inputs.image, inputs.majorVersion)}-${legacySetupInputsToken( - inputs, - )}`; -} - -/** - * Resolves {@link LegacySetupInputs} from the caller's already-loaded db config: - * the resolved Postgres image, and `supabase/roles.sql`'s content (empty when - * absent, mirroring Go's `errors.Is(err, os.ErrNotExist)` tolerance in - * `setupInputsToken`, `apps/cli-go/internal/db/declarative/declarative.go:711-714`). - * Callers pass `toml.baseline` (`legacy-db-config.toml-read.ts`'s - * `LegacyBaselineTomlConfig`, already exactly this cache-key subset) verbatim. - */ -export const legacyResolveSetupInputs = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - workdir: string, - majorVersion: number, - orioledbVersion: string | undefined, - baseline: LegacyBaselineTomlConfig, -) { - const image = yield* legacyResolveDbImage(fs, path, workdir, majorVersion, orioledbVersion); - const rolesPath = path.join(workdir, "supabase", "roles.sql"); - const rolesSql = yield* fs - .readFileString(rolesPath) - .pipe( - Effect.catchTag("PlatformError", (error) => - error.reason._tag === "NotFound" ? Effect.succeed("") : Effect.fail(error), - ), - ); - return { - image, - majorVersion, - authEnabled: baseline.authEnabled, - storageEnabled: baseline.storageEnabled, - realtimeEnabled: baseline.realtimeEnabled, - autoExpose: Option.getOrElse(baseline.apiAutoExposeNewTables, () => true), - vaultNames: baseline.vaultNames, - rolesSql, - } satisfies LegacySetupInputs; -}); - -/** Mirrors Go's `declarativeCatalogCacheKey` (`declarative.go:753`): `-`. */ -export function legacyDeclarativeCatalogCacheKey(setupToken: string, schemaHash: string): string { - return `${setupToken}-${schemaHash}`; -} - -/** - * Mirrors Go's `migrationsCatalogCacheKey` (`declarative.go:765`): `- - * `. Used ONLY by {@link legacyGetMigrationsCatalogRef} (the - * `db schema declarative sync` migrations source) — `db diff`'s explicit - * `--from/--to migrations` uses a bare, setup-token-less hash instead (Go's - * `resolveMigrationsCatalogRef`, `internal/db/diff/explicit.go:88`; see - * {@link legacyResolveMigrationsCatalogRef}). These are deliberately two different - * cache-key schemes over the same `catalog-local-migrations-*.json` filename - * family, matching Go exactly (CLI-1959). - */ -export function legacyMigrationsCatalogCacheKey( - setupToken: string, - migrationsHash: string, -): string { - return `${setupToken}-${migrationsHash}`; -} - -/** `catalog-baseline-.json` (`declarative.go:44`). */ -export function legacyBaselineCatalogFileName(key: string): string { - return `catalog-baseline-${key}.json`; -} - -/** `catalog--declarative--.json` (`declarative.go:46`). */ -export function legacyDeclarativeCatalogFileName( - prefix: string, - hash: string, - timestampMillis: number, -): string { - return `catalog-${legacySanitizedCatalogPrefix(prefix)}-declarative-${hash}-${timestampMillis}.json`; -} - -/** - * Lists local migration file paths under `migrationsDir`. Mirrors Go's - * `migration.ListLocalMigrations` (`pkg/migration/list.go:33`): entries are sorted by name — Go's - * `fs.ReadDir` byte-wise UTF-8 order, via {@link legacyCompareUtf8Bytes}, not JS's default - * UTF-16-code-unit `Array.prototype.sort()` — directories skipped, a deprecated - * `<14-digit>_init.sql` first migration (pre-2021-12-09) is skipped, and names must match - * `_*.sql`. - * - * Each skipped file emits a byte-exact stderr warning matching Go's - * `fmt.Fprintf(os.Stderr, …)` (`list.go:45-53`) — same wording for both the - * deprecated-init and misnamed-file cases. Because this is the shared lister, - * the warning fires for the `db diff/pull/schema declarative` and pgcache paths - * too, not only the `migration` commands, exactly as in Go. - */ -export const legacyListLocalMigrations = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - migrationsDir: string, -) { - const output = yield* Output; - // Mirror Go's single `fs.ReadDir` (`pkg/migration/list.go:34-37`): only a - // not-exist directory is "no migrations"; every other read error (the path is a - // file → `ENOTDIR`, permission denied, …) aborts rather than silently letting - // smart generate/sync believe there are no local migrations. Effect surfaces - // "not found" as a `PlatformError` with a `SystemError` reason tagged `"NotFound"`. - const names = yield* fs.readDirectory(migrationsDir).pipe( - Effect.catchTag("PlatformError", (error) => - error.reason._tag === "NotFound" - ? Effect.succeed([] as ReadonlyArray) - : Effect.fail( - new LegacyMigrationsReadError({ - message: `failed to read directory: ${error.message}`, - }), - ), - ), - ); - if (names.length === 0) return [] as ReadonlyArray; - // Go's `fs.ReadDir` (`pkg/migration/list.go:34`) returns entries sorted byte-wise over each - // name's UTF-8 encoding — NOT JS's default `Array.prototype.sort()`, which compares UTF-16 code - // units and disagrees with byte/codepoint order for a supplementary-plane filename character - // alongside a BMP private-use one (see {@link legacyCompareUtf8Bytes}'s own doc comment, - // verified empirically there against both Go's `sort.Strings` and `os.ReadDir`). Left - // uncorrected, such a migrations directory would replay in a different order than Go, and a - // dependent migration could fail or produce a different shadow schema (review: - // PRRT_kwDOErm0O86W3OyD). - const sorted = [...names].sort(legacyCompareUtf8Bytes); - const result: Array = []; - for (let index = 0; index < sorted.length; index++) { - const name = sorted[index]!; - const entryPath = path.join(migrationsDir, name); - // Go's `os.ReadDir`/`DirEntry.IsDir()` (`pkg/migration/list.go:34-43`) classifies a - // directory entry from its own type without following symlinks (verified empirically: - // `DirEntry.IsDir()` reports `false` for a `.sql` symlink whose target is a directory) — - // so a symlinked migration is never skipped as a directory in Go, only later, when - // `ApplyMigrations` fails to read it as a regular file. `fs.stat` below follows - // symlinks, so it would misclassify a symlink-to-directory as a plain directory and - // silently skip it here instead. Check `readLink` (which only succeeds for a symlink) - // first and skip the directory check entirely for symlinks, matching Go's `IsDir()`. - const isSymlink = Option.isSome(yield* fs.readLink(entryPath).pipe(Effect.option)); - if (!isSymlink) { - const stat = yield* fs.stat(entryPath).pipe(Effect.option); - if (Option.isSome(stat) && stat.value.type === "Directory") continue; - } - if (index === 0) { - const init = INIT_SCHEMA_PATTERN.exec(name); - if (init !== null && Number(init[1]) < INIT_SCHEMA_CUTOFF) { - yield* output.raw( - `Skipping migration ${name}... (replace "init" with a different file name to apply this migration)\n`, - "stderr", - ); - continue; - } - } - if (!MIGRATE_FILE_PATTERN.test(name)) { - yield* output.raw( - `Skipping migration ${name}... (file name must match pattern "_name.sql")\n`, - "stderr", - ); - continue; - } - result.push(entryPath); - } - return result as ReadonlyArray; -}); - -/** - * Mirrors Go's `pgcache.HashMigrations` (`pgcache/cache.go`): for each local - * migration (in list order), hash its `workdir`-relative path then its - * contents. Returns full hex. - */ -export const legacyHashMigrations = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - workdir: string, - migrationsDir: string, -) { - const migrations = yield* legacyListLocalMigrations(fs, path, migrationsDir); - const hash = createHash("sha256"); - for (const filePath of migrations) { - const contents = yield* fs.readFile(filePath); - hash.update(path.relative(workdir, filePath), "utf8"); - hash.update(contents); - } - return hash.digest("hex"); -}); - -/** - * Walk the declarative dir for regular `.sql` files, byte-sort by relative path, and hash - * each file's forward-slash relative path then its contents. Returns full hex. - * - * Uses {@link legacyWalkSqlFiles} for the traversal, which gives three properties this cache - * key depends on: the walk is strict (a readDirectory/stat failure fails the hash rather than - * shrinking it into a possible stale-cache collision), it never follows symlinks (a directory - * symlink pointing at an ancestor would otherwise loop the walk forever, and symlinked `.sql` - * files are excluded like non-regular files), and each directory level plus the final list is - * byte-sorted so the key is stable across platforms. A missing root is the one tolerated - * case (deterministic empty hash; callers gate on the dir existing before catalog export) — - * `fs.exists` maps only not-found to `false`, so any other root failure (permissions, I/O) - * propagates instead of masquerading as an empty tree. - * - * Deliberate divergence from the old Go walk: a declarative root that is ITSELF a directory - * symlink is followed (entries beneath it still aren't). Go's lstat-rooted walk hashed such a - * root as an empty tree while the apply path followed the link and applied the target's files — - * a hash≠apply mismatch of exactly the stale-catalog class this function guards against. The - * cost is a one-time cache miss for symlinked-root setups. - */ -export const legacyHashDeclarativeSchemas = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - _path: Path.Path, - declarativeDir: string, -) { - const exists = yield* fs.exists(declarativeDir); - const files = exists ? yield* legacyWalkSqlFiles(fs, declarativeDir, "") : []; - const hash = createHash("sha256"); - for (const rel of files) { - const contents = yield* fs.readFile(`${declarativeDir}/${rel}`); - hash.update(rel, "utf8"); - hash.update(contents); - } - return hash.digest("hex"); -}); - -const parseCatalogTimestamp = (name: string): Option.Option => { - if (!name.endsWith(".json")) return Option.none(); - const raw = name.slice(0, -".json".length); - const idx = raw.lastIndexOf("-"); - if (idx < 0 || idx + 1 >= raw.length) return Option.none(); - const ts = Number(raw.slice(idx + 1)); - return Number.isInteger(ts) ? Option.some(ts) : Option.none(); -}; - -/** - * Mirrors Go's `ensureTempDir` + `ReadDir` pairing (`pgcache/cache.go`, - * `declarative.go`): the temp dir's existence is already guaranteed by the - * `MkdirAll` that runs before every write into it, so Go's `ReadDir` only ever - * needs to tolerate a genuinely missing directory (a cache that was never - * written to) — every OTHER read failure (e.g. permission denied) propagates, - * same as {@link legacyListLocalMigrations} above. Swallowing every failure - * (as an earlier version of this did) let a real read error silently look like - * "no cached catalogs", which both bypasses catalog resolution's cache HIT and - * — for cleanup's caller — bypasses the retention limit indefinitely, since - * the caller's own warning path never fires without a propagated failure. - */ -const listJsonEntries = Effect.fnUntraced(function* (fs: FileSystem.FileSystem, tempDir: string) { - return yield* fs.readDirectory(tempDir).pipe( - Effect.catchTag("PlatformError", (error) => - error.reason._tag === "NotFound" - ? Effect.succeed([] as ReadonlyArray) - : Effect.fail( - new LegacyMigrationsReadError({ - message: `failed to read directory: ${error.message}`, - }), - ), - ), - ); -}); - -/** - * Shared "highest suffixed timestamp wins" scan behind both - * {@link legacyResolveDeclarativeCatalogPath} and {@link legacyResolveMigrationCatalogPath}: - * of every `.json` entry in `tempDir`, returns the path with the - * highest `ts`. Mirrors both Go's `resolveDeclarativeCatalogPath` - * (`declarative.go:578`) and `pgcache.ResolveMigrationCatalogPath` - * (`internal/db/pgcache/cache.go:112-149`), which share this exact scan over their - * own filename family — only the family prefix differs between callers. - */ -const resolveLatestByFamily = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - tempDir: string, - familyPrefix: string, -) { - const entries = yield* listJsonEntries(fs, tempDir); - let latestPath = Option.none(); - let latest = -1; - for (const name of entries) { - if (!name.startsWith(familyPrefix) || !name.endsWith(".json")) continue; - const stamp = Number(name.slice(familyPrefix.length, -".json".length)); - if (Number.isInteger(stamp) && stamp > latest) { - latest = stamp; - latestPath = Option.some(path.join(tempDir, name)); - } - } - return latestPath; -}); - -/** - * Resolves the newest cached declarative catalog for `(hash, prefix)`. Mirrors - * Go's `resolveDeclarativeCatalogPath` (`declarative.go:578`): of all - * `catalog--declarative--.json`, returns the highest `ts`. - */ -export const legacyResolveDeclarativeCatalogPath = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - tempDir: string, - hash: string, - prefix: string, -) { - return yield* resolveLatestByFamily( - fs, - path, - tempDir, - `catalog-${legacySanitizedCatalogPrefix(prefix)}-declarative-${hash}-`, - ); -}); - -const cleanupOldCatalogsByFamily = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - tempDir: string, - familyPrefix: string, -) { - const entries = yield* listJsonEntries(fs, tempDir); - const files = entries - .filter((name) => name.startsWith(familyPrefix) && name.endsWith(".json")) - .map((name) => ({ name, timestamp: Option.getOrElse(parseCatalogTimestamp(name), () => 0) })) - .sort((a, b) => - b.timestamp === a.timestamp ? (a.name > b.name ? -1 : 1) : b.timestamp - a.timestamp, - ); - // Removal failures propagate: retention silently not being enforced would let - // snapshots accumulate indefinitely while every run reports a successful write. - for (let index = CATALOG_RETENTION_COUNT; index < files.length; index++) { - yield* fs.remove(path.join(tempDir, files[index]!.name)); - } -}); - -/** - * Removes all but the newest `catalogRetentionCount` declarative catalogs for a - * prefix family. Mirrors Go's `cleanupOldDeclarativeCatalogs` (`declarative.go:610`). - */ -export const legacyCleanupOldDeclarativeCatalogs = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - tempDir: string, - prefix: string, -) { - yield* cleanupOldCatalogsByFamily( - fs, - path, - tempDir, - `catalog-${legacySanitizedCatalogPrefix(prefix)}-declarative-`, - ); -}); - -/** - * Removes all but the newest `catalogRetentionCount` migrations catalogs for a - * prefix family. Mirrors Go's `pgcache.CleanupOldMigrationCatalogs` (`pgcache/cache.go`). - */ -export const legacyCleanupOldMigrationCatalogs = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - tempDir: string, - prefix: string, -) { - yield* cleanupOldCatalogsByFamily( - fs, - path, - tempDir, - `catalog-${legacySanitizedCatalogPrefix(prefix)}-migrations-`, - ); -}); - -/** `catalog--migrations--.json` (Go's `migrationsCatalogName`, `pgcache/cache.go`). */ -export function legacyMigrationCatalogFileName( - prefix: string, - hash: string, - timestampMillis: number, -): string { - return `catalog-${legacySanitizedCatalogPrefix(prefix)}-migrations-${hash}-${timestampMillis}.json`; -} - -/** - * Resolves the newest cached migrations catalog for `(hash, prefix)`. Mirrors - * Go's `pgcache.ResolveMigrationCatalogPath` (`internal/db/pgcache/cache.go:112-149`). - * Go's fallback to a pre-timestamp legacy filename (`catalog--migrations- - * .json`, no `-` suffix) is intentionally NOT replicated: nothing in the - * Go tree writes that name any more — `pgcache.MigrationCatalogPath` has always - * produced the timestamped form since the fallback was added in the same commit - * (CLI-1959 go-parity-auditor finding) — so it is unreachable dead code on both - * sides. - */ -export const legacyResolveMigrationCatalogPath = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - tempDir: string, - hash: string, - prefix: string, -) { - return yield* resolveLatestByFamily( - fs, - path, - tempDir, - `catalog-${legacySanitizedCatalogPrefix(prefix)}-migrations-${hash}-`, - ); -}); - -/** - * Writes a migrations-catalog snapshot to `/catalog--migrations--.json` - * and prunes older snapshots for the same `(prefix)` family. Mirrors Go's - * `pgcache.WriteMigrationCatalogSnapshot` (`pgcache/cache.go`). - */ -export const legacyWriteMigrationCatalogSnapshot = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - tempDir: string, - prefix: string, - hash: string, - snapshot: string, - timestampMillis: number, -) { - yield* fs.makeDirectory(tempDir, { recursive: true }).pipe(Effect.ignore); - const filePath = path.join( - tempDir, - legacyMigrationCatalogFileName(prefix, hash, timestampMillis), - ); - yield* fs.writeFileString(filePath, snapshot); - yield* legacyCleanupOldMigrationCatalogs(fs, path, tempDir, prefix); - return filePath; -}); - -/** - * Best-effort caches the migrations catalog for pg-delta after a successful - * `db push` migration apply. Mirrors Go's `pgcache.TryCacheMigrationsCatalog` - * (`pgcache/cache.go`); `enabled` is resolved by the caller since it depends on - * already-loaded config. Reuses `legacyExportCatalogPgDelta` (Go's correct - * `diff/pgdelta.go` `ExportCatalogPgDelta`) rather than porting a second copy, - * so this can't reintroduce the `/workspace` mount bug `pgcache/cache.go` had - * (supabase/cli#5921). - * - * The snapshot's timestamp is read from `Clock` HERE — after `legacyHashMigrations` - * and `legacyExportCatalogPgDelta` (the network round-trip) have both resolved, - * immediately before the write — never accepted as a caller-supplied parameter. - * This mirrors Go's own call order exactly: `TryCacheMigrationsCatalog` - * (`pgcache/cache.go:71-91`) resolves `hash` and `snapshot` FIRST, and only THEN - * calls `WriteMigrationCatalogSnapshot`, which itself reads `time.Now().UTC()` - * (`pgcache/cache.go:151-163`) — i.e. Go's clock read happens LAST, right before - * the file write, not before the export. A caller capturing the timestamp before - * calling this function (review CLI-1958) would race a concurrent cache write - * from another process: Go would order the two snapshots by real write-time, but - * the early-captured timestamp could sort the wrong one as "latest" during - * catalog resolution/retention (`legacyResolveMigrationCatalogPath`, - * `legacyCleanupOldMigrationCatalogs`). - */ -export const legacyTryCacheMigrationsCatalog = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - ctx: LegacyPgDeltaContext, - params: { - readonly enabled: boolean; - readonly targetUrl: string; - readonly conn: { - readonly host: string; - readonly port: number; - readonly user: string; - readonly database: string; - }; - readonly isLocal: boolean; - readonly migrationsDir: string; - }, -) { - if (!params.enabled) return; - const prefix = legacyCatalogPrefixFromConfig(params.conn, params.isLocal); - const hash = yield* legacyHashMigrations(fs, path, ctx.cwd, params.migrationsDir); - const snapshot = yield* legacyExportCatalogPgDelta(ctx, { - targetRef: params.targetUrl, - role: "postgres", - }); - const nowMillis = yield* Clock.currentTimeMillis; - yield* legacyWriteMigrationCatalogSnapshot( - fs, - path, - legacyPgDeltaTempPath(path, ctx.cwd), - prefix, - hash, - snapshot, - nowMillis, - ); -}); - -/** The spawner + already-built local container inputs {@link exportViaShadowCatalog} needs. */ -interface LegacyShadowCatalogInputs { - readonly spawner: ChildProcessSpawnerType["Service"]; - readonly localInputs: LegacyLocalDbContainerInputs; -} - -/** - * Builds the {@link LegacyShadowCatalogInputs} {@link exportViaShadowCatalog} needs — the SAME - * second `@supabase/config` load (`legacyBuildLocalDbContainerInputs`) `db diff`/`db pull` run - * before their own "Creating shadow database..." banner (`diff.handler.ts`'s `localInputs` - * build, see that call site's doc comment). Split out from `exportViaShadowCatalog` itself so - * {@link legacyGetMigrationsCatalogRef} can run it BEFORE printing its own banner: this load can - * fail on its own (e.g. an enabled API TLS's unreadable cert/key files, which `toml` never - * reads), and Go's config loading — ALL of it, including this validation — runs once in the - * root `PersistentPreRunE`, strictly before `declarative.go`'s `createShadowContainer` ever - * prints "Creating shadow database..." (`declarative.go:490`). Building it as an implicit side - * effect of `exportViaShadowCatalog` (called only after the banner already printed) would - * surface that failure AFTER the banner instead, unlike Go. {@link legacyResolveMigrationsCatalogRef} - * has no such banner, so calling this immediately before `exportViaShadowCatalog` on its own - * cache-miss path is harmless there too — it only ever changes when a pre-existing, - * unconditional build runs relative to a print that never happens on that path. - */ -const legacyBuildShadowCatalogInputs = Effect.fnUntraced(function* ( - ctx: LegacyPgDeltaContext, - toml: LegacyDbTomlValues, - provisionParams: { readonly projectRef?: string }, -) { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const runtimeInfo = yield* RuntimeInfo; - const networkIdFlag = yield* LegacyNetworkIdFlag; - // Go's equivalent stderr writer for the shadow's one-shot setup jobs is - // `utils.GetDebugLogger()` = `viper.GetBool("DEBUG")` (`internal/utils/logger.go:11`), - // which also honors `SUPABASE_DEBUG` via `AutomaticEnv` — NOT the bare `--debug` pflag - // value. `legacyResolveDebugWithProjectEnv` reproduces that (plus the project `.env` - // Go's `loadNestedEnv` has already `os.Setenv`'d into the process by this point). - const debug = yield* legacyResolveDebugWithProjectEnv(toml.projectEnv); - const localInputs = yield* legacyBuildLocalDbContainerInputs( - spawner, - ctx.cwd, - networkIdFlag, - runtimeInfo.platform, - debug, - provisionParams.projectRef, - toml.remoteOverrideKeys, - ); - return { spawner, localInputs } satisfies LegacyShadowCatalogInputs; -}); - -/** - * Shared shadow-provision → pg-delta export → persist → cleanup mechanics behind - * both {@link legacyResolveMigrationsCatalogRef} and {@link legacyGetMigrationsCatalogRef} - * on a cache miss. Provisions the shadow via the SAME native primitives `db - * diff`/`db pull` use for their own diff-source shadow (CLI-1956, - * `legacyCreateShadowDatabase` + `legacyPrepareShadowSource` + - * `legacyRemoveShadowDatabase`, `commands/db/shared/legacy-shadow-source.ts`) — - * NOT the retired `db __shadow` hidden CLI subcommand, which was only ever a - * TS-facing IPC shim over these same Go functions. This is in fact TRUER Go - * parity than the shim it replaces: Go's own two callers of this mechanics — - * `resolveMigrationsCatalogRef` (`apps/cli-go/internal/db/diff/explicit.go:88-126`) - * and `getMigrationsCatalogRef`'s `createShadow`/`createShadowContainer` - * (`apps/cli-go/internal/db/declarative/declarative.go:368-430,487-506`) — both - * call `diff.CreateShadowDatabase` + `diff.MigrateShadowDatabase` (via - * `start.WaitForHealthyService`) DIRECTLY, in-process, never through a CLI - * subcommand. `legacyPrepareShadowSource` is called with `targetLocal: false` + - * `usePgDelta: false`, which skips its ENTIRE declarative-schema-override branch - * (Go's local-target `PrepareShadowSource`/`shadow.go:37-91` branch) — neither Go - * function above ever takes that branch either, since neither has a "target" at - * all; they only ever provision + migrate + export. - * - * Exports the shadow's catalog via the already-native {@link legacyExportCatalogPgDelta} - * (the same edge-runtime script Go's own `ExportCatalogPgDelta` runs), hands the - * snapshot to `persist` to decide where it lands on disk, then removes the shadow - * (`Effect.acquireUseRelease`'s release phase, once the `use` phase below has run — - * success or failure alike) — matching Go's `defer utils.DockerRemove(shadow)` - * immediately after creation, and `diff.handler.ts`/`pull.handler.ts`'s own - * `acquire`=create/`use`=prepare+diff/`release`=remove shape for the exact same - * interruptibility reason (see `legacyPrepareShadowSource`'s own doc comment: - * creation runs inside `acquireUseRelease`'s uninterruptible `acquire`, while the - * health-wait/migrate sequence stays in the interruptible `use` phase, so a SIGINT - * during either can still land while the shadow is still reliably torn down). This - * is NOT an unconditional guarantee, though — see `legacyCreateShadowDatabase`'s own - * doc comment (`shadow-database.ts`) for the still-present, deliberate-Go-parity - * leak window when `acquire` itself (container creation) fails partway through. - * - * The persisted path is made relative to `ctx.cwd` before returning: every caller - * feeds this ref into pg-delta's edge-runtime scripts as SOURCE/TARGET, which - * prefix a bare (non-postgres://) ref with `/workspace/` — matching the container - * bind `${ctx.cwd}:/workspace` (`legacyPgDeltaContainerRef`, `legacy-pgdelta.ts: - * 100-103`). Go's equivalent (`pgcache.WriteMigrationCatalogSnapshot`) is only - * ever built from `utils.TempDir`, a workdir-RELATIVE constant (Go chdirs into the - * workdir first), so the ref it returns is relative too; return the same shape - * here rather than the absolute host path `persist` builds internally. The two - * public functions differ only in their cache-decision and `persist`'s - * cache-write logic, not in this mechanics. - * - * `toml` is the caller's own already-loaded/remote-merged `config.toml` read - * (`legacyReadDbToml`'s result) — used, together with the caller-supplied - * {@link LegacyShadowCatalogInputs} (built by {@link legacyBuildShadowCatalogInputs}), - * to derive the shadow's own container spec (image, JWT secret, root key, - * `db.settings`, service enabled-for-setup flags) exactly like `db diff`/`db pull` - * do for their own shadow. The build is NOT performed in here — see - * {@link legacyBuildShadowCatalogInputs}'s own doc comment for why a caller that - * prints a "Creating shadow database..." banner first must build it BEFORE that - * print, not have it built implicitly as a side effect of calling this function. - */ -/** - * A provisioned shadow, ready to export a pg-delta catalog from. `sourceUrl` is the only field - * {@link exportViaShadowCatalog} itself reads — {@link legacyPrepareShadowSource}'s richer - * `LegacyShadowSourceResult` (used by the migrations-catalog `provision` below) satisfies this - * structurally, so callers that provision a bare platform-baseline/declarative shadow (no - * migrations-catalog `targetUrlOverride` concept) can return just this shape. - */ -interface LegacyProvisionedShadow { - readonly sourceUrl: string; -} - -/** - * Shared shadow-provision → pg-delta export → persist → cleanup mechanics behind every - * `exportCatalog` composition in this file: {@link legacyResolveMigrationsCatalogRef} and - * {@link legacyGetMigrationsCatalogRef} below (migrations catalogs, via `provision = - * legacyPrepareShadowSource`, which additionally applies local migrations/declarative overrides), - * and {@link legacyExportBaselineCatalogRef}/{@link legacyExportDeclarativeCatalogRef} (the native - * `LegacyDeclarativeSeam.exportCatalog` compositions, via a `provision` that runs ONLY the - * platform baseline — see those functions' own doc comments for why they must NOT reuse - * `legacyPrepareShadowSource`, which applies local migrations). `provision` is the one part of the - * shadow lifecycle that genuinely differs between callers; everything else (create, export, - * persist, remove) is identical, so it is parameterized here rather than duplicated — see this - * function's own git history for the sibling-function shape this replaced. - */ -const exportViaShadowCatalog = ( - fs: FileSystem.FileSystem, - path: Path.Path, - ctx: LegacyPgDeltaContext, - toml: LegacyDbTomlValues, - built: LegacyShadowCatalogInputs, - provision: ( - spawner: Spawner, - handle: LegacyShadowAcquiredHandle, - shadowInput: LegacyShadowSetupInput, - ) => Effect.Effect, - persist: (snapshot: string) => Effect.Effect, - // No default: every caller must declare its provisioner's effective webhooks policy. - shadowCacheOpts: LegacyShadowCacheOpts, -) => - Effect.gen(function* () { - const { spawner, localInputs } = built; - const resolvedImage = yield* localInputs.resolvePostgresImage; - const shadowInput = legacyShadowRunInputFromLocalContainerInputs( - localInputs, - resolvedImage, - toml, - fs, - path, - ); - // `legacyWithShadowDatabase` (`db-bootstrap/shadow-cache.ts`) rather than a bare - // `legacyCreateShadowDatabase`/`legacyRemoveShadowDatabase` pair — see its doc comment: with - // `SUPABASE_SHADOW_CACHE` unset it IS that pair (identical Docker argv, identical labels), and - // with it set a catalog cache miss restores a key-matching PGDATA snapshot into the fresh - // shadow instead of paying the full cold provision — the same swap `db diff`/`db pull`'s own - // call sites make. `shadowCacheOpts` carries `sync --no-cache`'s bypass and the - // caller's effective Webhooks policy — see `LegacyShadowCacheOpts`. - const written = yield* legacyWithShadowDatabase( - spawner, - shadowInput, - (handle) => - Effect.gen(function* () { - const shadow = yield* provision(spawner, handle, shadowInput); - const snapshot = yield* legacyExportCatalogPgDelta(ctx, { - targetRef: shadow.sourceUrl, - role: "postgres", - }); - return yield* persist(snapshot); - }), - shadowCacheOpts, - ); - return path.relative(ctx.cwd, written); - }); - -/** - * {@link exportViaShadowCatalog}'s `provision` for the migrations-catalog callers - * ({@link legacyResolveMigrationsCatalogRef}/{@link legacyGetMigrationsCatalogRef}): extends the - * base shadow-setup input with the pg-delta/declarative-override fields - * {@link legacyPrepareShadowSource} needs (`targetLocal: false`/`usePgDelta: false` — neither - * caller has a "target" at all, they only ever provision + migrate + export), then applies local - * migrations via `legacyMigrateShadowDatabase`. - */ -const legacyProvisionMigrationsShadow = ( - ctx: LegacyPgDeltaContext, - toml: LegacyDbTomlValues, - spawner: Spawner, - handle: LegacyShadowAcquiredHandle, - shadowInput: LegacyShadowSetupInput, -) => - legacyPrepareShadowSource(spawner, handle, { - ...shadowInput, - targetLocal: false, - usePgDelta: false, - schemaPaths: toml.schemaPathPatterns, - pgDelta: toml.pgDelta, - ctx, - }); - -/** - * Resolves the pg-delta migrations-catalog ref for `db diff`'s explicit - * `--from migrations` / `--to migrations` target — the native replacement for - * the hidden Go seam `db schema declarative __catalog --mode migrations` this - * call site used to shell out to (CLI-1959). Mirrors Go's - * `resolveMigrationsCatalogRef` (`apps/cli-go/internal/db/diff/explicit.go:88-126`) - * EXACTLY — not {@link legacyGetMigrationsCatalogRef} below, which backs a - * different Go function (`declarative.go`'s `getMigrationsCatalogRef`, used by - * `db schema declarative sync`). The two diverge on purpose: this one uses a - * BARE migrations-content hash (no setup-inputs token — `explicit.go:89`'s - * `pgcache.HashMigrations`), always consults the cache (`db diff` has no - * `--no-cache` flag on this path), has no zero-migrations/baseline special case, - * and prints no "Creating shadow database..." line (Go calls the shadow - * primitives directly, without `DiffDatabase`'s own progress line). - * - * On a cache miss, the shadow-provision/export/persist/cleanup mechanics are - * shared with {@link legacyGetMigrationsCatalogRef} via {@link exportViaShadowCatalog} - * — see its doc comment. The catalog is cached with - * {@link legacyWriteMigrationCatalogSnapshot}. `toml` is the caller's own - * already-loaded/remote-merged `config.toml` read, threaded through to - * {@link exportViaShadowCatalog} for the shadow's own container spec (CLI-1956) — - * see that function's doc comment. - */ -export const legacyResolveMigrationsCatalogRef = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - ctx: LegacyPgDeltaContext, - toml: LegacyDbTomlValues, - params: { readonly projectRef?: string }, -) { - const tempDir = legacyPgDeltaTempPath(path, ctx.cwd); - const migrationsDir = path.join(ctx.cwd, "supabase", "migrations"); - const hash = yield* legacyHashMigrations(fs, path, ctx.cwd, migrationsDir); - const cached = yield* legacyResolveMigrationCatalogPath(fs, path, tempDir, hash, "local"); - if (Option.isSome(cached)) return path.relative(ctx.cwd, cached.value); - - const built = yield* legacyBuildShadowCatalogInputs(ctx, toml, params); - return yield* exportViaShadowCatalog( - fs, - path, - ctx, - toml, - built, - (spawner, handle, shadowInput) => - legacyProvisionMigrationsShadow(ctx, toml, spawner, handle, shadowInput), - (snapshot) => - Effect.gen(function* () { - const timestamp = yield* Clock.currentTimeMillis; - return yield* legacyWriteMigrationCatalogSnapshot( - fs, - path, - tempDir, - "local", - hash, - snapshot, - 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). - { webhooks: "enabled" }, - ); -}); - -/** `catalog-nocache-migrations.json` — Go's `noCacheMigrationsCatalogPath` (`declarative.go:51`). */ -const NO_CACHE_MIGRATIONS_CATALOG_NAME = "catalog-nocache-migrations.json"; - -/** - * Resolves (and caches under `supabase/.temp/pgdelta/`) the pg-delta migrations - * catalog — platform baseline + local migrations applied — for `db schema - * declarative sync`'s diff SOURCE. The native replacement for the hidden Go seam - * `db schema declarative __catalog --mode migrations` this call site used to - * shell out to (CLI-1959). Mirrors Go's `getMigrationsCatalogRef` - * (`apps/cli-go/internal/db/declarative/declarative.go:368-430`) — see - * {@link legacyResolveMigrationsCatalogRef}'s doc comment for exactly how this - * diverges from `db diff`'s bare-hash version: this one folds the setup-inputs - * token into the cache key, special-cases zero local migrations by reusing/ - * writing the platform-baseline catalog, honors `--no-cache`, and prints - * "Creating shadow database..." to stderr on a cache miss - * (`declarative.go:490`, reached only when `createShadow` actually runs). - * - * On a cache miss, the shadow-provision/export/persist/cleanup mechanics are - * shared with {@link legacyResolveMigrationsCatalogRef} via - * {@link exportViaShadowCatalog} — see its doc comment. `toml` is the caller's - * own already-loaded/remote-merged `config.toml` read, threaded through for the - * shadow's own container spec (CLI-1956) — distinct from `setupInputs`, which is - * only the cache-key/baseline-setup subset. - */ -export const legacyGetMigrationsCatalogRef = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - ctx: LegacyPgDeltaContext, - toml: LegacyDbTomlValues, - setupInputs: LegacySetupInputs, - params: { readonly noCache: boolean; readonly projectRef?: string }, -) { - const output = yield* Output; - const tempDir = legacyPgDeltaTempPath(path, ctx.cwd); - const migrationsDir = path.join(ctx.cwd, "supabase", "migrations"); - const migrations = yield* legacyListLocalMigrations(fs, path, migrationsDir); - const zeroMigrations = migrations.length === 0; - - // Built BEFORE the cache probes below, not just before the banner: this is a SECOND - // `@supabase/config` load (`legacyBuildLocalDbContainerInputs`) whose failure (e.g. an - // enabled API TLS's unreadable cert/key files) must surface regardless of cache state — - // Go's config loading (all of it) ran once in the root `PersistentPreRunE`, strictly - // before any catalog cache lookup and before `declarative.go`'s `createShadowContainer` - // ever prints the banner (`declarative.go:490`). Probing first would make an invalid - // project succeed or fail depending on whether a cache file happens to exist. - const built = yield* legacyBuildShadowCatalogInputs(ctx, toml, params); - - const baselinePath = path.join( - tempDir, - legacyBaselineCatalogFileName(legacyBaselineCatalogKey(setupInputs)), - ); - if (zeroMigrations && !params.noCache) { - // `fs.exists` maps only not-found to `false`, so any other probe failure (permissions, - // I/O under `.temp/pgdelta`) propagates — matching Go's `getMigrationsCatalogRef` - // returning the `afero.Exists` error immediately, before any Docker side effect. - const exists = yield* fs.exists(baselinePath); - if (exists) return path.relative(ctx.cwd, baselinePath); - } - - // Mirrors Go's unconditional `migrationsCatalogCacheKey` call (`declarative.go:393`), - // which always runs — even on the zeroMigrations/noCache paths — since it is pure - // and only unused there, not because it needs to run early for a side effect. - const setupToken = legacySetupInputsToken(setupInputs); - const migrationsHash = yield* legacyHashMigrations(fs, path, ctx.cwd, migrationsDir); - const hash = legacyMigrationsCatalogCacheKey(setupToken, migrationsHash); - - if (!params.noCache && !zeroMigrations) { - const cached = yield* legacyResolveMigrationCatalogPath(fs, path, tempDir, hash, "local"); - if (Option.isSome(cached)) return path.relative(ctx.cwd, cached.value); - } - - yield* output.raw("Creating shadow database...\n", "stderr"); - return yield* exportViaShadowCatalog( - fs, - path, - ctx, - toml, - built, - (spawner, handle, shadowInput) => - legacyProvisionMigrationsShadow(ctx, toml, spawner, handle, shadowInput), - (snapshot) => - Effect.gen(function* () { - if (params.noCache) { - yield* fs.makeDirectory(tempDir, { recursive: true }).pipe(Effect.ignore); - const noCachePath = path.join(tempDir, NO_CACHE_MIGRATIONS_CATALOG_NAME); - yield* fs.writeFileString(noCachePath, snapshot); - return noCachePath; - } - if (zeroMigrations) { - yield* fs.makeDirectory(tempDir, { recursive: true }).pipe(Effect.ignore); - yield* fs.writeFileString(baselinePath, snapshot); - return baselinePath; - } - const timestamp = yield* Clock.currentTimeMillis; - return yield* legacyWriteMigrationCatalogSnapshot( - fs, - path, - tempDir, - "local", - hash, - snapshot, - timestamp, - ); - }), - // `--no-cache` must also bypass the baseline snapshot, not just the catalog. - { bypassCache: params.noCache, webhooks: "enabled" }, - ); -}); - -/** `catalog-nocache-baseline.json` — Go's `noCacheBaselineCatalogPath` (`declarative.go:50`). */ -export const LEGACY_NO_CACHE_BASELINE_CATALOG_NAME = "catalog-nocache-baseline.json"; - -/** `catalog-nocache-declarative.json` — Go's `noCacheDeclarativeCatalogPath` (`declarative.go:52`). */ -export const LEGACY_NO_CACHE_DECLARATIVE_CATALOG_NAME = "catalog-nocache-declarative.json"; - -/** Writes a catalog snapshot to an exact path, creating `tempDir` first. Mirrors Go's `writeTempCatalog`/`ensureTempDir` pairing (`declarative.go:553-568`) for a caller that already knows its target file name (a keyed baseline catalog, or either mode's `--no-cache` file), unlike {@link legacyWriteMigrationCatalogSnapshot}/{@link legacyWriteDeclarativeCatalogSnapshot} below, which also derive the file name and prune older snapshots. */ -const legacyWriteCatalogFile = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - tempDir: string, - filePath: string, - snapshot: string, -) { - yield* fs.makeDirectory(tempDir, { recursive: true }).pipe(Effect.ignore); - yield* fs.writeFileString(filePath, snapshot); - return filePath; -}); - -/** - * Writes a declarative-catalog snapshot to - * `/catalog--declarative--.json` and prunes older snapshots for the - * same `(prefix, hash)` family (retention 2). The declarative sibling of - * {@link legacyWriteMigrationCatalogSnapshot}; mirrors Go's `writeDeclarativeCatalogFromConfig`'s - * own persist step (`declarative.go:463-485`). - */ -export const legacyWriteDeclarativeCatalogSnapshot = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - tempDir: string, - prefix: string, - hash: string, - snapshot: string, - timestampMillis: number, -) { - const filePath = path.join( - tempDir, - legacyDeclarativeCatalogFileName(prefix, hash, timestampMillis), - ); - yield* legacyWriteCatalogFile(fs, tempDir, filePath, snapshot); - yield* legacyCleanupOldDeclarativeCatalogs(fs, path, tempDir, prefix); - return filePath; -}); - -/** - * {@link exportViaShadowCatalog}'s `provision` for {@link legacyExportBaselineCatalogRef}: the - * platform baseline ONLY — no local migrations, no declarative apply. Mirrors Go's - * `getGenerateBaselineCatalogRef` (`declarative.go:306-361`), which sets up the shadow via - * `setupShadowDatabase` (the Supabase platform baseline, auth/storage/realtime) and nothing - * else. Deliberately does NOT call {@link legacyPrepareShadowSource}/`legacyMigrateShadowDatabase` - * — those apply local migrations, which would contaminate the baseline catalog Go's own - * `baselineCatalogName` doc comment warns against (the baseline is reused as sync's diff - * SOURCE when there are no local migrations, and as generate's diff SOURCE against a live - * database — both need "platform baseline, nothing else"). - */ -const legacyProvisionBaselineShadow = ( - spawner: Spawner, - fs: FileSystem.FileSystem, - path: Path.Path, - ctx: LegacyPgDeltaContext, - handle: LegacyShadowAcquiredHandle, - shadowInput: LegacyShadowSetupInput, -) => - Effect.gen(function* () { - const connConfig: LegacyPgConnInput = { - host: shadowInput.hostname, - port: shadowInput.shadowPort, - user: "postgres", - password: shadowInput.password, - database: "postgres", - }; - yield* legacyWaitForShadowReady(spawner, handle.containerId, connConfig, { - timeoutSeconds: shadowInput.healthTimeoutSeconds, - image: shadowInput.image, - }); - yield* legacySetupShadowDatabase( - spawner, - { - fs, - path, - workdir: ctx.cwd, - projectId: shadowInput.projectId, - container: handle.containerId, - networkId: shadowInput.networkId, - connConfig, - setup: shadowInput.setup, - }, - {}, - handle, - ); - return { sourceUrl: legacyToPostgresURL(connConfig) } satisfies LegacyProvisionedShadow; - }); - -/** - * {@link exportViaShadowCatalog}'s `provision` for {@link legacyExportDeclarativeCatalogRef}: the - * platform baseline, THEN the declarative directory applied to the shadow's own `postgres` - * database (NOT `contrib_regression` — unlike `legacy-shadow-source.ts`'s local-target override - * branch, there is no separate "target" database here; the shadow IS the declarative target). - * Mirrors Go's `getDeclarativeCatalogRef`/`writeDeclarativeCatalogFromConfig` - * (`declarative.go:434-485`). - */ -const legacyProvisionDeclarativeShadow = ( - spawner: Spawner, - fs: FileSystem.FileSystem, - path: Path.Path, - ctx: LegacyPgDeltaContext, - declarativeDirAbs: string, - declarativeDirRel: string, - handle: LegacyShadowAcquiredHandle, - shadowInput: LegacyShadowSetupInput, -) => - Effect.gen(function* () { - const connConfig: LegacyPgConnInput = { - host: shadowInput.hostname, - port: shadowInput.shadowPort, - user: "postgres", - password: shadowInput.password, - database: "postgres", - }; - yield* legacyWaitForShadowReady(spawner, handle.containerId, connConfig, { - timeoutSeconds: shadowInput.healthTimeoutSeconds, - image: shadowInput.image, - }); - yield* legacySetupShadowDatabase( - spawner, - { - fs, - path, - workdir: ctx.cwd, - projectId: shadowInput.projectId, - container: handle.containerId, - networkId: shadowInput.networkId, - connConfig, - setup: shadowInput.setup, - }, - {}, - handle, - ); - const targetUrl = legacyToPostgresURL(connConfig); - yield* legacyApplyDeclarativePgDelta(ctx, { - fs, - declarativeDirAbs, - declarativeDirRel, - target: targetUrl, - }); - return { sourceUrl: targetUrl } satisfies LegacyProvisionedShadow; - }); - -/** - * Resolves (and caches under `supabase/.temp/pgdelta/`) the pg-delta BASELINE catalog — the - * Supabase platform baseline (auth/storage/realtime) with no local migrations and no - * declarative files applied. Backs `LegacyDeclarativeSeam.exportCatalog({ mode: "baseline" })` — - * `db schema declarative generate`'s own diff SOURCE, and (via - * {@link legacyGetMigrationsCatalogRef}'s own zero-migrations special case above) `sync`'s diff - * source when there are no local migrations. Mirrors Go's `getGenerateBaselineCatalogRef` - * (`apps/cli-go/internal/db/declarative/declarative.go:306-361`). - * - * Unlike Go, which can reuse ONE shadow across `Generate`'s own export and its post-write cache - * warm (`generateBaselineCatalogRef.shadow`), this always provisions (and tears down) its own - * shadow per call — see `legacy-pgdelta.seam.service.ts`'s own doc comment for why that - * simplification is deliberate and accepted. - */ -export const legacyExportBaselineCatalogRef = ( - fs: FileSystem.FileSystem, - path: Path.Path, - workdir: string, - cliProjectId: Option.Option, - params: { readonly noCache: boolean; readonly projectRef?: string }, -) => - Effect.gen(function* () { - const toml = yield* legacyReadDbToml(fs, path, workdir, params.projectRef); - const ctx: LegacyPgDeltaContext = { - projectId: legacyResolvePgDeltaProjectId(cliProjectId, toml, workdir), - cwd: workdir, - npmVersion: Option.getOrUndefined(toml.pgDelta.npmVersion), - denoVersion: toml.denoVersion, - projectEnv: toml.projectEnv, - }; - const tempDir = legacyPgDeltaTempPath(path, workdir); - const setupInputs = yield* legacyResolveSetupInputs( - fs, - path, - workdir, - toml.majorVersion, - Option.getOrUndefined(toml.orioledbVersion), - toml.baseline, - ); - // Built BEFORE the cache probe below, not just before the banner: this is the SECOND - // `@supabase/config` load, and the Go `__catalog` child ran its equivalent in the command - // pre-run unconditionally — so an invalid project (e.g. an unreadable `[api.tls]` cert) - // failed consistently whether or not a cached catalog existed. Probing first would make - // that failure appear and disappear with cache state. - const built = yield* legacyBuildShadowCatalogInputs(ctx, toml, params); - const cachePath = path.join( - tempDir, - legacyBaselineCatalogFileName(legacyBaselineCatalogKey(setupInputs)), - ); - if (!params.noCache) { - // Same propagation as the zero-migrations probe in `legacyResolveMigrationsCatalogRef`: - // `fs.exists` maps only not-found to `false`, so a failing probe (permissions, I/O) - // fails the export before any Docker side effect instead of faking a cache miss. - const exists = yield* fs.exists(cachePath); - if (exists) return path.relative(workdir, cachePath); - } - - const output = yield* Output; - yield* output.raw("Creating shadow database...\n", "stderr"); - return yield* exportViaShadowCatalog( - fs, - path, - ctx, - toml, - built, - (spawner, handle, shadowInput) => - legacyProvisionBaselineShadow(spawner, fs, path, ctx, handle, shadowInput), - (snapshot) => - params.noCache - ? legacyWriteCatalogFile( - fs, - tempDir, - path.join(tempDir, LEGACY_NO_CACHE_BASELINE_CATALOG_NAME), - snapshot, - ) - : legacyWriteCatalogFile(fs, tempDir, cachePath, snapshot), - { bypassCache: params.noCache, webhooks: "config" }, - ); - }); - -/** - * Resolves (and caches under `supabase/.temp/pgdelta/`) the pg-delta DECLARATIVE catalog — the - * Supabase platform baseline with the declarative directory applied. Backs - * `LegacyDeclarativeSeam.exportCatalog({ mode: "declarative" })` — `sync`'s diff TARGET, and the - * cache `generate` warms after writing declarative files. Mirrors Go's `getDeclarativeCatalogRef` - * (`apps/cli-go/internal/db/declarative/declarative.go:434-461`). - */ -export const legacyExportDeclarativeCatalogRef = ( - fs: FileSystem.FileSystem, - path: Path.Path, - workdir: string, - cliProjectId: Option.Option, - params: { readonly noCache: boolean; readonly projectRef?: string }, -) => - Effect.gen(function* () { - const toml = yield* legacyReadDbToml(fs, path, workdir, params.projectRef); - const ctx: LegacyPgDeltaContext = { - projectId: legacyResolvePgDeltaProjectId(cliProjectId, toml, workdir), - cwd: workdir, - npmVersion: Option.getOrUndefined(toml.pgDelta.npmVersion), - denoVersion: toml.denoVersion, - projectEnv: toml.projectEnv, - }; - const tempDir = legacyPgDeltaTempPath(path, workdir); - const declarativeDirRel = legacyResolveDeclarativeDir(path, toml.pgDelta); - const declarativeDirAbs = path.resolve(workdir, declarativeDirRel); - - const setupInputs = yield* legacyResolveSetupInputs( - fs, - path, - workdir, - toml.majorVersion, - Option.getOrUndefined(toml.orioledbVersion), - toml.baseline, - ); - const setupToken = legacySetupInputsToken(setupInputs); - const schemaHash = yield* legacyHashDeclarativeSchemas(fs, path, declarativeDirAbs); - const hash = legacyDeclarativeCatalogCacheKey(setupToken, schemaHash); - const prefix = "local"; - - // Built BEFORE the cache probe — see `legacyExportBaselineCatalogRef`'s own comment above: - // config validation must not depend on cache state. - const built = yield* legacyBuildShadowCatalogInputs(ctx, toml, params); - if (!params.noCache) { - const cached = yield* legacyResolveDeclarativeCatalogPath(fs, path, tempDir, hash, prefix); - if (Option.isSome(cached)) return path.relative(workdir, cached.value); - } - - const output = yield* Output; - yield* output.raw("Creating shadow database...\n", "stderr"); - return yield* exportViaShadowCatalog( - fs, - path, - ctx, - toml, - built, - (spawner, handle, shadowInput) => - legacyProvisionDeclarativeShadow( - spawner, - fs, - path, - ctx, - declarativeDirAbs, - declarativeDirRel, - handle, - shadowInput, - ), - (snapshot) => - params.noCache - ? legacyWriteCatalogFile( - fs, - tempDir, - path.join(tempDir, LEGACY_NO_CACHE_DECLARATIVE_CATALOG_NAME), - snapshot, - ) - : Effect.gen(function* () { - const timestamp = yield* Clock.currentTimeMillis; - return yield* legacyWriteDeclarativeCatalogSnapshot( - fs, - path, - tempDir, - prefix, - hash, - snapshot, - timestamp, - ); - }), - { bypassCache: params.noCache, webhooks: "config" }, - ); - }); diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta.cache.unit.test.ts b/apps/cli/src/legacy/shared/legacy-pgdelta.cache.unit.test.ts deleted file mode 100644 index 4f2c3c5c42..0000000000 --- a/apps/cli/src/legacy/shared/legacy-pgdelta.cache.unit.test.ts +++ /dev/null @@ -1,857 +0,0 @@ -import { createHash } from "node:crypto"; -import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { BunServices } from "@effect/platform-bun"; -import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; - -import { Output } from "../../shared/output/output.service.ts"; -import { mockOutput } from "../../../tests/helpers/mocks.ts"; -import { LegacyEdgeRuntimeScript } from "./legacy-edge-runtime-script.service.ts"; -import { LegacyPgDeltaSslProbe } from "./legacy-pgdelta-ssl-probe.service.ts"; -import { type LegacyPgDeltaContext } from "./legacy-pgdelta.ts"; -import { - LEGACY_NO_CACHE_BASELINE_CATALOG_NAME, - LEGACY_NO_CACHE_DECLARATIVE_CATALOG_NAME, - type LegacySetupInputs, - legacyBaselineCatalogFileName, - legacyBaselineCatalogKey, - legacyBaselineVersionToken, - legacyCatalogPrefixFromConfig, - legacyCleanupOldDeclarativeCatalogs, - legacyCleanupOldMigrationCatalogs, - legacyDeclarativeCatalogCacheKey, - legacyDeclarativeCatalogFileName, - legacyHashDeclarativeSchemas, - legacyHashMigrations, - legacyListLocalMigrations, - legacyMigrationCatalogFileName, - legacyMigrationsCatalogCacheKey, - legacyResolveDeclarativeCatalogPath, - legacyResolveMigrationCatalogPath, - legacyResolveSetupInputs, - legacySanitizedCatalogPrefix, - legacySetupInputsToken, - legacyTryCacheMigrationsCatalog, - legacyWriteDeclarativeCatalogSnapshot, - legacyWriteMigrationCatalogSnapshot, -} from "./legacy-pgdelta.cache.ts"; - -const BASE: LegacySetupInputs = { - image: "supabase/postgres:17.6.1.135", - majorVersion: 17, - authEnabled: true, - storageEnabled: true, - realtimeEnabled: true, - autoExpose: false, - vaultNames: [], - rolesSql: "", -}; - -const sha12 = (payload: string) => - createHash("sha256").update(payload, "utf8").digest("hex").slice(0, 12); - -describe("legacySanitizedCatalogPrefix", () => { - it("defaults blank to 'local' and sanitizes non [a-zA-Z0-9._-]", () => { - expect(legacySanitizedCatalogPrefix(" ")).toBe("local"); - expect(legacySanitizedCatalogPrefix("local")).toBe("local"); - expect(legacySanitizedCatalogPrefix("db prod/2")).toBe("db-prod-2"); - }); -}); - -describe("legacyBaselineVersionToken", () => { - it("uses the image tag", () => { - expect(legacyBaselineVersionToken("supabase/postgres:17.6.1.135", 17)).toBe("17.6.1.135"); - }); - - it("falls back to pg only when the image is empty", () => { - expect(legacyBaselineVersionToken("", 15)).toBe("pg15"); - expect(legacyBaselineVersionToken(" ", 15)).toBe("pg15"); - // Go only slices when idx+1 < len, so a trailing-colon image is sanitized whole. - expect(legacyBaselineVersionToken("supabase/postgres:", 14)).toBe("supabase-postgres-"); - }); -}); - -describe("legacySetupInputsToken", () => { - it("byte-matches the Go hash input sequence", () => { - const expected = sha12( - "17.6.1.135\nauth=true storage=true realtime=true\nauto_expose_new_tables=false\n", - ); - expect(legacySetupInputsToken(BASE)).toBe(expected); - }); - - it("folds in sorted vault names and roles.sql", () => { - const token = legacySetupInputsToken({ - ...BASE, - vaultNames: ["b_secret", "a_secret"], - rolesSql: "create role app;", - }); - const expected = sha12( - "17.6.1.135\nauth=true storage=true realtime=true\nauto_expose_new_tables=false\n" + - "vault=a_secret\nvault=b_secret\ncreate role app;", - ); - expect(token).toBe(expected); - }); - - it("self-invalidates when any baseline input changes", () => { - const baseToken = legacySetupInputsToken(BASE); - expect(legacySetupInputsToken({ ...BASE, authEnabled: false })).not.toBe(baseToken); - expect(legacySetupInputsToken({ ...BASE, autoExpose: true })).not.toBe(baseToken); - expect(legacySetupInputsToken({ ...BASE, vaultNames: ["x"] })).not.toBe(baseToken); - expect(legacySetupInputsToken({ ...BASE, rolesSql: "x" })).not.toBe(baseToken); - expect(legacySetupInputsToken({ ...BASE, image: "supabase/postgres:15.8.1.085" })).not.toBe( - baseToken, - ); - }); -}); - -describe("catalog keys + file names", () => { - it("composes the baseline + declarative cache keys", () => { - expect(legacyBaselineCatalogKey(BASE)).toBe(`17.6.1.135-${legacySetupInputsToken(BASE)}`); - expect(legacyDeclarativeCatalogCacheKey("setup12chars", "schemahash")).toBe( - "setup12chars-schemahash", - ); - }); - - it("composes the migrations cache key used by `db schema declarative sync` (setup-token-folded)", () => { - // Mirrors Go's `migrationsCatalogCacheKey` (`declarative.go:765`) — deliberately - // different from `db diff`'s bare `pgcache.HashMigrations` key (CLI-1959): this - // one folds the setup-inputs token in so a baseline/config change self- - // invalidates the sync migrations catalog too. - expect(legacyMigrationsCatalogCacheKey("setup12chars", "migrationshash")).toBe( - "setup12chars-migrationshash", - ); - }); - - it("formats catalog file names", () => { - expect(legacyBaselineCatalogFileName("17.6.1.135-abc")).toBe( - "catalog-baseline-17.6.1.135-abc.json", - ); - expect(legacyDeclarativeCatalogFileName("local", "h", 1700)).toBe( - "catalog-local-declarative-h-1700.json", - ); - }); -}); - -const withTemp = () => mkdtempSync(join(tmpdir(), "legacy-decl-cache-")); - -const run = (effect: Effect.Effect) => - effect.pipe( - Effect.provide(Layer.mergeAll(BunServices.layer, mockOutput().layer)), - ) as Effect.Effect; - -const withServices = ( - body: (fs: FileSystem.FileSystem, path: Path.Path) => Effect.Effect, -) => - run( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - return yield* body(fs, path); - }), - ); - -describe("legacyListLocalMigrations", () => { - it.effect("returns sorted valid migrations, skipping a deprecated _init.sql first file", () => { - const dir = withTemp(); - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - writeFileSync(join(migrationsDir, "20200101000000_init.sql"), "-- old init"); - writeFileSync(join(migrationsDir, "20240101120000_create.sql"), "create table x();"); - writeFileSync(join(migrationsDir, "notes.txt"), "ignore me"); - return withServices((fs, path) => legacyListLocalMigrations(fs, path, migrationsDir)).pipe( - Effect.tap((paths) => - Effect.sync(() => { - expect(paths.map((p) => p.split("/").pop())).toEqual(["20240101120000_create.sql"]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); - - it.effect( - "warns (byte-exact, on stderr) when skipping a deprecated init and a misnamed file", - () => { - // Mirrors Go's `ListLocalMigrations` warnings (`pkg/migration/list.go:45-53`): - // a `fmt.Fprintf(os.Stderr, …)` for the deprecated `_init.sql` first file and - // for any name that does not match `_name.sql`. - const dir = withTemp(); - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - writeFileSync(join(migrationsDir, "20200101000000_init.sql"), "-- old init"); - writeFileSync(join(migrationsDir, "20240101120000_create.sql"), "create table x();"); - writeFileSync(join(migrationsDir, "notes.txt"), "ignore me"); - const out = mockOutput(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - return yield* legacyListLocalMigrations(fs, path, migrationsDir); - }).pipe( - Effect.provide(Layer.mergeAll(BunServices.layer, out.layer)), - Effect.tap((paths) => - Effect.sync(() => { - expect(paths.map((p) => p.split("/").pop())).toEqual(["20240101120000_create.sql"]); - const stderr = out.rawChunks.filter((c) => c.stream === "stderr").map((c) => c.text); - expect(stderr).toContain( - 'Skipping migration 20200101000000_init.sql... (replace "init" with a different file name to apply this migration)\n', - ); - expect(stderr).toContain( - 'Skipping migration notes.txt... (file name must match pattern "_name.sql")\n', - ); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ) as Effect.Effect; - }, - ); - - it.effect( - "includes a validly-named .sql symlink to a directory, matching Go's IsDir() (no follow)", - () => { - // Go's `os.ReadDir`/`DirEntry.IsDir()` (`pkg/migration/list.go:34-43`) classifies a - // directory entry from its own type without following symlinks, so a `.sql` symlink - // whose target is a directory is NOT skipped as a directory — it is only ever dropped - // later, if something actually tries to read it as a file. A naive `fs.stat`-based - // directory check (which follows symlinks) would misclassify it and silently skip it. - const dir = withTemp(); - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - const targetDir = join(dir, "outside-target"); - mkdirSync(targetDir, { recursive: true }); - writeFileSync(join(migrationsDir, "20240101120000_create.sql"), "create table x();"); - symlinkSync(targetDir, join(migrationsDir, "20240102000000_link.sql")); - return withServices((fs, path) => legacyListLocalMigrations(fs, path, migrationsDir)).pipe( - Effect.tap((paths) => - Effect.sync(() => { - expect(paths.map((p) => p.split("/").pop())).toEqual([ - "20240101120000_create.sql", - "20240102000000_link.sql", - ]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, - ); - - it.effect( - "sorts by UTF-8 byte order, matching Go's fs.ReadDir, not JS's default UTF-16 code-unit order", - () => { - // Go's `fs.ReadDir` (`pkg/migration/list.go:34`) sorts entries byte-wise over each name's - // UTF-8 encoding. A BMP private-use character (U+E000, single UTF-16 code unit `0xE000`) - // and a supplementary-plane character (U+1F600, a surrogate pair starting `0xD83D`) reverse - // order between the two schemes: JS's default `Array.prototype.sort()` ranks the surrogate - // pair first (`0xD83D < 0xE000`), while Go's byte order — which preserves codepoint order — - // ranks U+1F600 (`> U+FFFF`) after U+E000. A migrations directory with such filenames must - // replay in Go's order, not JS's default, or a dependent migration could apply out of order. - const dir = withTemp(); - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - const privateUseFile = "20240101120000_z\uE000.sql"; - const supplementaryFile = "20240101120000_z\u{1F600}.sql"; - writeFileSync(join(migrationsDir, privateUseFile), "create table x();"); - writeFileSync(join(migrationsDir, supplementaryFile), "create table y();"); - return withServices((fs, path) => legacyListLocalMigrations(fs, path, migrationsDir)).pipe( - Effect.tap((paths) => - Effect.sync(() => { - expect(paths.map((p) => p.split("/").pop())).toEqual([ - privateUseFile, - supplementaryFile, - ]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, - ); - - it.effect("returns [] when the migrations dir is absent", () => { - const dir = withTemp(); - return withServices((fs, path) => legacyListLocalMigrations(fs, path, join(dir, "nope"))).pipe( - Effect.tap((paths) => - Effect.sync(() => { - expect(paths).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); - - it.effect("fails (instead of returning []) when the migrations path is unreadable", () => { - // `supabase/migrations` exists but is a file, not a directory — Go's - // ListLocalMigrations aborts with `failed to read directory` rather than - // treating it as "no migrations". - const dir = withTemp(); - const migrationsPath = join(dir, "supabase", "migrations"); - mkdirSync(join(dir, "supabase"), { recursive: true }); - writeFileSync(migrationsPath, "not a directory"); - return withServices((fs, path) => - legacyListLocalMigrations(fs, path, migrationsPath).pipe(Effect.exit), - ).pipe( - Effect.tap((exit) => - Effect.sync(() => { - expect(exit._tag).toBe("Failure"); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); -}); - -describe("legacyHashMigrations", () => { - it.effect( - "hashes the workdir-relative path + contents in list order (stable, content-sensitive)", - () => { - const dir = withTemp(); - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - const file = join(migrationsDir, "20240101120000_create.sql"); - writeFileSync(file, "create table x();"); - const relPath = join("supabase", "migrations", "20240101120000_create.sql"); - const expected = createHash("sha256") - .update(relPath, "utf8") - .update(Buffer.from("create table x();")) - .digest("hex"); - return withServices((fs, path) => legacyHashMigrations(fs, path, dir, migrationsDir)).pipe( - Effect.tap((hash) => - Effect.sync(() => { - expect(hash).toBe(expected); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, - ); - - it.effect( - "is unaffected by the absolute location of workdir (Go-parity, not machine-specific)", - () => { - const dirA = withTemp(); - const dirB = withTemp(); - const migrationsA = join(dirA, "supabase", "migrations"); - const migrationsB = join(dirB, "supabase", "migrations"); - mkdirSync(migrationsA, { recursive: true }); - mkdirSync(migrationsB, { recursive: true }); - writeFileSync(join(migrationsA, "20240101120000_create.sql"), "create table x();"); - writeFileSync(join(migrationsB, "20240101120000_create.sql"), "create table x();"); - return withServices((fs, path) => - Effect.gen(function* () { - const hashA = yield* legacyHashMigrations(fs, path, dirA, migrationsA); - const hashB = yield* legacyHashMigrations(fs, path, dirB, migrationsB); - expect(hashA).toBe(hashB); - }), - ).pipe( - Effect.tap(() => - Effect.sync(() => { - rmSync(dirA, { recursive: true, force: true }); - rmSync(dirB, { recursive: true, force: true }); - }), - ), - ); - }, - ); -}); - -describe("legacyHashDeclarativeSchemas", () => { - it.effect("hashes forward-slash rel path + contents over sorted .sql files", () => { - const dir = withTemp(); - const declDir = join(dir, "supabase", "database"); - mkdirSync(join(declDir, "nested"), { recursive: true }); - writeFileSync(join(declDir, "public.sql"), "A"); - writeFileSync(join(declDir, "nested", "auth.sql"), "B"); - writeFileSync(join(declDir, "skip.txt"), "C"); - const expected = createHash("sha256") - .update("nested/auth.sql", "utf8") - .update(Buffer.from("B")) - .update("public.sql", "utf8") - .update(Buffer.from("A")) - .digest("hex"); - return withServices((fs, path) => legacyHashDeclarativeSchemas(fs, path, declDir)).pipe( - Effect.tap((hash) => - Effect.sync(() => { - expect(hash).toBe(expected); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); - - // A directory symlink pointing at an ancestor must not loop the walk, and symlinked - // entries are excluded from the hash entirely — matching the walker's no-follow - // semantics (codex review, PR #6162). - it.effect("skips symlinked entries instead of following them", () => { - const dir = withTemp(); - const declDir = join(dir, "supabase", "database"); - mkdirSync(declDir, { recursive: true }); - writeFileSync(join(declDir, "public.sql"), "A"); - symlinkSync(join(dir, "supabase"), join(declDir, "loop")); - const expected = createHash("sha256") - .update("public.sql", "utf8") - .update(Buffer.from("A")) - .digest("hex"); - return withServices((fs, path) => legacyHashDeclarativeSchemas(fs, path, declDir)).pipe( - Effect.tap((hash) => - Effect.sync(() => { - expect(hash).toBe(expected); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); - - // Retention removal failures must propagate — a silently-failing cleanup would let - // snapshots accumulate forever while every run reports success (codex review, PR #6162). - it.effect("cleanup fails when an old snapshot cannot be removed", () => { - const dir = withTemp(); - const tempDir = join(dir, "pgdelta"); - mkdirSync(tempDir, { recursive: true }); - for (const ts of [100, 200, 300]) { - writeFileSync(join(tempDir, `catalog-local-declarative-h-${ts}.json`), "{}"); - } - return withServices((fs, path) => - Effect.gen(function* () { - const err = yield* fs.readDirectory(join(dir, "does-not-exist")).pipe(Effect.flip); - const failing: FileSystem.FileSystem = { ...fs, remove: () => Effect.fail(err) }; - return yield* legacyCleanupOldDeclarativeCatalogs(failing, path, tempDir, "local").pipe( - Effect.exit, - ); - }), - ).pipe( - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); - - // A root-level failure that isn't not-found (permissions, I/O) must propagate rather - // than be treated as an empty tree — an empty-tree hash could cache an empty catalog - // and let sync emit destructive drops (codex review, PR #6162). - it.effect("fails when the root existence check itself fails", () => { - const dir = withTemp(); - const declDir = join(dir, "supabase", "database"); - mkdirSync(declDir, { recursive: true }); - return withServices((fs, path) => - Effect.gen(function* () { - const err = yield* fs.readDirectory(join(dir, "does-not-exist")).pipe(Effect.flip); - const failing: FileSystem.FileSystem = { ...fs, exists: () => Effect.fail(err) }; - return yield* legacyHashDeclarativeSchemas(failing, path, declDir).pipe(Effect.exit); - }), - ).pipe( - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); - - // A partial hash can collide with an existing cache key and serve a stale catalog, - // so a traversal failure must fail the hash, not shrink it (codex review, PR #6162). - it.effect("fails when part of the tree cannot be read instead of hashing a subset", () => { - const dir = withTemp(); - const declDir = join(dir, "supabase", "database"); - mkdirSync(join(declDir, "nested"), { recursive: true }); - writeFileSync(join(declDir, "public.sql"), "A"); - writeFileSync(join(declDir, "nested", "auth.sql"), "B"); - return withServices((fs, path) => { - const failing: FileSystem.FileSystem = { - ...fs, - readDirectory: (p, opts) => - p.endsWith("nested") - ? fs.readDirectory(join(dir, "does-not-exist")) - : fs.readDirectory(p, opts), - }; - return legacyHashDeclarativeSchemas(failing, path, declDir).pipe(Effect.exit); - }).pipe( - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); -}); - -describe("legacyResolveDeclarativeCatalogPath + cleanup", () => { - it.effect("resolves the newest snapshot and prunes to the retention count", () => { - const dir = withTemp(); - const tempDir = join(dir, "pgdelta"); - mkdirSync(tempDir, { recursive: true }); - for (const ts of [100, 300, 200]) { - writeFileSync(join(tempDir, `catalog-local-declarative-h-${ts}.json`), "{}"); - } - writeFileSync(join(tempDir, "catalog-local-declarative-other-50.json"), "{}"); - return withServices((fs, path) => - Effect.gen(function* () { - const latest = yield* legacyResolveDeclarativeCatalogPath(fs, path, tempDir, "h", "local"); - expect(Option.getOrNull(latest)?.endsWith("catalog-local-declarative-h-300.json")).toBe( - true, - ); - yield* legacyCleanupOldDeclarativeCatalogs(fs, path, tempDir, "local"); - const remaining = (yield* fs.readDirectory(tempDir)).filter((n) => - n.startsWith("catalog-local-declarative-"), - ); - expect(remaining.sort()).toEqual([ - "catalog-local-declarative-h-200.json", - "catalog-local-declarative-h-300.json", - ]); - }), - ).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); - }); -}); - -describe("no-cache catalog file names", () => { - it("matches Go's noCacheBaselineCatalogPath/noCacheDeclarativeCatalogPath literals", () => { - expect(LEGACY_NO_CACHE_BASELINE_CATALOG_NAME).toBe("catalog-nocache-baseline.json"); - expect(LEGACY_NO_CACHE_DECLARATIVE_CATALOG_NAME).toBe("catalog-nocache-declarative.json"); - }); -}); - -describe("legacyWriteDeclarativeCatalogSnapshot + cleanup", () => { - it.effect( - "writes the snapshot and prunes older declarative catalogs past the retention count", - () => { - const dir = withTemp(); - const tempDir = join(dir, "pgdelta"); - mkdirSync(tempDir, { recursive: true }); - for (const ts of [100, 300, 200]) { - writeFileSync(join(tempDir, `catalog-local-declarative-h-${ts}.json`), "{}"); - } - return withServices((fs, path) => - Effect.gen(function* () { - const filePath = yield* legacyWriteDeclarativeCatalogSnapshot( - fs, - path, - tempDir, - "local", - "h", - '{"snapshot":true}', - 400, - ); - expect(filePath.endsWith("catalog-local-declarative-h-400.json")).toBe(true); - expect(yield* fs.readFileString(filePath)).toBe('{"snapshot":true}'); - const remaining = (yield* fs.readDirectory(tempDir)).filter((n) => - n.startsWith("catalog-local-declarative-"), - ); - expect(remaining.sort()).toEqual([ - "catalog-local-declarative-h-300.json", - "catalog-local-declarative-h-400.json", - ]); - }), - ).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); - }, - ); - - it.effect("creates the temp dir when it doesn't exist yet", () => { - const dir = withTemp(); - const tempDir = join(dir, "pgdelta"); - return withServices((fs, path) => - Effect.gen(function* () { - yield* legacyWriteDeclarativeCatalogSnapshot(fs, path, tempDir, "local", "h", "{}", 100); - expect(yield* fs.exists(join(tempDir, "catalog-local-declarative-h-100.json"))).toBe(true); - }), - ).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); - }); -}); - -describe("legacyCatalogPrefixFromConfig", () => { - const CONN = { host: "127.0.0.1", port: 5432, user: "postgres", database: "postgres" }; - - it("returns 'local' for a local database regardless of host", () => { - expect(legacyCatalogPrefixFromConfig(CONN, true)).toBe("local"); - }); - - it("returns the project ref for a direct db..supabase.{co,red} host", () => { - const ref = "abcdefghijklmnopqrst"; - expect(legacyCatalogPrefixFromConfig({ ...CONN, host: `db.${ref}.supabase.co` }, false)).toBe( - ref, - ); - expect(legacyCatalogPrefixFromConfig({ ...CONN, host: `db.${ref}.supabase.red` }, false)).toBe( - ref, - ); - }); - - it("falls back to a stable url- hash for anything else", () => { - const conn = { - host: "aws-0-us-east-1.pooler.supabase.com", - port: 6543, - user: "postgres.ref", - database: "postgres", - }; - expect(legacyCatalogPrefixFromConfig(conn, false)).toBe( - `url-${sha12(`${conn.user}@${conn.host}:${conn.port}/${conn.database}`)}`, - ); - }); - - it("does not match a host with the wrong ref length or a different TLD", () => { - const conn = { ...CONN, host: "db.tooshort.supabase.co" }; - const digest = createHash("sha256") - .update(`${conn.user}@${conn.host}:${conn.port}/${conn.database}`, "utf8") - .digest("hex"); - expect(legacyCatalogPrefixFromConfig(conn, false)).toBe(`url-${digest.slice(0, 12)}`); - }); -}); - -describe("legacyResolveMigrationCatalogPath", () => { - it.effect("resolves the newest snapshot for the (hash, prefix) family", () => { - const dir = withTemp(); - const tempDir = join(dir, "pgdelta"); - mkdirSync(tempDir, { recursive: true }); - for (const ts of [100, 300, 200]) { - writeFileSync(join(tempDir, `catalog-local-migrations-h-${ts}.json`), "{}"); - } - // A different hash in the same prefix family must not be picked up. - writeFileSync(join(tempDir, "catalog-local-migrations-other-500.json"), "{}"); - return withServices((fs, path) => - Effect.gen(function* () { - const latest = yield* legacyResolveMigrationCatalogPath(fs, path, tempDir, "h", "local"); - expect(Option.getOrNull(latest)?.endsWith("catalog-local-migrations-h-300.json")).toBe( - true, - ); - }), - ).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); - }); - - it.effect("returns None on a cache miss (no matching family member)", () => { - const dir = withTemp(); - const tempDir = join(dir, "pgdelta"); - return withServices((fs, path) => - Effect.gen(function* () { - const resolved = yield* legacyResolveMigrationCatalogPath(fs, path, tempDir, "h", "local"); - expect(Option.isNone(resolved)).toBe(true); - }), - ).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); - }); -}); - -describe("legacyResolveSetupInputs", () => { - it.effect("resolves the image and tolerates a missing roles.sql", () => { - const dir = withTemp(); - return withServices((fs, path) => - legacyResolveSetupInputs(fs, path, dir, 17, undefined, { - authEnabled: true, - storageEnabled: false, - realtimeEnabled: true, - apiAutoExposeNewTables: Option.none(), - vaultNames: ["a_secret"], - }), - ).pipe( - Effect.tap((inputs) => - Effect.sync(() => { - expect(inputs).toMatchObject({ - majorVersion: 17, - authEnabled: true, - storageEnabled: false, - realtimeEnabled: true, - autoExpose: true, - vaultNames: ["a_secret"], - rolesSql: "", - }); - expect(inputs.image.length).toBeGreaterThan(0); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); - - it.effect("reads roles.sql content and resolves the effective auto-expose bool", () => { - const dir = withTemp(); - mkdirSync(join(dir, "supabase"), { recursive: true }); - writeFileSync(join(dir, "supabase", "roles.sql"), "create role app;"); - return withServices((fs, path) => - legacyResolveSetupInputs(fs, path, dir, 17, undefined, { - authEnabled: true, - storageEnabled: true, - realtimeEnabled: true, - apiAutoExposeNewTables: Option.some(false), - vaultNames: [], - }), - ).pipe( - Effect.tap((inputs) => - Effect.sync(() => { - expect(inputs.rolesSql).toBe("create role app;"); - expect(inputs.autoExpose).toBe(false); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }); -}); - -describe("legacyMigrationCatalogFileName", () => { - it("formats catalog--migrations--.json", () => { - expect(legacyMigrationCatalogFileName("local", "h", 1700)).toBe( - "catalog-local-migrations-h-1700.json", - ); - }); -}); - -describe("legacyWriteMigrationCatalogSnapshot + cleanup", () => { - it.effect( - "writes the snapshot and prunes older migrations catalogs past the retention count", - () => { - const dir = withTemp(); - const tempDir = join(dir, "pgdelta"); - mkdirSync(tempDir, { recursive: true }); - for (const ts of [100, 300, 200]) { - writeFileSync(join(tempDir, `catalog-local-migrations-h-${ts}.json`), "{}"); - } - return withServices((fs, path) => - Effect.gen(function* () { - const filePath = yield* legacyWriteMigrationCatalogSnapshot( - fs, - path, - tempDir, - "local", - "h", - '{"snapshot":true}', - 400, - ); - expect(filePath.endsWith("catalog-local-migrations-h-400.json")).toBe(true); - expect(yield* fs.readFileString(filePath)).toBe('{"snapshot":true}'); - const remaining = (yield* fs.readDirectory(tempDir)).filter((n) => - n.startsWith("catalog-local-migrations-"), - ); - expect(remaining.sort()).toEqual([ - "catalog-local-migrations-h-300.json", - "catalog-local-migrations-h-400.json", - ]); - }), - ).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); - }, - ); - - it.effect("creates the temp dir when it doesn't exist yet", () => { - const dir = withTemp(); - const tempDir = join(dir, "pgdelta"); - return withServices((fs, path) => - Effect.gen(function* () { - yield* legacyWriteMigrationCatalogSnapshot(fs, path, tempDir, "local", "h", "{}", 100); - expect(yield* fs.exists(join(tempDir, "catalog-local-migrations-h-100.json"))).toBe(true); - }), - ).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); - }); -}); - -describe("legacyTryCacheMigrationsCatalog — timestamp ordering (review CLI-1958)", () => { - // `it.live` (not `it.effect`): the mocked export below uses a real `Effect.sleep` - // to create a measurable time gap, which needs the real wall clock, not - // `it.effect`'s virtual `TestClock` (which never auto-advances and would hang). - it.live( - "reads the clock AFTER the pg-delta export resolves, matching Go's WriteMigrationCatalogSnapshot ordering", - () => { - // Go's `TryCacheMigrationsCatalog` (`pgcache/cache.go:71-91`) resolves `hash` - // and `snapshot` FIRST and only THEN calls `WriteMigrationCatalogSnapshot`, - // which itself reads `time.Now().UTC()` (`pgcache/cache.go:151-163`) — i.e. - // Go's clock read happens LAST, right before the file write. The mocked - // edge-runtime export below sleeps for a real, measurable interval before - // resolving; the written snapshot's embedded timestamp must reflect a moment - // AFTER that sleep, proving the clock was read after the export — not - // captured up front by a caller before this function even started (the - // pre-fix bug). - const dir = withTemp(); - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - // Mirrors `legacyPgDeltaTempPath` (`/supabase/.temp/pgdelta`). - const tempDir = join(dir, "supabase", ".temp", "pgdelta"); - const beforeCallMillis = Date.now(); - const edge = Layer.succeed(LegacyEdgeRuntimeScript, { - run: () => - Effect.gen(function* () { - yield* Effect.sleep("30 millis"); - return { stdout: "{}", stderr: "" }; - }), - }); - const sslProbe = Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }); - const ctx: LegacyPgDeltaContext = { - projectId: "test", - cwd: dir, - npmVersion: undefined, - denoVersion: 1, - projectEnv: {}, - }; - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* legacyTryCacheMigrationsCatalog(fs, path, ctx, { - enabled: true, - targetUrl: "postgresql://postgres:postgres@127.0.0.1:5432/postgres", - conn: { host: "127.0.0.1", port: 5432, user: "postgres", database: "postgres" }, - isLocal: true, - migrationsDir, - }); - const names = (yield* fs.readDirectory(tempDir)).filter((n) => - n.startsWith("catalog-local-migrations-"), - ); - expect(names.length).toBe(1); - const match = /-(\d+)\.json$/.exec(names[0]!); - expect(match).not.toBeNull(); - const embeddedMillis = Number(match![1]); - expect(embeddedMillis).toBeGreaterThanOrEqual(beforeCallMillis + 25); - }).pipe( - Effect.provide(Layer.mergeAll(BunServices.layer, mockOutput().layer, edge, sslProbe)), - Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); - }, - ); -}); - -describe("legacyCleanupOldMigrationCatalogs", () => { - it.effect("only prunes files matching the given prefix's family", () => { - const dir = withTemp(); - const tempDir = join(dir, "pgdelta"); - mkdirSync(tempDir, { recursive: true }); - for (const ts of [100, 200, 300]) { - writeFileSync(join(tempDir, `catalog-local-migrations-h-${ts}.json`), "{}"); - } - writeFileSync(join(tempDir, "catalog-other-migrations-h-50.json"), "{}"); - return withServices((fs, path) => - Effect.gen(function* () { - yield* legacyCleanupOldMigrationCatalogs(fs, path, tempDir, "local"); - const remaining = (yield* fs.readDirectory(tempDir)).sort(); - expect(remaining).toEqual([ - "catalog-local-migrations-h-200.json", - "catalog-local-migrations-h-300.json", - "catalog-other-migrations-h-50.json", - ]); - }), - ).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); - }); - - it.effect( - "propagates a permission-denied directory read instead of treating it as empty (Go ReadDir parity)", - () => { - // Go's CleanupOldMigrationCatalogs only tolerates a genuinely MISSING temp dir - // (ensureTempDir already created it before ReadDir runs) — any other ReadDir - // failure propagates, so a permission-denied listing must fail here too rather - // than silently look like "no cached catalogs" (which would bypass retention - // indefinitely, since the caller's own best-effort warning never fires without - // a propagated failure). - const dir = withTemp(); - const tempDir = join(dir, "pgdelta"); - mkdirSync(tempDir, { recursive: true }); - writeFileSync(join(tempDir, "catalog-local-migrations-h-100.json"), "{}"); - chmodSync(tempDir, 0o000); - return withServices((fs, path) => - legacyCleanupOldMigrationCatalogs(fs, path, tempDir, "local").pipe(Effect.exit), - ).pipe( - Effect.tap((exit) => - Effect.sync(() => { - chmodSync(tempDir, 0o755); - expect(Exit.isFailure(exit)).toBe(true); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, - ); -}); diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta.integration.test.ts b/apps/cli/src/legacy/shared/legacy-pgdelta.integration.test.ts deleted file mode 100644 index 123246afe9..0000000000 --- a/apps/cli/src/legacy/shared/legacy-pgdelta.integration.test.ts +++ /dev/null @@ -1,351 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { BunServices } from "@effect/platform-bun"; -import { Cause, Effect, Exit, Layer } from "effect"; - -import { - type LegacyEdgeRuntimeRunOpts, - type LegacyEdgeRuntimeRunResult, - LegacyEdgeRuntimeScript, -} from "./legacy-edge-runtime-script.service.ts"; -import { LegacyEdgeRuntimeScriptError } from "./legacy-edge-runtime-script.errors.ts"; -import { LegacyPgDeltaSslProbe } from "./legacy-pgdelta-ssl-probe.service.ts"; -import { - LEGACY_DEFAULT_PG_DELTA_NPM_VERSION, - LEGACY_PG_DELTA_NPM_VERSION_PLACEHOLDER, -} from "../commands/db/shared/legacy-pgdelta.deno-templates.ts"; -import { - legacyDeclarativeExportPgDelta, - legacyDiffPgDelta, - legacyExportCatalogPgDelta, - type LegacyPgDeltaContext, -} from "./legacy-pgdelta.ts"; - -const CTX: LegacyPgDeltaContext = { - projectId: "ref", - cwd: "/proj", - npmVersion: undefined, - denoVersion: 2, - projectEnv: {}, -}; - -function fakeEdgeRuntime( - outcome: { - stdout?: string; - stderr?: string; - fail?: string; - docker?: "daemon" | "inspect" | "pull"; - } = {}, -) { - const calls: LegacyEdgeRuntimeRunOpts[] = []; - const layer = Layer.succeed(LegacyEdgeRuntimeScript, { - run: (opts: LegacyEdgeRuntimeRunOpts) => { - calls.push(opts); - if (outcome.fail !== undefined) { - return Effect.fail( - new LegacyEdgeRuntimeScriptError({ - message: outcome.fail, - ...(outcome.docker !== undefined ? { docker: outcome.docker } : {}), - }), - ); - } - return Effect.succeed({ - stdout: outcome.stdout ?? "", - stderr: outcome.stderr ?? "", - } satisfies LegacyEdgeRuntimeRunResult); - }, - }); - return { layer, calls }; -} - -// These refs are local (127.0.0.1) endpoints that refuse TLS, so the probe reports -// "not required" — matching the no-SSL-env passthrough these tests assert. -const probe = Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), -}); - -const failError = (exit: Exit.Exit) => - Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isFailReason)?.error : undefined; - -describe("legacyDiffPgDelta", () => { - it.effect( - "returns the SQL + stderr and passes the interpolated diff script + env + binds", - () => { - const edge = fakeEdgeRuntime({ - stdout: JSON.stringify({ - version: 1, - files: [ - { - order: 1, - name: "schema_changes", - transactionMode: "transactional", - sql: "-- unit 1\n\nALTER TABLE x;", - }, - ], - }), - stderr: "warn", - }); - return legacyDiffPgDelta(CTX, { - targetRef: "postgresql://u:p@127.0.0.1:54320/postgres?connect_timeout=10", - sourceRef: "supabase/.temp/catalog.json", - schema: ["public", "auth"], - formatOptions: '{"indent":2}', - }).pipe( - Effect.tap((result) => - Effect.sync(() => { - // The envelope is parsed into per-unit files and a flattened SQL join. - expect(result.sql).toBe("-- unit 1\n\nALTER TABLE x;"); - expect(result.files).toHaveLength(1); - expect(result.files[0]?.name).toBe("schema_changes"); - expect(result.stderr).toBe("warn"); - const opts = edge.calls[0]!; - expect(opts.errPrefix).toBe("error diffing schema"); - // The (remote-merged) deno_version is forwarded so the edge-runtime - // layer picks the configured Deno image, matching Go. - expect(opts.denoVersion).toBe(2); - // Default npm version interpolated into the template. - expect(opts.script).toContain( - `npm:@supabase/pg-delta@${LEGACY_DEFAULT_PG_DELTA_NPM_VERSION}`, - ); - expect(opts.script).not.toContain( - `npm:@supabase/pg-delta@${LEGACY_PG_DELTA_NPM_VERSION_PLACEHOLDER}`, - ); - // TARGET is a URL (passthrough); SOURCE catalog file mapped to /workspace. - expect(opts.env["TARGET"]).toBe( - "postgresql://u:p@127.0.0.1:54320/postgres?connect_timeout=10", - ); - expect(opts.env["SOURCE"]).toBe("/workspace/supabase/.temp/catalog.json"); - expect(opts.env["INCLUDED_SCHEMAS"]).toBe("public,auth"); - expect(opts.env["FORMAT_OPTIONS"]).toBe('{"indent":2}'); - expect(opts.binds).toEqual([ - "supabase_edge_runtime_ref:/root/.cache/deno:rw", - "/proj:/workspace", - ]); - }), - ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), - ); - }, - ); - - it.effect("omits SOURCE / schema / format when not provided", () => { - const edge = fakeEdgeRuntime({ stdout: "" }); - return legacyDiffPgDelta(CTX, { - targetRef: "postgresql://t", - sourceRef: "", - schema: [], - formatOptions: " ", - }).pipe( - Effect.tap(() => - Effect.sync(() => { - const env = edge.calls[0]!.env; - expect(env["SOURCE"]).toBeUndefined(); - expect(env["INCLUDED_SCHEMAS"]).toBeUndefined(); - expect(env["FORMAT_OPTIONS"]).toBeUndefined(); - }), - ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), - ); - }); - - it.effect("maps an edge-runtime failure to LegacyDeclarativeEdgeRuntimeError", () => { - const edge = fakeEdgeRuntime({ fail: "error diffing schema: boom" }); - return legacyDiffPgDelta(CTX, { - targetRef: "postgresql://t", - sourceRef: "", - schema: [], - formatOptions: "", - }).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeEdgeRuntimeError"); - expect((failError(exit) as { message: string }).message).toBe( - "error diffing schema: boom", - ); - }), - ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), - ); - }); - - it.effect("preserves docker failure classification through the pg-delta wrapper", () => { - const edge = fakeEdgeRuntime({ - fail: "error diffing schema: docker unavailable", - docker: "daemon", - }); - return legacyDiffPgDelta(CTX, { - targetRef: "postgresql://t", - sourceRef: "", - schema: [], - formatOptions: "", - }).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(failError(exit)).toMatchObject({ - _tag: "LegacyDeclarativeEdgeRuntimeError", - docker: "daemon", - }); - }), - ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), - ); - }); - - it.effect("fails with LegacyPgDeltaDiffParseError on a malformed envelope", () => { - const edge = fakeEdgeRuntime({ stdout: "not json{", stderr: "boom" }); - return legacyDiffPgDelta(CTX, { - targetRef: "postgresql://t", - sourceRef: "", - schema: [], - formatOptions: "", - }).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDiffParseError"); - const message = (failError(exit) as { message: string }).message; - expect(message).toContain("failed to parse pg-delta diff output"); - expect(message).toContain("boom"); - }), - ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), - ); - }); - - it.effect("rejects an unknown transaction mode", () => { - const edge = fakeEdgeRuntime({ - stdout: JSON.stringify({ - version: 1, - files: [ - { - order: 1, - name: "schema_changes", - transactionMode: "non-transactional", - sql: "SELECT 1;", - }, - ], - }), - }); - return legacyDiffPgDelta(CTX, { - targetRef: "postgresql://t", - sourceRef: "", - schema: [], - formatOptions: "", - }).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDiffParseError"); - expect((failError(exit) as { message: string }).message).toContain( - 'unknown pg-delta transaction mode "non-transactional"', - ); - }), - ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), - ); - }); -}); - -describe("legacyDeclarativeExportPgDelta", () => { - it.effect("parses the declarative output envelope", () => { - const payload = { - version: 1, - mode: "declarative", - files: [{ path: "public.sql", order: 0, statements: 2, sql: "..." }], - }; - const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); - return legacyDeclarativeExportPgDelta(CTX, { - targetRef: "postgresql://t", - sourceRef: "", - schema: [], - formatOptions: "", - }).pipe( - Effect.tap((out) => - Effect.sync(() => { - expect(out.version).toBe(1); - expect(out.files[0]?.path).toBe("public.sql"); - expect(edge.calls[0]!.errPrefix).toBe("error exporting declarative schema"); - }), - ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), - ); - }); - - it.effect("fails with empty-output error when the script prints nothing", () => { - const edge = fakeEdgeRuntime({ stdout: "", stderr: "stack" }); - return legacyDeclarativeExportPgDelta(CTX, { - targetRef: "postgresql://t", - sourceRef: "", - schema: [], - formatOptions: "", - }).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeEmptyOutputError"); - expect((failError(exit) as { message: string }).message).toBe( - "error exporting declarative schema: edge-runtime script produced no output:\nstack", - ); - }), - ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), - ); - }); - - it.effect("fails with parse error on invalid JSON", () => { - const edge = fakeEdgeRuntime({ stdout: "not json" }); - return legacyDeclarativeExportPgDelta(CTX, { - targetRef: "postgresql://t", - sourceRef: "", - schema: [], - formatOptions: "", - }).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeParseOutputError"); - expect((failError(exit) as { message: string }).message).toContain( - "failed to parse declarative export output:", - ); - }), - ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), - ); - }); -}); - -describe("legacyExportCatalogPgDelta", () => { - it.effect("returns the trimmed snapshot and sets ROLE / TARGET", () => { - const edge = fakeEdgeRuntime({ stdout: ' {"catalog":true}\n ' }); - return legacyExportCatalogPgDelta(CTX, { - targetRef: "postgresql://t", - role: "postgres", - }).pipe( - Effect.tap((snapshot) => - Effect.sync(() => { - expect(snapshot).toBe('{"catalog":true}'); - const opts = edge.calls[0]!; - expect(opts.errPrefix).toBe("error exporting pg-delta catalog"); - expect(opts.env["TARGET"]).toBe("postgresql://t"); - expect(opts.env["ROLE"]).toBe("postgres"); - }), - ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), - ); - }); - - it.effect("omits ROLE when empty and errors on empty output", () => { - const edge = fakeEdgeRuntime({ stdout: " ", stderr: "oops" }); - return legacyExportCatalogPgDelta(CTX, { targetRef: "postgresql://t", role: "" }).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeEmptyOutputError"); - }), - ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), - ); - }); -}); diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta.paths.ts b/apps/cli/src/legacy/shared/legacy-pgdelta.paths.ts index fdb2912c92..5d740aac22 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta.paths.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta.paths.ts @@ -1,11 +1,6 @@ /** * On-disk locations for pg-delta-adjacent cache/snapshot artefacts. * - * Split out of `legacy-pgdelta.cache.ts` (which owns the catalog cache's keys AND its - * shadow-provisioning resolution path) so `db-bootstrap/shadow-cache.ts` — the warm - * shadow-container cache, which `legacy-pgdelta.cache.ts` itself consumes for its own - * shadow provisioning — can reach path helpers without an import cycle between the two. - * * Two roots: * - {@link legacyPgDeltaTempPath}: project-local (`supabase/.temp/pgdelta`) — catalog * snapshots and debug bundles (Go-shared, workspace-mounted). diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta.ts b/apps/cli/src/legacy/shared/legacy-pgdelta.ts index be66428dc9..f2d511fa4d 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta.ts @@ -1,31 +1,6 @@ -import { Effect, FileSystem, Option, Path } from "effect"; +import { Option } from "effect"; -import { legacyViperEnvStringWithProjectFallback } from "../../shared/legacy/legacy-viper-env.ts"; -import { - type LegacyEdgeRuntimeFile, - LegacyEdgeRuntimeScript, -} from "./legacy-edge-runtime-script.service.ts"; import { legacyResolveLocalProjectId, legacySanitizeProjectId } from "./legacy-docker-ids.ts"; -import { - LEGACY_PG_DELTA_SOURCE_SSL_ENV, - LEGACY_PG_DELTA_TARGET_SSL_ENV, - legacyPreparePgDeltaRef, -} from "./legacy-pgdelta-ssl.ts"; -import { - legacyInterpolatePgDeltaScript, - legacyPgDeltaCatalogExportScript, - legacyPgDeltaDeclarativeExportScript, - legacyPgDeltaDiffScript, -} from "../commands/db/shared/legacy-pgdelta.deno-templates.ts"; -import { - LegacyDeclarativeEdgeRuntimeError, - LegacyDeclarativeEmptyOutputError, - LegacyDeclarativeParseOutputError, - LegacyPgDeltaDiffParseError, -} from "../commands/db/shared/legacy-pgdelta.errors.ts"; -import type { LegacyMigrationTransactionMode } from "./legacy-migration-file.ts"; - -const PG_DELTA_NPM_REGISTRY_ENV = "PGDELTA_NPM_REGISTRY"; /** A per-file payload from pg-delta declarative export. Mirrors Go's `DeclarativeFile`. */ interface LegacyDeclarativeFile { @@ -43,60 +18,22 @@ export interface LegacyDeclarativeOutput { } /** - * One execution-aware migration unit from a pg-delta diff plan. Mirrors Go's - * `PgDeltaPlanFile` (`internal/db/diff/pgdelta.go`): a numbered SQL file whose - * header comments record the unit number, transaction mode and boundary reason. - */ -interface LegacyPgDeltaPlanFile { - readonly order: number; - readonly name: string; - readonly transactionMode: LegacyMigrationTransactionMode; - readonly sql: string; -} - -/** The pg-delta diff envelope. Mirrors Go's `PgDeltaDiffOutput`. */ -interface LegacyPgDeltaDiffOutput { - readonly version: number; - readonly files: ReadonlyArray< - Omit & { - readonly transactionMode: string; - } - >; -} - -/** - * Result of a pg-delta diff: the per-unit plan `files`, a `sql` flattening of - * them (kept for `db diff` / declarative callers that consume one blob), and the - * edge-runtime `stderr`. - */ -interface LegacyPgDeltaDiffResult { - readonly sql: string; - readonly files: ReadonlyArray; - readonly stderr: string; -} - -/** - * Ambient inputs retained for the legacy pg-delta adapter: the project id (for the - * `supabase_edge_runtime_` Deno-cache volume), the working directory (mounted - * at `/workspace`), and the resolved pg-delta npm version (template interpolation). + * Ambient inputs shared by the pg-delta and migra diff workflows: the project id + * (for the `supabase_edge_runtime_` Deno-cache volume migra's edge-runtime + * run binds), the working directory, the effective `edge_runtime.deno_version`, + * and the project's parsed `supabase/.env`. */ export interface LegacyPgDeltaContext { readonly projectId: string; readonly cwd: string; - readonly npmVersion: string | undefined; /** * Effective `edge_runtime.deno_version` from the (remote-merged on `--linked`) - * config, forwarded to the edge-runtime container so pg-delta runs under the + * config, forwarded to the edge-runtime container so migra runs under the * configured Deno image. Mirrors Go, which resolves the image from the loaded * config the command operates on rather than the base `config.toml`. */ readonly denoVersion: number; - /** - * The project's parsed `supabase/.env` (`legacyReadDbToml`'s `projectEnv`), so - * {@link legacyPgDeltaNpmRegistryOption}'s `PGDELTA_NPM_REGISTRY` read matches Go's - * `os.Getenv`, which already observes `.env`-loaded values by this point (see that - * function's doc comment). - */ + /** The project's parsed `supabase/.env` (`legacyReadDbToml`'s `projectEnv`). */ readonly projectEnv: Readonly>; } @@ -118,7 +55,7 @@ export interface LegacyPgDeltaContext { * gate, review: PRRT_kwDOErm0O86XHGDL) — but `legacyResolveLocalProjectId` tries its FIRST * argument before its second, so passing the raw, ungated `cliProjectId` through would let * an unrelated ambient `SUPABASE_PROJECT_ID` win back over the matched remote's own id, - * mounting the wrong Deno-cache volume for a linked pg-delta run. Mirrors the same + * mounting the wrong Deno-cache volume for a linked run. Mirrors the same * suppression `legacy-local-project-context.ts`'s own `legacyLoadLocalProjectContext` * already applies (review: PRRT_kwDOErm0O86XI1w8). */ @@ -141,271 +78,13 @@ export function legacyIsPostgresURL(ref: string): boolean { return ref.startsWith("postgres://") || ref.startsWith("postgresql://"); } -/** - * Maps a host-relative catalog-file path to its in-container path (`cwd` mounted - * at `/workspace`); Postgres URLs and empty strings pass through. Separators are - * normalised to `/` so Windows paths resolve inside the Linux container. Mirrors - * Go's `containerRef` (`internal/db/diff/pgdelta.go:55-60`). - */ -export function legacyPgDeltaContainerRef(ref: string): string { - if (ref === "" || legacyIsPostgresURL(ref)) return ref; - return `/workspace/${ref.split("\\").join("/")}`; -} - /** Mirrors Go's `utils.EdgeRuntimeId` = `GetId("edge_runtime")` = `supabase_edge_runtime_`. */ export function legacyEdgeRuntimeId(projectId: string): string { return `supabase_edge_runtime_${projectId}`; } -/** - * The volume binds for a pg-delta run: the named Deno-cache volume (so npm - * downloads persist across runs) and the project root mounted at `/workspace` - * (so catalog files / `.npmrc` resolve). Mirrors the `binds` in - * `internal/db/diff/pgdelta.go`. - */ -export function legacyPgDeltaBinds(projectId: string, cwd: string): ReadonlyArray { - return [`${legacyEdgeRuntimeId(projectId)}:/root/.cache/deno:rw`, `${cwd}:/workspace`]; -} - /** Mirrors Go's `IsPgDeltaDebugEnabled` (`internal/db/diff/pgdelta_debug.go:11`). */ export function legacyIsPgDeltaDebugEnabled(): boolean { const value = (process.env["PGDELTA_DEBUG"] ?? "").trim().toLowerCase(); return value === "1" || value === "true" || value === "yes"; } - -/** - * Mirrors Go's `PgDeltaNpmRegistryOption` (`internal/utils/pgdelta_local.go:30`): - * when `PGDELTA_NPM_REGISTRY` is set, drop a project-local `.npmrc` scoping the - * `@supabase` registry and forward both `PGDELTA_NPM_REGISTRY` and the universal - * `NPM_CONFIG_REGISTRY` into the container. Exported so `legacy-pgdelta.apply.ts`'s - * declarative-apply runner (CLI-1956) can reuse the same option, matching every other - * pg-delta edge-runtime invocation in this file. - * - * `PGDELTA_NPM_REGISTRY` is a bare `os.Getenv` read in Go (`pgdelta_local.go:30`), not a - * viper-bound flag — but by the time Go reaches it, `config.Load`'s `loadNestedEnv` has - * already run `godotenv.Load` on the project's `supabase/.env`, which calls `os.Setenv` for - * every key not already present in the real process env (`godotenv@v1.5.1/godotenv.go:184- - * 200`). So a project `.env`-only `PGDELTA_NPM_REGISTRY` is visible to this exact `os.Getenv` - * call in Go. `projectEnv` reproduces that merge with the same shell-presence-wins semantics - * (review: PRRT_kwDOErm0O86XFmjf). - */ -export function legacyPgDeltaNpmRegistryOption(projectEnv: Readonly>): { - readonly extraFiles?: ReadonlyArray; - readonly extraEnv?: Readonly>; -} { - const registry = legacyViperEnvStringWithProjectFallback( - PG_DELTA_NPM_REGISTRY_ENV, - projectEnv, - ).trim(); - if (registry.length === 0) return {}; - return { - extraFiles: [{ name: ".npmrc", content: `@supabase:registry=${registry}\n` }], - extraEnv: { [PG_DELTA_NPM_REGISTRY_ENV]: registry, NPM_CONFIG_REGISTRY: registry }, - }; -} - -/** Adds the container ref + any SSL env for a SOURCE/TARGET endpoint (writes a CA bundle for Supabase-hosted remotes). */ -const appendRefEnv = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - cwd: string, - env: Record, - name: "SOURCE" | "TARGET", - ref: string, -) { - const sslRootCertEnv = - name === "SOURCE" ? LEGACY_PG_DELTA_SOURCE_SSL_ENV : LEGACY_PG_DELTA_TARGET_SSL_ENV; - const prepared = yield* legacyPreparePgDeltaRef(fs, path, cwd, ref, sslRootCertEnv); - env[name] = legacyPgDeltaContainerRef(prepared.ref); - Object.assign(env, prepared.sslEnv); -}); - -/** Builds the env shared by diff + declarative export (TARGET, optional SOURCE, schema, format). */ -const buildDiffEnv = Effect.fnUntraced(function* ( - fs: FileSystem.FileSystem, - path: Path.Path, - cwd: string, - params: { - readonly targetRef: string; - readonly sourceRef: string; - readonly schema: ReadonlyArray; - readonly formatOptions: string; - }, -) { - const env: Record = {}; - yield* appendRefEnv(fs, path, cwd, env, "TARGET", params.targetRef); - if (params.sourceRef.length > 0) - yield* appendRefEnv(fs, path, cwd, env, "SOURCE", params.sourceRef); - if (params.schema.length > 0) env["INCLUDED_SCHEMAS"] = params.schema.join(","); - if (params.formatOptions.trim().length > 0) env["FORMAT_OPTIONS"] = params.formatOptions; - if (legacyIsPgDeltaDebugEnabled()) env["PGDELTA_DEBUG"] = "1"; - return env; -}); - -const toDeclarativeEdgeRuntimeError = (error: { - readonly message: string; - readonly docker?: "daemon" | "inspect" | "pull"; -}) => - new LegacyDeclarativeEdgeRuntimeError({ - message: error.message, - ...(error.docker !== undefined ? { docker: error.docker } : {}), - }); - -/** - * Diffs SOURCE → TARGET via the pg-delta diff script. Mirrors Go's - * `DiffPgDeltaRefDetailed` (`internal/db/diff/pgdelta.go:108`). `sourceRef` may - * be empty (diff against an empty source). Refs are either Postgres URLs - * (`legacyToPostgresURL`) or host-relative catalog-file paths. - */ -export const legacyDiffPgDelta = Effect.fnUntraced(function* ( - ctx: LegacyPgDeltaContext, - params: { - readonly targetRef: string; - readonly sourceRef: string; - readonly schema: ReadonlyArray; - readonly formatOptions: string; - }, -) { - const edgeRuntime = yield* LegacyEdgeRuntimeScript; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const env = yield* buildDiffEnv(fs, path, ctx.cwd, params); - const npm = legacyPgDeltaNpmRegistryOption(ctx.projectEnv); - const result = yield* edgeRuntime - .run({ - script: legacyInterpolatePgDeltaScript(legacyPgDeltaDiffScript, ctx.npmVersion), - env, - binds: legacyPgDeltaBinds(ctx.projectId, ctx.cwd), - errPrefix: "error diffing schema", - extraFiles: npm.extraFiles, - extraEnv: npm.extraEnv, - denoVersion: ctx.denoVersion, - workdir: ctx.cwd, - }) - .pipe(Effect.mapError(toDeclarativeEdgeRuntimeError)); - // The template always prints the diff envelope on the success path, even for an - // empty plan (`{"version":1,"files":[]}`); a truly empty stdout means no envelope - // was produced, which we surface as "no changes" rather than a parse error. - // Mirrors Go's `parsePgDeltaDiffOutput` (`internal/db/diff/pgdelta.go`). - if (result.stdout.trim().length === 0) { - return { sql: "", files: [], stderr: result.stderr } satisfies LegacyPgDeltaDiffResult; - } - const envelope = yield* Effect.try({ - try: () => JSON.parse(result.stdout) as LegacyPgDeltaDiffOutput, - catch: (cause) => - new LegacyPgDeltaDiffParseError({ - message: `failed to parse pg-delta diff output: ${ - cause instanceof Error ? cause.message : String(cause) - }:\n${result.stderr}`, - }), - }); - const rawFiles = envelope.files ?? []; - const files: Array = []; - for (const file of rawFiles) { - const transactionMode = file.transactionMode; - if (transactionMode !== "transactional" && transactionMode !== "none") { - return yield* Effect.fail( - new LegacyPgDeltaDiffParseError({ - message: `unknown pg-delta transaction mode ${JSON.stringify(transactionMode)}`, - }), - ); - } - files.push({ ...file, transactionMode }); - } - // Flatten to one blob for callers that need it; unit header comments keep the - // transaction boundaries visible (mirrors Go's `joinPgDeltaFiles`). - const sql = files.map((file) => file.sql).join("\n\n"); - return { sql, files, stderr: result.stderr } satisfies LegacyPgDeltaDiffResult; -}); - -/** - * Exports TARGET as declarative file payloads. Mirrors Go's - * `DeclarativeExportPgDeltaRef` (`internal/db/diff/pgdelta.go:156`): empty output - * is an error, and the JSON envelope is parsed into `LegacyDeclarativeOutput`. - */ -export const legacyDeclarativeExportPgDelta = Effect.fnUntraced(function* ( - ctx: LegacyPgDeltaContext, - params: { - readonly targetRef: string; - readonly sourceRef: string; - readonly schema: ReadonlyArray; - readonly formatOptions: string; - }, -) { - const edgeRuntime = yield* LegacyEdgeRuntimeScript; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const env = yield* buildDiffEnv(fs, path, ctx.cwd, params); - const npm = legacyPgDeltaNpmRegistryOption(ctx.projectEnv); - const result = yield* edgeRuntime - .run({ - script: legacyInterpolatePgDeltaScript(legacyPgDeltaDeclarativeExportScript, ctx.npmVersion), - env, - binds: legacyPgDeltaBinds(ctx.projectId, ctx.cwd), - errPrefix: "error exporting declarative schema", - extraFiles: npm.extraFiles, - extraEnv: npm.extraEnv, - denoVersion: ctx.denoVersion, - workdir: ctx.cwd, - }) - .pipe(Effect.mapError(toDeclarativeEdgeRuntimeError)); - - if (result.stdout.length === 0) { - return yield* Effect.fail( - new LegacyDeclarativeEmptyOutputError({ - message: `error exporting declarative schema: edge-runtime script produced no output:\n${result.stderr}`, - }), - ); - } - - return yield* Effect.try({ - try: () => JSON.parse(result.stdout) as LegacyDeclarativeOutput, - catch: (cause) => - new LegacyDeclarativeParseOutputError({ - message: `failed to parse declarative export output: ${ - cause instanceof Error ? cause.message : String(cause) - }`, - }), - }); -}); - -/** - * Serializes TARGET into a pg-delta catalog snapshot (JSON) for caching. Mirrors - * Go's `ExportCatalogPgDelta` (`internal/db/diff/pgdelta.go:199`): `role` - * optionally steps down the connection; empty output is an error; the snapshot is - * trimmed. - */ -export const legacyExportCatalogPgDelta = Effect.fnUntraced(function* ( - ctx: LegacyPgDeltaContext, - params: { readonly targetRef: string; readonly role: string }, -) { - const edgeRuntime = yield* LegacyEdgeRuntimeScript; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const env: Record = {}; - yield* appendRefEnv(fs, path, ctx.cwd, env, "TARGET", params.targetRef); - if (params.role.length > 0) env["ROLE"] = params.role; - const npm = legacyPgDeltaNpmRegistryOption(ctx.projectEnv); - const result = yield* edgeRuntime - .run({ - script: legacyInterpolatePgDeltaScript(legacyPgDeltaCatalogExportScript, ctx.npmVersion), - env, - binds: legacyPgDeltaBinds(ctx.projectId, ctx.cwd), - errPrefix: "error exporting pg-delta catalog", - extraFiles: npm.extraFiles, - extraEnv: npm.extraEnv, - denoVersion: ctx.denoVersion, - workdir: ctx.cwd, - }) - .pipe(Effect.mapError(toDeclarativeEdgeRuntimeError)); - - const snapshot = result.stdout.trim(); - if (snapshot.length === 0) { - return yield* Effect.fail( - new LegacyDeclarativeEmptyOutputError({ - message: `error exporting pg-delta catalog: edge-runtime script produced no output:\n${result.stderr}`, - }), - ); - } - return snapshot; -}); diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta.unit.test.ts b/apps/cli/src/legacy/shared/legacy-pgdelta.unit.test.ts index f012934ce2..3e25b1621f 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta.unit.test.ts @@ -4,9 +4,6 @@ import { legacyEdgeRuntimeId, legacyIsPgDeltaDebugEnabled, legacyIsPostgresURL, - legacyPgDeltaBinds, - legacyPgDeltaContainerRef, - legacyPgDeltaNpmRegistryOption, } from "./legacy-pgdelta.ts"; describe("legacyIsPostgresURL", () => { @@ -18,42 +15,12 @@ describe("legacyIsPostgresURL", () => { }); }); -describe("legacyPgDeltaContainerRef", () => { - it("passes through empty strings and Postgres URLs unchanged", () => { - expect(legacyPgDeltaContainerRef("")).toBe(""); - expect(legacyPgDeltaContainerRef("postgresql://u:p@h:5432/db")).toBe( - "postgresql://u:p@h:5432/db", - ); - }); - - it("maps a relative catalog path under /workspace", () => { - expect(legacyPgDeltaContainerRef("supabase/.temp/catalog.json")).toBe( - "/workspace/supabase/.temp/catalog.json", - ); - }); - - it("normalizes Windows separators to forward slashes", () => { - expect(legacyPgDeltaContainerRef("supabase\\.temp\\catalog.json")).toBe( - "/workspace/supabase/.temp/catalog.json", - ); - }); -}); - describe("legacyEdgeRuntimeId", () => { it("names the deno-cache volume per project", () => { expect(legacyEdgeRuntimeId("my-ref")).toBe("supabase_edge_runtime_my-ref"); }); }); -describe("legacyPgDeltaBinds", () => { - it("binds the deno cache volume and the cwd workspace", () => { - expect(legacyPgDeltaBinds("ref", "/proj")).toEqual([ - "supabase_edge_runtime_ref:/root/.cache/deno:rw", - "/proj:/workspace", - ]); - }); -}); - describe("legacyIsPgDeltaDebugEnabled", () => { const prev = process.env["PGDELTA_DEBUG"]; afterEach(() => { @@ -75,43 +42,3 @@ describe("legacyIsPgDeltaDebugEnabled", () => { expect(legacyIsPgDeltaDebugEnabled()).toBe(false); }); }); - -describe("legacyPgDeltaNpmRegistryOption", () => { - const prev = process.env["PGDELTA_NPM_REGISTRY"]; - afterEach(() => { - if (prev === undefined) delete process.env["PGDELTA_NPM_REGISTRY"]; - else process.env["PGDELTA_NPM_REGISTRY"] = prev; - }); - - it("returns no option when unset in both the shell and the project .env", () => { - delete process.env["PGDELTA_NPM_REGISTRY"]; - expect(legacyPgDeltaNpmRegistryOption({})).toEqual({}); - }); - - it("falls back to the project .env when the shell env is unset (Go's godotenv.Load parity)", () => { - delete process.env["PGDELTA_NPM_REGISTRY"]; - const npm = legacyPgDeltaNpmRegistryOption({ - PGDELTA_NPM_REGISTRY: "https://registry.example.com", - }); - expect(npm.extraFiles).toEqual([ - { name: ".npmrc", content: "@supabase:registry=https://registry.example.com\n" }, - ]); - expect(npm.extraEnv).toEqual({ - PGDELTA_NPM_REGISTRY: "https://registry.example.com", - NPM_CONFIG_REGISTRY: "https://registry.example.com", - }); - }); - - it("prefers the shell env over the project .env (shell presence wins)", () => { - process.env["PGDELTA_NPM_REGISTRY"] = "https://shell.example.com"; - const npm = legacyPgDeltaNpmRegistryOption({ - PGDELTA_NPM_REGISTRY: "https://dotenv.example.com", - }); - expect(npm.extraEnv?.["PGDELTA_NPM_REGISTRY"]).toBe("https://shell.example.com"); - }); - - it("treats a whitespace-only value as unset", () => { - delete process.env["PGDELTA_NPM_REGISTRY"]; - expect(legacyPgDeltaNpmRegistryOption({ PGDELTA_NPM_REGISTRY: " " })).toEqual({}); - }); -}); From cd004ef2b72d9269c7b6e6e1cb9509b6147fb3be Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:42:50 +0000 Subject: [PATCH 02/17] feat(cli): default the schema diff engine to pg-delta for all projects (CLI-1588) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An absent [experimental.pgdelta] section (or a section that omits `enabled`) now resolves to pg-delta for db diff, db pull, and the delegated db remote commit. Rollback stays a one-liner: [experimental.pgdelta] enabled = false or per-invocation `db diff --use-migra` / `db pull --diff-engine migra`. - TypeScript config reader: `enabled` defaults to true when absent - @supabase/config schema + published config.schema.json: default true - Go binary (delegated db remote changes/commit, db pull --experimental): the ejected config template now defaults `enabled = true`, so viper's key-level merge resolves both an absent section and a section without the key to enabled; `IsPgDeltaEnabled` treats a nil section as enabled. The init-only PgDeltaInitEnabled flag is gone — the scaffold and the runtime default are the same value now. - Knock-on defaults that key off "pg-delta enabled": the declarative schema commands' gate is open by default (no --experimental needed), and db reset --experimental only takes the schema-files path when pg-delta is explicitly disabled. - The --use-pg-schema deprecation warning now points at the default pg-delta engine / --use-migra rather than "the default migra engine". - apps/cli-go/CONTRIBUTING.md scopes the Verdaccio/PGDELTA_NPM_REGISTRY workflow to the Go binary, which is the only remaining reader. Existing projects pinned to migra output should set `enabled = false` before upgrading if they depend on byte-identical migra diffs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BQAoY9JF4uiZRvKVkSd9Wy --- apps/cli-go/CONTRIBUTING.md | 10 ++++++- apps/cli-go/internal/utils/config.go | 5 ---- apps/cli-go/internal/utils/config_test.go | 19 ++----------- apps/cli-go/internal/utils/misc.go | 7 ++++- apps/cli-go/pkg/config/config.go | 5 ---- apps/cli-go/pkg/config/config_test.go | 27 ++++++++++++++----- apps/cli-go/pkg/config/templates/config.toml | 4 +-- apps/cli/docs/supabase/db/diff.md | 2 +- apps/cli/docs/supabase/db/pull.md | 16 +++-------- .../legacy/commands/db/diff/SIDE_EFFECTS.md | 2 +- .../legacy/commands/db/diff/diff.command.ts | 4 +-- .../legacy/commands/db/diff/diff.handler.ts | 2 +- .../legacy/commands/db/reset/SIDE_EFFECTS.md | 3 ++- .../shared/legacy-db-config.toml-read.ts | 7 ++--- .../legacy-db-config.toml-read.unit.test.ts | 11 ++++---- .../src/shared/init/project-init.templates.ts | 2 +- apps/docs/public/cli/config.schema.json | 4 +-- packages/config/src/experimental.ts | 6 ++--- 18 files changed, 67 insertions(+), 69 deletions(-) diff --git a/apps/cli-go/CONTRIBUTING.md b/apps/cli-go/CONTRIBUTING.md index 39d0d33d85..4ce12329f4 100644 --- a/apps/cli-go/CONTRIBUTING.md +++ b/apps/cli-go/CONTRIBUTING.md @@ -44,7 +44,15 @@ The Supabase API client is generated from OpenAPI spec. See [our guide](api/READ ## Testing local pg-delta builds -To exercise unpublished `@supabase/pg-delta` changes inside CLI edge-runtime scripts (`db pull`, `db diff`, `db push`, etc.), publish a local build via Verdaccio in [pg-toolbelt](https://github.com/supabase/pg-toolbelt) and point the CLI at that registry. +> **Scope:** this workflow only applies to the Go binary's own edge-runtime pg-delta +> path, which the TypeScript CLI still reaches through the delegated +> `db remote commit` / `db pull --experimental` commands. The main TypeScript CLI +> bundles `@supabase/pg-delta` in-process and reads neither `PGDELTA_NPM_REGISTRY` +> nor `supabase/.temp/pgdelta-version` — to test a local pg-delta build there, +> update the `@supabase/pg-delta` dependency pin in `apps/cli/package.json` / +> `pnpm-workspace.yaml` instead. + +To exercise unpublished `@supabase/pg-delta` changes inside the Go binary's edge-runtime scripts, publish a local build via Verdaccio in [pg-toolbelt](https://github.com/supabase/pg-toolbelt) and point the Go binary at that registry. ### 1. Start Verdaccio (pg-toolbelt) diff --git a/apps/cli-go/internal/utils/config.go b/apps/cli-go/internal/utils/config.go index d171d06753..108c3391a1 100644 --- a/apps/cli-go/internal/utils/config.go +++ b/apps/cli-go/internal/utils/config.go @@ -216,7 +216,6 @@ func ToRealtimeEnv(addr config.AddressFamily) string { type InitParams struct { ProjectId string UseOrioleDB bool - UsePgDelta bool Overwrite bool } @@ -226,10 +225,6 @@ func InitConfig(params InitParams, fsys afero.Fs) error { if params.UseOrioleDB { c.Experimental.OrioleDBVersion = "15.1.0.150" } - // The supabase init command opts new projects into pg-delta. Existing configs are - // unaffected because mergeDefaultValues ejects with this flag false (default stays - // migra), and other InitConfig callers leave it disabled. - c.Experimental.PgDeltaInitEnabled = params.UsePgDelta // Create config file if err := MkdirIfNotExistFS(fsys, SupabaseDirPath); err != nil { return err diff --git a/apps/cli-go/internal/utils/config_test.go b/apps/cli-go/internal/utils/config_test.go index 6d829304f1..cd2a69d2a8 100644 --- a/apps/cli-go/internal/utils/config_test.go +++ b/apps/cli-go/internal/utils/config_test.go @@ -72,22 +72,7 @@ func TestInitConfig(t *testing.T) { assert.True(t, exists) }) - t.Run("generated config enables pgdelta when requested", func(t *testing.T) { - fsys := afero.NewMemMapFs() - params := InitParams{ - ProjectId: "test-project", - UsePgDelta: true, - } - - err := InitConfig(params, fsys) - - require.NoError(t, err) - content, err := afero.ReadFile(fsys, ConfigPath) - require.NoError(t, err) - assert.Contains(t, string(content), "[experimental.pgdelta]\nenabled = true") - }) - - t.Run("generated config leaves pgdelta disabled by default", func(t *testing.T) { + t.Run("generated config enables pgdelta by default", func(t *testing.T) { fsys := afero.NewMemMapFs() params := InitParams{ ProjectId: "test-project", @@ -98,7 +83,7 @@ func TestInitConfig(t *testing.T) { require.NoError(t, err) content, err := afero.ReadFile(fsys, ConfigPath) require.NoError(t, err) - assert.Contains(t, string(content), "[experimental.pgdelta]\nenabled = false") + assert.Contains(t, string(content), "[experimental.pgdelta]\nenabled = true") }) t.Run("creates config with orioledb", func(t *testing.T) { diff --git a/apps/cli-go/internal/utils/misc.go b/apps/cli-go/internal/utils/misc.go index 8805544430..f085849587 100644 --- a/apps/cli-go/internal/utils/misc.go +++ b/apps/cli-go/internal/utils/misc.go @@ -124,7 +124,12 @@ func GetDeclarativeDir() string { } func IsPgDeltaEnabled() bool { - return Config.Experimental.PgDelta != nil && Config.Experimental.PgDelta.Enabled + // pg-delta is the default diff engine: an absent [experimental.pgdelta] + // section (nil before config load) resolves to enabled. The config template + // ejects `enabled = true` as the viper default, so a section that omits the + // key also resolves to enabled; only an explicit `enabled = false` opts back + // into migra. + return Config.Experimental.PgDelta == nil || Config.Experimental.PgDelta.Enabled } func GetCurrentTimestamp() string { diff --git a/apps/cli-go/pkg/config/config.go b/apps/cli-go/pkg/config/config.go index 7fb7b684ed..9f368ce70c 100644 --- a/apps/cli-go/pkg/config/config.go +++ b/apps/cli-go/pkg/config/config.go @@ -347,11 +347,6 @@ type ( Webhooks *webhooks `toml:"webhooks" json:"webhooks"` PgDelta *PgDeltaConfig `toml:"pgdelta" json:"pgdelta"` Inspect inspect `toml:"inspect" json:"inspect"` - // PgDeltaInitEnabled drives the [experimental.pgdelta] enabled value rendered - // by Eject. It is true only for the supabase init scaffold so freshly generated - // projects opt into pg-delta, and false when Eject feeds mergeDefaultValues so - // existing configs without the section keep resolving to migra (non-breaking). - PgDeltaInitEnabled bool `toml:"-" json:"-"` } ) diff --git a/apps/cli-go/pkg/config/config_test.go b/apps/cli-go/pkg/config/config_test.go index f09803bc4e..f82d735d25 100644 --- a/apps/cli-go/pkg/config/config_test.go +++ b/apps/cli-go/pkg/config/config_test.go @@ -245,8 +245,6 @@ format_options = "not-json" t.Run("init scaffold opts into pgdelta", func(t *testing.T) { config := NewConfig() - // supabase init renders the scaffold with the pg-delta opt-in flag set - config.Experimental.PgDeltaInitEnabled = true var buf bytes.Buffer require.NoError(t, config.Eject(&buf)) fsys := fs.MapFS{"supabase/config.toml": &fs.MapFile{Data: buf.Bytes()}} @@ -256,7 +254,7 @@ format_options = "not-json" assert.True(t, config.Experimental.PgDelta.Enabled) }) - t.Run("absent pgdelta section falls back to migra", func(t *testing.T) { + t.Run("absent pgdelta section defaults to pg-delta", func(t *testing.T) { config := NewConfig() fsys := fs.MapFS{ "supabase/config.toml": &fs.MapFile{Data: []byte(` @@ -265,11 +263,28 @@ orioledb_version = "" `)}, } - // The default ejected by mergeDefaultValues keeps pg-delta disabled, so a config - // without the section resolves to migra (PgDelta is non-nil only for version pinning). + // The default ejected by mergeDefaultValues enables pg-delta, so a config + // without the section resolves to pg-delta. require.NoError(t, config.Load("", fsys)) require.NotNil(t, config.Experimental.PgDelta) - assert.False(t, config.Experimental.PgDelta.Enabled) + assert.True(t, config.Experimental.PgDelta.Enabled) + }) + + t.Run("pgdelta section without enabled key defaults to pg-delta", func(t *testing.T) { + config := NewConfig() + fsys := fs.MapFS{ + "supabase/config.toml": &fs.MapFile{Data: []byte(` +[experimental.pgdelta] +declarative_schema_path = "./db/decl" +`)}, + } + + // viper merges the user file over the ejected defaults key-by-key, so a + // section that omits enabled keeps the default true rather than the Go + // zero value false. + require.NoError(t, config.Load("", fsys)) + require.NotNil(t, config.Experimental.PgDelta) + assert.True(t, config.Experimental.PgDelta.Enabled) }) t.Run("explicit enabled false restores migra", func(t *testing.T) { diff --git a/apps/cli-go/pkg/config/templates/config.toml b/apps/cli-go/pkg/config/templates/config.toml index fe820ae14b..24cc4b2e7b 100644 --- a/apps/cli-go/pkg/config/templates/config.toml +++ b/apps/cli-go/pkg/config/templates/config.toml @@ -404,10 +404,10 @@ s3_access_key = "env(S3_ACCESS_KEY)" # Configures AWS_SECRET_ACCESS_KEY for S3 bucket s3_secret_key = "env(S3_SECRET_KEY)" -# pg-delta is the schema diff engine for db diff / db pull / db remote commit. +# pg-delta is the default schema diff engine for db diff / db pull / db remote commit. # Set enabled = false to fall back to the legacy migra engine. [experimental.pgdelta] -enabled = {{ .Experimental.PgDeltaInitEnabled }} +enabled = true # Directory under `supabase/` where declarative files are written. # declarative_schema_path = "./schemas" # JSON string passed through to pg-delta SQL formatting. diff --git a/apps/cli/docs/supabase/db/diff.md b/apps/cli/docs/supabase/db/diff.md index 6ad22f7e1a..2d245a1cc8 100644 --- a/apps/cli/docs/supabase/db/diff.md +++ b/apps/cli/docs/supabase/db/diff.md @@ -10,7 +10,7 @@ Explicit `--from`/`--to` mode always uses pg-delta. In this mode, `-f` is ignore By default, all schemas in the target database are diffed. Use the `--schema public,extensions` flag to restrict diffing to a subset of schemas. -Projects created by a recent `supabase init` default to the pg-delta diff engine (`[experimental.pgdelta] enabled = true` in `config.toml`). Existing projects are unaffected and keep using migra unless they opt in. To fall back to the legacy migra engine, set `enabled = false` under `[experimental.pgdelta]`, or pass `--use-migra` for a single run. +pg-delta is the default diff engine for all projects. To fall back to the legacy migra engine, set `enabled = false` under `[experimental.pgdelta]` in `config.toml`, or pass `--use-migra` for a single run. With the bundled pg-delta engine, diff SQL defaults to uppercase keywords, indent 2, a maximum width of 180, trailing commas, and column/key alignment, matching its declarative export. When `-f` writes migrations, execution-aware transaction semantics are preserved as ordered per-unit files; non-transactional units carry a directive that the CLI apply path honors. Flattened review output retains the rendered SQL and preambles, but not the unit boundaries supplied to a migration runner. Configure overrides with `[experimental.pgdelta] format_options`, or set `format_options = "null"` to emit raw, unformatted statements. diff --git a/apps/cli/docs/supabase/db/pull.md b/apps/cli/docs/supabase/db/pull.md index e10c0679f7..b692c49756 100644 --- a/apps/cli/docs/supabase/db/pull.md +++ b/apps/cli/docs/supabase/db/pull.md @@ -8,15 +8,15 @@ Requires your local project to be linked to a remote database by running `supaba Optionally, a new row can be inserted into the migration history table to reflect the current state of the remote database. -If no entries exist in the migration history table, the default diff engine uses `pg_dump` to capture all contents of the remote schemas you have created. Otherwise, this command will only diff schema changes against the remote database, similar to running `db diff --linked`. +If no entries exist in the migration history table, pg-delta (the default diff engine) produces the full migration from the shadow diff alone; with `--diff-engine migra`, the initial pull instead uses `pg_dump` to capture all contents of the remote schemas you have created. Otherwise, this command will only diff schema changes against the remote database, similar to running `db diff --linked`. -Pass `--diff-engine pg-delta` to keep the migration-file `db pull` workflow while using pg-delta for the shadow diff step. On initial pull, pg-delta replaces `pg_dump` and produces the full migration from the shadow diff alone. Pass `--declarative` to switch to the declarative pg-delta export workflow instead. +Pass `--declarative` to switch to the declarative pg-delta export workflow instead of writing a migration file. pg-delta plans are execution-aware: when a plan crosses a transaction boundary — for example `ALTER TYPE ... ADD VALUE` followed by a statement that uses the new enum value, which cannot run in the same transaction — `db pull` writes one ordered migration file per plan unit instead of a single file (for example `_remote_schema_schema_changes.sql` and `_remote_schema_after_enum_values.sql`), each recorded in the migration history. The common case (a single unit) still produces exactly one `_remote_schema.sql` file. By default the emitted SQL is formatted with the same settings the declarative export uses (uppercase keywords, wrapped at a max width of 180, indented and column-aligned). Configure overrides with `[experimental.pgdelta] format_options` in `config.toml`, or set `format_options = "null"` to opt out and emit raw, unformatted statements. -When `[experimental.pgdelta] enabled = true` (the default for projects created by a recent `supabase init`), the migration-file `db pull` workflow uses pg-delta for the shadow diff step by default; it does not switch to declarative output. Existing projects without the section are unaffected and keep using migra. To fall back to the legacy migra engine, set `enabled = false` under `[experimental.pgdelta]`, or pass `--diff-engine migra` for a single run. +pg-delta is the default diff engine: the migration-file `db pull` workflow uses pg-delta for the shadow diff step unless configured otherwise; it does not switch to declarative output. To fall back to the legacy migra engine, set `enabled = false` under `[experimental.pgdelta]` in `config.toml`, or pass `--diff-engine migra` for a single run. When pulling from a remote database with `--db-url`, prefer a direct connection (`db..supabase.co:5432`) over the connection pooler so pg-delta can introspect the full catalog reliably. @@ -28,14 +28,6 @@ If `db pull --diff-engine pg-delta` reports `No schema changes found` but you ex PGDELTA_DEBUG=1 supabase db pull --db-url "$DATABASE_URL" --diff-engine pg-delta ``` -When pg-delta returns zero statements, the CLI writes a debug bundle under `supabase/.temp/pgdelta/debug//`: - -- `source-catalog.json` — shadow database baseline pg-delta extracted -- `target-catalog.json` — remote database pg-delta extracted -- `pgdelta-stderr.txt` — pg-delta script diagnostics (statement count, schemas) -- `connection.txt` — redacted connection metadata -- `error.txt` — error summary - -Catalog files are not written during normal `db pull` runs. The `.temp/pgdelta` directory is also used by migration catalog caching (`db push`, local `db start`) when `[experimental.pgdelta] enabled = true`. +When pg-delta returns zero statements, the CLI writes a debug bundle under `supabase/.temp/pgdelta/v2/debug/-diff/` containing the source/desired catalog snapshots, the plan, and coverage diagnostics. Catalog files are not written during normal `db pull` runs. For TLS tracing without disabling SSL, use `SUPABASE_SSL_DEBUG=true` alongside `PGDELTA_DEBUG=1`. 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 3db3f5333c..240bffe850 100644 --- a/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md @@ -301,7 +301,7 @@ Given that, the flag is now deprecated rather than ported: - A TS-only stderr deprecation warning is printed immediately before delegating (both text and machine `--output-format` modes — diagnostics stay stderr-only, - the CLI-1546 rule): `"--use-pg-schema" is deprecated. Use the pg-delta engine ([experimental.pgdelta] enabled = true / --use-pg-delta) or the default migra engine instead.` + the CLI-1546 rule): `"--use-pg-schema" is deprecated. Use the default pg-delta engine or the migra engine (--use-migra) instead.` The warning text intentionally does not promise a removal timeline. - This is **additive** to (printed before) Go's own pre-existing "experimental" warning (`cmd/db.go:121`, unchanged): `--use-pg-schema flag is experimental and may not include all entities, such as views and grants.` The delegated child diff --git a/apps/cli/src/legacy/commands/db/diff/diff.command.ts b/apps/cli/src/legacy/commands/db/diff/diff.command.ts index 9a29292a3c..0449450aa1 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.command.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.command.ts @@ -21,13 +21,13 @@ const config = { Flag.optional, ), usePgSchema: Flag.boolean("use-pg-schema").pipe( - // Deprecated in favor of the pg-delta engine (or the default migra engine) — + // Deprecated in favor of the default pg-delta engine (or the migra engine) — // a keep-in-Go exception (in-process stripe/pg-schema-diff library, no // TS/container equivalent — see SIDE_EFFECTS.md). This description-only // notice is not enforced by the flag framework — see diff.handler.ts's // runtime warning for the enforced half of the deprecation. Flag.withDescription( - "Use pg-schema-diff to generate schema diff. Deprecated: use the pg-delta engine ([experimental.pgdelta] enabled = true / --use-pg-delta) or the default migra engine instead.", + "Use pg-schema-diff to generate schema diff. Deprecated: use the default pg-delta engine or the migra engine (--use-migra) instead.", ), Flag.optional, ), 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 b532bb8fc8..d94d27fca6 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -85,7 +85,7 @@ Run ${legacyAqua("supabase db reset")} to verify that the new migration does not // SIDE_EFFECTS.md). The flag is deprecated in favor of the pg-delta engine. // This warning is additive to (and prints before) the delegated child's own // "experimental" warning, which it still prints unchanged. -const warnPgSchemaDeprecated = `${legacyYellow("WARNING:")} "--use-pg-schema" is deprecated. Use the pg-delta engine ([experimental.pgdelta] enabled = true / --use-pg-delta) or the default migra engine instead.`; +const warnPgSchemaDeprecated = `${legacyYellow("WARNING:")} "--use-pg-schema" is deprecated. Use the default pg-delta engine or the migra engine (--use-migra) instead.`; const declarativeBaselineAdvisory = (declarativePath: string | null) => ({ code: "DeclarativeSchemaNotUsedAsDiffBaseline", diff --git a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md index 5f7b9b47e7..66d4972ad1 100644 --- a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md @@ -4,7 +4,8 @@ Reinitialises a database from local migrations (plus seed). Both targets are fully native. The **remote** path (`--linked`, or a remote `--db-url`) drops all user schemas, upserts vault secrets, then either re-applies migrations (the default) or, on a versionless `--experimental`/`SUPABASE_EXPERIMENTAL` reset -with pg-delta not enabled, applies the declarative `[db.migrations].schema_paths` +with pg-delta explicitly disabled (`[experimental.pgdelta] enabled = false`; pg-delta +is enabled by default), applies the declarative `[db.migrations].schema_paths` files instead (the `MigrateAndSeed` EXPERIMENTAL branch, CLI-1958), then seeds. The **local** path (`--local`/default, or a `--db-url` pointing at the local stack) is ALSO fully native (CLI-1955 removed the hidden Go `db __db-bootstrap` seam diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts index 96f7d1c816..2ed9d2f903 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts @@ -188,7 +188,7 @@ export interface LegacyBaselineTomlConfig { /** The `[experimental.pgdelta]` subtree. */ export interface LegacyPgDeltaTomlConfig { - /** `[experimental.pgdelta] enabled`, default false. `IsPgDeltaEnabled`. */ + /** `[experimental.pgdelta] enabled`, default true. `IsPgDeltaEnabled`. */ readonly enabled: boolean; /** * `[experimental.pgdelta] declarative_schema_path`, resolved to a @@ -1749,7 +1749,8 @@ const readDbTomlCore = Effect.fnUntraced(function* ( // Go decodes this bool via `strconv.ParseBool` (mapstructure weakly typed), so `"1"` // counts as true and a malformed value (`SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED=maybe`) // aborts the load. The env override wins (viper AutomaticEnv), then the TOML bool, then - // an `env(VAR)` string, defaulting to false when absent. + // an `env(VAR)` string, defaulting to true when absent: pg-delta is the default + // diff engine, and only an explicit `enabled = false` opts back into migra. let enabled: boolean; if (enabledEnv !== undefined) { // The AutomaticEnv override is decoded through `LoadEnvHook`, so an `env(VAR)` @@ -1781,7 +1782,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( } enabled = parsed; } else { - enabled = false; + enabled = true; } const declarativeSchemaPathRaw = pgDeltaRaw?.["declarative_schema_path"]; diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts index df06cb9df3..5d920229bb 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts @@ -1032,15 +1032,16 @@ describe("legacyReadDbToml", () => { it.effect("SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED still wins when the block omits pgdelta", () => { // Control: the env override is suppressed only for keys the matched block explicitly set; - // a block that omits experimental.pgdelta.enabled leaves the env override in force. + // a block that omits experimental.pgdelta.enabled leaves the env override in force + // (an explicit false beats the enabled-by-default resolution). const ref = "abcdefghijklmnopqrst"; const previous = process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"]; - process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"] = "true"; + process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"] = "false"; const dir = withConfig(["[remotes.prod]", `project_id = "${ref}"`, ""].join("\n")); return readRef(dir, ref).pipe( Effect.tap((v) => Effect.sync(() => { - expect(v.pgDelta.enabled).toBe(true); + expect(v.pgDelta.enabled).toBe(false); }), ), Effect.ensuring( @@ -2713,12 +2714,12 @@ describe("legacyReadDbToml", () => { }); describe("legacyReadDbToml [experimental.pgdelta]", () => { - it.effect("defaults pg-delta to disabled with no config", () => { + it.effect("defaults pg-delta to enabled with no config", () => { const dir = withConfig(undefined); return read(dir).pipe( Effect.tap((v) => Effect.sync(() => { - expect(v.pgDelta.enabled).toBe(false); + expect(v.pgDelta.enabled).toBe(true); expect(Option.isNone(v.pgDelta.declarativeSchemaPath)).toBe(true); expect(Option.isNone(v.pgDelta.formatOptions)).toBe(true); rmSync(dir, { recursive: true, force: true }); diff --git a/apps/cli/src/shared/init/project-init.templates.ts b/apps/cli/src/shared/init/project-init.templates.ts index 3a5c438a8c..b00c7a0eac 100644 --- a/apps/cli/src/shared/init/project-init.templates.ts +++ b/apps/cli/src/shared/init/project-init.templates.ts @@ -403,7 +403,7 @@ s3_access_key = "env(S3_ACCESS_KEY)" # Configures AWS_SECRET_ACCESS_KEY for S3 bucket s3_secret_key = "env(S3_SECRET_KEY)" -# pg-delta is the schema diff engine for db diff / db pull / db remote commit. +# pg-delta is the default schema diff engine for db diff / db pull / db remote commit. # Set enabled = false to fall back to the legacy migra engine. [experimental.pgdelta] enabled = true diff --git a/apps/docs/public/cli/config.schema.json b/apps/docs/public/cli/config.schema.json index 71eb17061d..d7e26e8b31 100644 --- a/apps/docs/public/cli/config.schema.json +++ b/apps/docs/public/cli/config.schema.json @@ -3396,8 +3396,8 @@ "properties": { "enabled": { "type": "boolean", - "description": "Use pg-delta as the schema diff engine for db diff / db pull / db remote commit. Set false to fall back to the legacy migra engine.", - "default": false + "description": "Use pg-delta as the schema diff engine for db diff / db pull / db remote commit (the default). Set false to fall back to the legacy migra engine.", + "default": true }, "declarative_schema_path": { "type": "string", diff --git a/packages/config/src/experimental.ts b/packages/config/src/experimental.ts index 9b6e13b243..08fb2f6bab 100644 --- a/packages/config/src/experimental.ts +++ b/packages/config/src/experimental.ts @@ -78,11 +78,11 @@ export const experimental = Schema.Struct({ pgdelta: Schema.optionalKey( Schema.Struct({ enabled: Schema.Boolean.annotate({ - default: false, + default: true, description: - "Use pg-delta as the schema diff engine for db diff / db pull / db remote commit. Set false to fall back to the legacy migra engine.", + "Use pg-delta as the schema diff engine for db diff / db pull / db remote commit (the default). Set false to fall back to the legacy migra engine.", tags, - }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(false))), + }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(true))), declarative_schema_path: Schema.optionalKey( Schema.String.annotate({ description: "Directory under supabase/ where declarative schema files are written.", From 9bac2a7b4f9f2c1ec14e4648047913c3fcf973c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:48:08 +0000 Subject: [PATCH 03/17] test(cli): cover the pg-delta default in the declarative gate tests (CLI-1588) The declarative commands' gate is open by default now, so the gate-closed tests seed an explicit [experimental.pgdelta] enabled = false config, and new tests pin the default-open behavior. The sync recovery-reset tests' fixture fails apply once instead of always: under the pg-delta default the in-process recovery reset genuinely replays migrations, so a permanently failing ALTER made the reset itself fail rather than exercising the recovery flow. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BQAoY9JF4uiZRvKVkSd9Wy --- .../generate/generate.integration.test.ts | 43 +++++++- .../declarative/sync/sync.integration.test.ts | 99 ++++++++++++++----- 2 files changed, 114 insertions(+), 28 deletions(-) diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts index 3e78e954ea..3b49bc8d04 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts @@ -279,10 +279,24 @@ const flags = ( const failError = (exit: Exit.Exit) => Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isFailReason)?.error : undefined; +// pg-delta is the default schema diff engine (CLI-1588): an absent +// `[experimental.pgdelta]` section resolves to enabled = true, so gate-closed +// scenarios must now disable it explicitly. +const seedPgDeltaDisabledConfig = (workdir: string) => { + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + "[experimental.pgdelta]\nenabled = false\n", + ); +}; + describe("legacy db schema declarative generate integration", () => { const tmp = useLegacyTempWorkdir(); - it.effect("gate: fails when neither --experimental nor config enables pg-delta", () => { + it.effect("gate: fails when config disables pg-delta and --experimental is not passed", () => { + // pg-delta is the default engine (CLI-1588): the gate only closes when the + // config EXPLICITLY sets `enabled = false` and --experimental is absent. + seedPgDeltaDisabledConfig(tmp.current); const { layer } = setup(tmp.current, { experimental: false }); return Effect.gen(function* () { const exit = yield* Effect.exit( @@ -293,6 +307,19 @@ describe("legacy db schema declarative generate integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect( + "gate: open by default — no [experimental.pgdelta] section and no --experimental", + () => { + // The pg-delta default flip (CLI-1588): an absent section resolves to + // enabled = true, so generate proceeds without --experimental. + const s = setup(tmp.current, { experimental: false }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })); + expect(s.engineExportCalls).toHaveLength(1); + }).pipe(Effect.provide(s.layer)); + }, + ); + it.effect("--local --linked with --experimental fails with the mutex error", () => { // Go's declarative PersistentPreRunE gate (db_schema_declarative.go:49-99) runs // BEFORE cobra's ValidateFlagGroups() mutex check (cobra@v1.10.2/command.go:985, @@ -318,7 +345,9 @@ describe("legacy db schema declarative generate integration", () => { () => { // Mirrors storage's experimental-gate-vs-mutex ordering fix (CLI-1855 / CLI-1876): // the pg-delta gate runs before the mutex check, so an unopened gate wins even - // when the flags would also violate mutual exclusivity. + // when the flags would also violate mutual exclusivity. Closing the gate now + // requires an explicit `enabled = false` (pg-delta default flip, CLI-1588). + seedPgDeltaDisabledConfig(tmp.current); const { layer } = setup(tmp.current, { experimental: false }); return Effect.gen(function* () { const exit = yield* Effect.exit( @@ -368,7 +397,9 @@ describe("legacy db schema declarative generate integration", () => { // viper's bound-pflag lookup returns the flag value whenever Changed is true — // BEFORE falling back to AutomaticEnv (viper@v1.21.0/viper.go:1176-1178) — so an // explicit --experimental=false must win over SUPABASE_EXPERIMENTAL=1, closing the - // gate instead of letting the env value override it. + // gate instead of letting the env value override it. The config must disable + // pg-delta explicitly, or the new default (CLI-1588) keeps the gate open anyway. + seedPgDeltaDisabledConfig(tmp.current); const { layer } = setup(tmp.current, { experimental: false, args: ["db", "schema", "declarative", "generate", "--experimental=false"], @@ -755,13 +786,17 @@ describe("legacy db schema declarative generate integration", () => { () => { // Go gates pg-delta on the base LoadConfig (declarative PersistentPreRunE) before the // root ParseDatabaseConfig reloads the remote block, so a remote enabled=true must NOT - // enable a base-disabled command without --experimental. + // enable a base-disabled command without --experimental. The base disables pg-delta + // explicitly (an absent section would be enabled by default since CLI-1588), keeping + // the subject — the gate reads the BASE config — testable. const ref = "abcdefghijklmnopqrst"; mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync( join(tmp.current, "supabase", "config.toml"), [ 'project_id = "base"', + "[experimental.pgdelta]", + "enabled = false", "[remotes.prod]", `project_id = "${ref}"`, "[remotes.prod.experimental.pgdelta]", diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts index cc7aed7ecc..5ce21f4b0e 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts @@ -132,34 +132,49 @@ function setup(workdir: string, opts: SetupOpts = {}) { // expects to stay empty until the REAL local-apply connection // (`applyMigrationToLocal`, `toml.port`) runs. const SHADOW_PORT = 54320; + // `applyFails` fails only the FIRST attempt at the migration's ALTER statement. + // With pg-delta enabled by default (CLI-1588), the recovery reset's in-process + // `legacyResetLocalDatabase` replays timestamped migration files natively + // (`legacyMigrateAndSeed`'s migrations branch — it no longer takes the + // experimental schema-files branch, whose empty `schema_paths = []` default + // applied nothing), so the reapply on the freshly reset database must succeed, + // mirroring a failure that a reset actually recovers from. + let applyFailed = false; const dbConn = Layer.succeed(LegacyDbConnection, { connect: (cfg: LegacyPgConnInput) => Effect.succeed({ exec: (sql: string) => - opts.applyFails === true && sql.startsWith("ALTER") - ? Effect.fail({ _tag: "LegacyDbExecError", message: "boom" } as never) - : Effect.sync(() => { - if (cfg.port !== SHADOW_PORT) dbExec.push(sql); - }), - execBatch: (statements: ReadonlyArray) => { - const sql = statements.map((statement) => statement.sql); - const failureIndex = - opts.applyFails === true - ? sql.findIndex((statement) => statement.startsWith("ALTER")) - : -1; - return failureIndex >= 0 - ? Effect.fail({ + Effect.suspend(() => { + if (opts.applyFails === true && !applyFailed && sql.startsWith("ALTER")) { + applyFailed = true; + return Effect.fail({ _tag: "LegacyDbExecError", message: "boom" } as never); + } + return Effect.sync(() => { + if (cfg.port !== SHADOW_PORT) dbExec.push(sql); + }); + }), + execBatch: (statements: ReadonlyArray) => + Effect.suspend(() => { + const sql = statements.map((statement) => statement.sql); + const failureIndex = + opts.applyFails === true && !applyFailed + ? sql.findIndex((statement) => statement.startsWith("ALTER")) + : -1; + if (failureIndex >= 0) { + applyFailed = true; + return Effect.fail({ _tag: "LegacyDbExecError", message: "boom", statementIndex: failureIndex, - } as never) - : Effect.sync(() => { - if (cfg.port !== SHADOW_PORT) { - dbBatches.push(sql); - dbExec.push(...sql); - } - }); - }, + } as never); + } + return Effect.sync(() => { + if (cfg.port !== SHADOW_PORT) { + dbBatches.push(sql); + dbExec.push(...sql); + } + }); + }), query: (sql: string) => Effect.sync(() => { if (cfg.port !== SHADOW_PORT) dbExec.push(sql); @@ -322,6 +337,17 @@ const seedDeclarative = (workdir: string) => { writeFileSync(join(dir, "public.sql"), "create table a();"); }; +// pg-delta is the default schema diff engine (CLI-1588): an absent +// `[experimental.pgdelta]` section resolves to enabled = true, so gate-closed +// scenarios must now disable it explicitly. +const seedPgDeltaDisabledConfig = (workdir: string) => { + mkdirSync(join(workdir, "supabase"), { recursive: true }); + writeFileSync( + join(workdir, "supabase", "config.toml"), + "[experimental.pgdelta]\nenabled = false\n", + ); +}; + const seedLegacyUuidDeclarative = (workdir: string, directory = "schemas") => { const dir = join(workdir, "supabase", directory); mkdirSync(join(dir, "schemas", "app", "tables"), { recursive: true }); @@ -365,8 +391,11 @@ describe("legacy db schema declarative sync integration", () => { const tmp = useLegacyTempWorkdir(); useLegacyShadowCacheDisabled(); - it.effect("gate: fails when pg-delta is not enabled", () => { + it.effect("gate: fails when config disables pg-delta and --experimental is not passed", () => { + // pg-delta is the default engine (CLI-1588): the gate only closes when the + // config EXPLICITLY sets `enabled = false` and --experimental is absent. seedDeclarative(tmp.current); + seedPgDeltaDisabledConfig(tmp.current); const { layer } = setup(tmp.current, { experimental: false }); return Effect.gen(function* () { const exit = yield* Effect.exit(legacyDbSchemaDeclarativeSync(flags())); @@ -374,6 +403,20 @@ describe("legacy db schema declarative sync integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect( + "gate: open by default — no [experimental.pgdelta] section and no --experimental", + () => { + // The pg-delta default flip (CLI-1588): an absent section resolves to + // enabled = true, so sync proceeds without --experimental. + seedDeclarative(tmp.current); + const s = setup(tmp.current, { experimental: false, diffSql: "" }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); + expect(s.out.rawChunks.some((c) => c.text.includes("No schema changes found"))).toBe(true); + }).pipe(Effect.provide(s.layer)); + }, + ); + it.effect("--apply and --no-apply together with --experimental fail with the mutex error", () => { // Go's declarative PersistentPreRunE gate (db_schema_declarative.go:49-99) runs // BEFORE cobra's ValidateFlagGroups() mutex check (cobra@v1.10.2/command.go:985, @@ -399,7 +442,9 @@ describe("legacy db schema declarative sync integration", () => { () => { // Mirrors storage's experimental-gate-vs-mutex ordering fix (CLI-1855 / CLI-1876): // the pg-delta gate runs before the mutex check, so an unopened gate wins even - // when the flags would also violate mutual exclusivity. + // when the flags would also violate mutual exclusivity. Closing the gate now + // requires an explicit `enabled = false` (pg-delta default flip, CLI-1588). + seedPgDeltaDisabledConfig(tmp.current); const { layer } = setup(tmp.current, { experimental: false }); return Effect.gen(function* () { const exit = yield* Effect.exit( @@ -449,7 +494,9 @@ describe("legacy db schema declarative sync integration", () => { // viper's bound-pflag lookup returns the flag value whenever Changed is true — // BEFORE falling back to AutomaticEnv (viper@v1.21.0/viper.go:1176-1178) — so an // explicit --experimental=false must win over SUPABASE_EXPERIMENTAL=1, closing the - // gate instead of letting the env value override it. + // gate instead of letting the env value override it. The config must disable + // pg-delta explicitly, or the new default (CLI-1588) keeps the gate open anyway. + seedPgDeltaDisabledConfig(tmp.current); const { layer } = setup(tmp.current, { experimental: false, args: ["db", "schema", "declarative", "sync", "--experimental=false"], @@ -1257,6 +1304,10 @@ describe("legacy db schema declarative sync integration", () => { // effect, not just a tracked call. expect(legacyLocalResetRemovedContainers(s.child.spawned)).toContain("supabase_db_test"); expect(legacyLocalResetCreateArgs(s.child.spawned)).not.toBeUndefined(); + // With pg-delta enabled by default (CLI-1588), the reset replays the + // just-written migration file natively — the reapply succeeds on the + // freshly reset database. + expect(s.dbExec.some((sql) => sql.includes("ALTER TABLE a ADD COLUMN b int"))).toBe(true); expect(s.out.rawChunks.some((c) => c.text.includes("Resetting local database"))).toBe(true); expect( s.out.rawChunks.some((c) => From 656fa8ad57e4be68de92d2a77317f8c72afa604a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:48:41 +0000 Subject: [PATCH 04/17] test(cli): cover the pg-delta default in the db pull tests (CLI-1588) Migra-subject pull tests opt out explicitly (--diff-engine migra, or enabled = false when the disabled config state or the declarative alias is the subject), and new tests pin the flip: no config section -> pg-delta engine, enabled = false -> migra, --diff-engine migra -> migra despite the default. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BQAoY9JF4uiZRvKVkSd9Wy --- .../commands/db/pull/pull.integration.test.ts | 152 +++++++++++++++--- 1 file changed, 126 insertions(+), 26 deletions(-) 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 8b767e58aa..82ef2bbfb8 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 @@ -804,9 +804,18 @@ describe("legacy db pull", () => { it.effect("pulls with migra and does not warn about schema_paths", () => { seedMigration(tmp.current, "20240101000000"); + // pg-delta is the default engine now, so migra requires the explicit + // config opt-out. writeFileSync( join(tmp.current, "supabase", "config.toml"), - ["[db.migrations]", 'schema_paths = ["database/*.sql"]', ""].join("\n"), + [ + "[db.migrations]", + 'schema_paths = ["database/*.sql"]', + "", + "[experimental.pgdelta]", + "enabled = false", + "", + ].join("\n"), ); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], @@ -920,7 +929,10 @@ describe("legacy db pull", () => { // config (db pull does not force-enable it), so later db reset/db diff read // the pulled files. mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "config.toml"), "[db]\n"); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + "[db]\n\n[experimental.pgdelta]\nenabled = false\n", + ); const s = setup(tmp.current, { edgeStdout: EXPORT_JSON }); return Effect.gen(function* () { yield* legacyDbPull(flags({ declarative: Option.some(true) })); @@ -951,7 +963,7 @@ describe("legacy db pull", () => { mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync( join(tmp.current, "supabase", "config.toml"), - '[db.migrations]\nschema_paths = [\n "schemas/*.sql",\n]\n', + '[db.migrations]\nschema_paths = [\n "schemas/*.sql",\n]\n\n[experimental.pgdelta]\nenabled = false\n', ); const s = setup(tmp.current, { edgeStdout: EXPORT_JSON }); return Effect.gen(function* () { @@ -1028,6 +1040,13 @@ describe("legacy db pull", () => { // invocation ends false => migration mode + history repair, NOT declarative // export. OR-ing the two parsed flags would wrongly take the declarative path. seedMigration(tmp.current, "20240101000000"); + // The raw-SQL `edgeStdout` below is migra output; opt out of the pg-delta + // default via config (the diff-engine flag is mutually exclusive with the + // declarative alias this test exercises). + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + "[experimental.pgdelta]\nenabled = false\n", + ); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", @@ -1047,6 +1066,12 @@ describe("legacy db pull", () => { "--use-pg-delta --declarative=false stays in migration mode (Go last-occurrence-wins)", () => { seedMigration(tmp.current, "20240101000000"); + // Same config opt-out as above: raw-SQL `edgeStdout` is migra output and + // the diff-engine flag would trip the declarative-alias mutual exclusion. + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + "[experimental.pgdelta]\nenabled = false\n", + ); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", @@ -1098,7 +1123,7 @@ describe("legacy db pull", () => { yes: true, }); return Effect.gen(function* () { - yield* legacyDbPull(flags()); + yield* legacyDbPull(flags({ diffEngine: Option.some("migra") })); expect(s.proxyCalls).toHaveLength(0); expect(s.proxyCaptureCalls).toHaveLength(0); // pg_dump ran with the schema-dump env (internal-schema exclude + comment strip). @@ -1138,7 +1163,7 @@ describe("legacy db pull", () => { edgeStdout: "create table diffed ();\n", }); return Effect.gen(function* () { - yield* legacyDbPull(flags()); + yield* legacyDbPull(flags({ diffEngine: Option.some("migra") })); expect(s.proxyCalls).toHaveLength(0); expect(s.proxyCaptureCalls).toHaveLength(0); const success = s.out.messages.find((m) => m.type === "success"); @@ -1169,7 +1194,9 @@ describe("legacy db pull", () => { yes: true, }); return Effect.gen(function* () { - const exit = yield* legacyDbPull(flags()).pipe(Effect.exit); + const exit = yield* legacyDbPull(flags({ diffEngine: Option.some("migra") })).pipe( + Effect.exit, + ); expect(Exit.isSuccess(exit)).toBe(true); const dir = join(tmp.current, "supabase", "migrations"); const file = readdirSync(dir).find((f) => f.endsWith("_remote_schema.sql")); @@ -1231,7 +1258,9 @@ describe("legacy db pull", () => { yes: true, }); return Effect.gen(function* () { - const error = yield* legacyDbPull(flags()).pipe(Effect.flip); + const error = yield* legacyDbPull(flags({ diffEngine: Option.some("migra") })).pipe( + Effect.flip, + ); expect(error.message).toBe("No schema changes found"); expect(s.dumpCalls).toHaveLength(2); // direct attempt + pooler retry expect(s.historyUpserts).toHaveLength(0); // no migration-history row written @@ -1246,7 +1275,9 @@ describe("legacy db pull", () => { dumpStderr: "connection refused", }); return Effect.gen(function* () { - const error = yield* legacyDbPull(flags()).pipe(Effect.flip); + const error = yield* legacyDbPull(flags({ diffEngine: Option.some("migra") })).pipe( + Effect.flip, + ); expect(error.message).toContain("error running container: exit 1"); // The diff pass never ran — the dump failure aborts before provisioning a shadow. expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toEqual([]); @@ -1265,7 +1296,7 @@ describe("legacy db pull", () => { yes: true, }); return Effect.gen(function* () { - yield* legacyDbPull(flags()); + yield* legacyDbPull(flags({ diffEngine: Option.some("migra") })); expect(s.dumpCalls).toHaveLength(2); // direct attempt + pooler retry expect(s.poolerFallbackCalls).toHaveLength(1); const err = streamText(s.out, "stderr"); @@ -1285,7 +1316,9 @@ describe("legacy db pull", () => { poolerAvailable: false, }); return Effect.gen(function* () { - const error = yield* legacyDbPull(flags()).pipe(Effect.flip); + const error = yield* legacyDbPull(flags({ diffEngine: Option.some("migra") })).pipe( + Effect.flip, + ); expect(error.message).toContain("error running container: exit 1"); expect(s.poolerFallbackCalls).toHaveLength(1); // gate checked, no pooler resolved expect(streamText(s.out, "stderr")).not.toContain("Retrying via the IPv4 connection pooler"); @@ -1364,7 +1397,7 @@ describe("legacy db pull", () => { promptConfirmResponses: [true], }); return Effect.gen(function* () { - yield* legacyDbPull(flags()); + yield* legacyDbPull(flags({ diffEngine: Option.some("migra") })); expect(s.historyUpserts.length).toBe(1); }).pipe(Effect.provide(s.layer)); }); @@ -1378,7 +1411,7 @@ describe("legacy db pull", () => { promptConfirmResponses: [false], }); return Effect.gen(function* () { - yield* legacyDbPull(flags()); + yield* legacyDbPull(flags({ diffEngine: Option.some("migra") })); expect(s.historyUpserts.length).toBe(0); }).pipe(Effect.provide(s.layer)); }); @@ -1396,7 +1429,7 @@ describe("legacy db pull", () => { stdinIsTty: false, }); return Effect.gen(function* () { - yield* legacyDbPull(flags()); + yield* legacyDbPull(flags({ diffEngine: Option.some("migra") })); expect(s.historyUpserts.length).toBe(1); }).pipe(Effect.provide(s.layer)); }); @@ -1413,7 +1446,7 @@ describe("legacy db pull", () => { pipedAnswers: ["n"], }); return Effect.gen(function* () { - yield* legacyDbPull(flags()); + yield* legacyDbPull(flags({ diffEngine: Option.some("migra") })); expect(s.historyUpserts.length).toBe(0); // Prints the label then echoes the consumed answer. expect(streamText(s.out, "stderr")).toContain( @@ -1431,7 +1464,7 @@ describe("legacy db pull", () => { yes: true, }); return Effect.gen(function* () { - yield* legacyDbPull(flags()); + yield* legacyDbPull(flags({ diffEngine: Option.some("migra") })); expect(streamText(s.out, "stdout")).not.toContain("Finished supabase db pull."); // Diagnostics still go to stderr in machine mode (the Connecting line is // written regardless of output format); stdout stays payload-only. @@ -1450,7 +1483,7 @@ describe("legacy db pull", () => { // no --yes: a non-interactive prompt falls back to the default (true). }); return Effect.gen(function* () { - yield* legacyDbPull(flags()); + yield* legacyDbPull(flags({ diffEngine: Option.some("migra") })); expect(s.historyUpserts.length).toBe(1); }).pipe(Effect.provide(s.layer)); }); @@ -1469,7 +1502,7 @@ describe("legacy db pull", () => { stdinIsTty: true, }); return Effect.gen(function* () { - yield* legacyDbPull(flags()); + yield* legacyDbPull(flags({ diffEngine: Option.some("migra") })); expect(s.historyUpserts.length).toBe(1); expect(streamText(s.out, "stderr")).toContain( "Update remote migration history table? [Y/n] y", @@ -1503,7 +1536,7 @@ describe("legacy db pull", () => { pipedAnswers: ["n"], }); return Effect.gen(function* () { - yield* legacyDbPull(flags()); + yield* legacyDbPull(flags({ diffEngine: Option.some("migra") })); expect(s.historyUpserts.length).toBe(1); }).pipe( Effect.ensuring( @@ -1537,7 +1570,7 @@ describe("legacy db pull", () => { yes: true, }); return Effect.gen(function* () { - yield* legacyDbPull(flags()); + yield* legacyDbPull(flags({ diffEngine: Option.some("migra") })); expect(s.dumpCalls.length).toBeGreaterThanOrEqual(1); // The pg_dump container image is rewritten to the configured mirror. expect(s.dumpCalls[0]?.image).toMatch(/^my-mirror\.example\.com\/supabase\//u); @@ -1570,7 +1603,7 @@ describe("legacy db pull", () => { yes: true, }); return Effect.gen(function* () { - yield* legacyDbPull(flags()); + yield* legacyDbPull(flags({ diffEngine: Option.some("migra") })); expect(s.dumpCalls.length).toBeGreaterThanOrEqual(1); expect(s.dumpCalls[0]?.network).toEqual({ _tag: "named", name: "dotenv-net" }); }).pipe( @@ -1601,7 +1634,7 @@ describe("legacy db pull", () => { args: ["db", "pull", "--yes=false"], }); return Effect.gen(function* () { - yield* legacyDbPull(flags()); + yield* legacyDbPull(flags({ diffEngine: Option.some("migra") })); expect(s.historyUpserts.length).toBe(0); }).pipe( Effect.ensuring( @@ -1633,7 +1666,7 @@ describe("legacy db pull", () => { args: ["db", "pull", "--password", "--yes=false"], }); return Effect.gen(function* () { - yield* legacyDbPull(flags()); + yield* legacyDbPull(flags({ diffEngine: Option.some("migra") })); expect(s.historyUpserts.length).toBe(1); expect(streamText(s.out, "stderr")).toContain( "Update remote migration history table? [Y/n] y", @@ -1809,7 +1842,7 @@ describe("legacy db pull", () => { args: ["db", "pull", "--experimental=false"], }); return Effect.gen(function* () { - yield* legacyDbPull(flags()); + yield* legacyDbPull(flags({ diffEngine: Option.some("migra") })); expect(streamText(s.out, "stderr")).toContain("Connecting to remote database...\n"); }).pipe( Effect.ensuring( @@ -1923,6 +1956,71 @@ describe("legacy db pull", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect( + "defaults to the pg-delta engine when config has no [experimental.pgdelta] section", + () => { + // CLI-1588: pg-delta is the default schema diff engine. With no config + // section and no --diff-engine flag, the migration-style pull must call + // the pg-delta engine's diffDatabase, never migra's edge-runtime script. + seedMigration(tmp.current, "20240101000000"); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: pgDeltaDiffEnvelope([ + { name: "schema_changes", sql: "create table remote ();" }, + ]), + yes: true, + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags()); + expect(s.engineCalls).toHaveLength(1); + expect(s.engineCalls[0]?.operation).toBe("diff"); + expect(s.edgeRunCount).toBe(0); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("[experimental.pgdelta] enabled = false in config selects the migra engine", () => { + // Explicit config opt-out from the pg-delta default: the pull must run + // migra's edge-runtime diff and never touch the pg-delta engine. Migra + // selection is also proven by the raw-SQL `edgeStdout` being written as a + // migration (pg-delta would fail to JSON.parse it). + seedMigration(tmp.current, "20240101000000"); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + "[experimental.pgdelta]\nenabled = false\n", + ); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: "create table remote ();\n", + yes: true, + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags()); + expect(s.engineCalls).toHaveLength(0); + expect(s.edgeRunCount).toBe(1); + const dir = join(tmp.current, "supabase", "migrations"); + expect(readdirSync(dir).some((f) => f.endsWith("_remote_schema.sql"))).toBe(true); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("--diff-engine migra forces migra even when config leaves the default on", () => { + // No config.toml at all, so the config-level default is pg-delta; the + // explicit flag must still win and select migra. + seedMigration(tmp.current, "20240101000000"); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: "create table remote ();\n", + yes: true, + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags({ diffEngine: Option.some("migra") })); + expect(s.engineCalls).toHaveLength(0); + expect(s.edgeRunCount).toBe(1); + const dir = join(tmp.current, "supabase", "migrations"); + expect(readdirSync(dir).some((f) => f.endsWith("_remote_schema.sql"))).toBe(true); + }).pipe(Effect.provide(s.layer)); + }); + it.effect("db pull --local with pg-delta-next diffs against the live local database", () => { seedMigration(tmp.current, "20240101000000"); mkdirSync(join(tmp.current, "supabase", "schemas"), { recursive: true }); @@ -1954,7 +2052,7 @@ describe("legacy db pull", () => { yes: true, }); return Effect.gen(function* () { - yield* legacyDbPull(flags({ local: Option.some(true) })); + yield* legacyDbPull(flags({ local: Option.some(true), diffEngine: Option.some("migra") })); expect(s.connectedDatabases).toContain("contrib_regression"); // A local target prints the local wording (established output contract). expect(streamText(s.out, "stderr")).toContain("Connecting to local database...\n"); @@ -2028,7 +2126,7 @@ describe("legacy db pull", () => { // no --yes }); return Effect.gen(function* () { - yield* legacyDbPull(flags()); + yield* legacyDbPull(flags({ diffEngine: Option.some("migra") })); expect(s.historyUpserts.length).toBe(1); const success = s.out.messages.find((m) => m.type === "success"); expect(success?.data).toMatchObject({ remoteHistoryUpdated: true }); @@ -2135,7 +2233,9 @@ describe("legacy db pull", () => { resolvedRef: "abcdefghijklmnopqrst", }); return Effect.gen(function* () { - yield* legacyDbPull(flags({ linked: Option.some(true) })); + yield* legacyDbPull( + flags({ linked: Option.some(true), diffEngine: Option.some("migra") }), + ); const createArgs = s.shadowSpawned.find((c) => c.args[0] === "create")?.args ?? []; expect(createArgs).toContain("--tmpfs"); }).pipe(Effect.provide(s.layer)); From 09f187d2dca42c07bba0556411dc7bfe4f853cdb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:49:53 +0000 Subject: [PATCH 05/17] test(cli): cover the pg-delta default in the db diff and db reset tests (CLI-1588) Migra-subject diff tests opt in with --use-migra; the experimental schema-files reset tests disable pg-delta explicitly, since that branch now requires enabled = false. New tests pin the flip on both commands: no config section -> pg-delta engine (db diff), enabled = false -> migra, --use-migra override, and an experimental versionless reset without a pgdelta section replaying migrations instead of schema files. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BQAoY9JF4uiZRvKVkSd9Wy --- .../commands/db/diff/diff.integration.test.ts | 132 ++++++++++++------ .../db/reset/reset.integration.test.ts | 53 +++++-- 2 files changed, 129 insertions(+), 56 deletions(-) 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 ab617b77c8..f9248320fd 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 @@ -559,46 +559,54 @@ const PGADMIN_SOURCE_URL = const PGADMIN_TARGET_URL = "postgresql://postgres:postgres@127.0.0.1:54320/postgres"; describe("legacy db diff", () => { - it.effect("diffs local with the default migra engine and prints SQL to stdout", () => { - const s = setup(tmp.current, { diffSql: "create table players ();\n" }); - return Effect.gen(function* () { - yield* legacyDbDiff(flags()); - // The native shadow was created once (one `docker create`) and removed once - // (one `docker rm -f -v`) — see `mockLegacyShadowContainerCliSpawner`. - expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); - expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); - expect(stdout(s.out)).toBe("create table players ();\n\n"); - expect(stderr(s.out)).toContain("Creating shadow database..."); - expect(stderr(s.out)).toContain("Diffing schemas..."); - expect(stderr(s.out)).toContain("Finished supabase db diff on branch"); - expect(s.telemetry.flushed).toBe(true); - // The shadow's PG15+ one-shot platform-baseline job(s) connect to the shadow over - // Docker's embedded DNS using the shadow container's OWN 12-char short id as - // `DB_HOST` — NOT the real `db` container's name, and not some other slice length - // (a mutation from `.slice(0, 12)` to `.slice(0, 8)` must fail this). This is the - // one shadow-specific parameterization that matters - // (`legacyBuildShadowSetupDatabaseInput`'s `dbHost`). The default config enables - // realtime (and PG >= 15 by default), so this always exercises at least one - // one-shot job — Realtime's own env sets `DB_HOST` directly; Storage/Auth embed - // the same host inside a `DATABASE_URL`-style connection string instead. - const expectedHost = LEGACY_FAKE_SHADOW_CONTAINER_ID.slice(0, 12); - expect(s.shadowSetupJobCalls.length).toBeGreaterThan(0); - let sawHost = false; - for (const call of s.shadowSetupJobCalls) { - 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}:`); + it.effect( + "diffs local with the default pg-delta engine (no pgdelta config section) and prints SQL to stdout", + () => { + const s = setup(tmp.current, { diffSql: "create table players ();\n" }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags()); + // With NO `[experimental.pgdelta]` section, `enabled` defaults to TRUE + // (CLI-1588), so the diff routes through the pg-delta engine — not migra's + // edge-runtime script. + expect(s.databaseDiffCalls).toHaveLength(1); + expect(s.edgeCalls).toEqual([]); + // The native shadow was created once (one `docker create`) and removed once + // (one `docker rm -f -v`) — see `mockLegacyShadowContainerCliSpawner`. + expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + expect(stdout(s.out)).toBe("create table players ();\n\n"); + expect(stderr(s.out)).toContain("Creating shadow database..."); + expect(stderr(s.out)).toContain("Diffing schemas..."); + expect(stderr(s.out)).toContain("Finished supabase db diff on branch"); + expect(s.telemetry.flushed).toBe(true); + // The shadow's PG15+ one-shot platform-baseline job(s) connect to the shadow over + // Docker's embedded DNS using the shadow container's OWN 12-char short id as + // `DB_HOST` — NOT the real `db` container's name, and not some other slice length + // (a mutation from `.slice(0, 12)` to `.slice(0, 8)` must fail this). This is the + // one shadow-specific parameterization that matters + // (`legacyBuildShadowSetupDatabaseInput`'s `dbHost`). The default config enables + // realtime (and PG >= 15 by default), so this always exercises at least one + // one-shot job — Realtime's own env sets `DB_HOST` directly; Storage/Auth embed + // the same host inside a `DATABASE_URL`-style connection string instead. + const expectedHost = LEGACY_FAKE_SHADOW_CONTAINER_ID.slice(0, 12); + expect(s.shadowSetupJobCalls.length).toBeGreaterThan(0); + let sawHost = false; + for (const call of s.shadowSetupJobCalls) { + 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)); - }); + expect(sawHost).toBe(true); + }).pipe(Effect.provide(s.layer)); + }, + ); it.effect("diffs local with pgdelta when --use-pg-delta is set", () => { const s = setup(tmp.current, { diffSql: "create table p ();\n" }); @@ -634,6 +642,38 @@ describe("legacy db diff", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect("an explicit [experimental.pgdelta] enabled = false selects the migra engine", () => { + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + "[experimental.pgdelta]\nenabled = false\n", + ); + const s = setup(tmp.current, { diffSql: "create table players ();\n" }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags()); + // Explicitly disabling pg-delta opts back into migra: the diff runs through + // migra's edge-runtime script (not pg-delta's `renderPlanFiles` script, and + // not the pg-delta engine service). + expect(s.databaseDiffCalls).toEqual([]); + expect(s.edgeCalls).toHaveLength(1); + expect(s.edgeCalls[0]?.script).not.toContain("renderPlanFiles"); + expect(stdout(s.out)).toBe("create table players ();\n\n"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("--use-migra overrides the pg-delta default when no pgdelta config exists", () => { + const s = setup(tmp.current, { diffSql: "create table players ();\n" }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ useMigra: Option.some(true) })); + // No `[experimental.pgdelta]` section, so pg-delta is the default — the + // `--use-migra` flag must still win and route through migra. + expect(s.databaseDiffCalls).toEqual([]); + expect(s.edgeCalls).toHaveLength(1); + expect(s.edgeCalls[0]?.script).not.toContain("renderPlanFiles"); + expect(stdout(s.out)).toBe("create table players ();\n\n"); + }).pipe(Effect.provide(s.layer)); + }); + it.effect("next local diff ignores schema_paths and declarative files", () => { mkdirSync(join(tmp.current, "supabase", "schemas"), { recursive: true }); writeFileSync( @@ -1049,7 +1089,9 @@ describe("legacy db diff", () => { writeFileSync(join(tmp.current, "supabase", "schemas", "public.sql"), "select 1;\n"); const s = setup(tmp.current, { diffSql: "create table o ();\n" }); return Effect.gen(function* () { - yield* legacyDbDiff(flags()); + // Migra is opt-in now that pg-delta is the default engine (CLI-1588); + // pg-delta ignores the declarative contrib_regression override entirely. + yield* legacyDbDiff(flags({ useMigra: Option.some(true) })); expect(stdout(s.out)).toBe("create table o ();\n\n"); // The declarative-schema file was migrated into the contrib_regression override. expect(s.shadowConnectedDatabases).toContain("contrib_regression"); @@ -1926,7 +1968,9 @@ describe("legacy db diff", () => { it.effect("emits a json envelope with --output-format json (payload-only stdout)", () => { const s = setup(tmp.current, { format: "json", diffSql: "create table j ();\n" }); return Effect.gen(function* () { - yield* legacyDbDiff(flags()); + // Migra is opt-in now that pg-delta is the default engine (CLI-1588); this + // test pins the migra envelope's `engine` value specifically. + yield* legacyDbDiff(flags({ useMigra: Option.some(true) })); // No raw SQL on stdout in machine mode; the envelope carries it instead. expect(stdout(s.out)).toBe(""); const success = s.out.messages.find((m) => m.type === "success"); @@ -1953,7 +1997,9 @@ describe("legacy db diff", () => { "error diffing schema: error running script:\nTypeError: Cannot read properties of undefined (reading 'constraints')\nPGDELTA_SCRIPT_ERROR\n", }); return Effect.gen(function* () { - const exit = yield* legacyDbDiff(flags()).pipe(Effect.exit); + // Migra is opt-in now that pg-delta is the default engine (CLI-1588); the + // crash is injected into migra's edge-runtime script run. + const exit = yield* legacyDbDiff(flags({ useMigra: Option.some(true) })).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(stderr(s.out)).not.toContain("No schema changes found"); }).pipe(Effect.provide(s.layer)); @@ -1962,8 +2008,9 @@ describe("legacy db diff", () => { it.effect("falls back to the migra Docker image when edge-runtime OOMs", () => { const s = setup(tmp.current, { oom: true, diffSql: "create table fb ();\n", isLocal: true }); return Effect.gen(function* () { + // Migra is opt-in now that pg-delta is the default engine (CLI-1588). // Pass --schema so the fallback does not need a live DB to list schemas. - yield* legacyDbDiff(flags({ schema: ["public"] })); + yield* legacyDbDiff(flags({ useMigra: Option.some(true), schema: ["public"] })); expect(s.dockerCalls).toHaveLength(1); expect(stdout(s.out)).toBe("create table fb ();\n\n"); }).pipe(Effect.provide(s.layer)); @@ -1979,7 +2026,8 @@ describe("legacy db diff", () => { networkId: "my-net", }); return Effect.gen(function* () { - yield* legacyDbDiff(flags({ schema: ["public"] })); + // Migra is opt-in now that pg-delta is the default engine (CLI-1588). + yield* legacyDbDiff(flags({ useMigra: Option.some(true), schema: ["public"] })); expect(s.dockerCalls).toHaveLength(1); expect((s.dockerCalls[0] as { network: unknown }).network).toEqual({ _tag: "named", diff --git a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts index 5d15c71660..9c12686cdd 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts @@ -1232,7 +1232,7 @@ describe("legacy db reset", () => { // such file exists), failing the whole reset, instead of `supabase/schema.sql` // (where this test actually places the file). const { layer, conn } = setup(tmp.current, { - toml: 'project_id = "test"\n[db]\nmajor_version = 14\n[db.migrations]\nschema_paths = ["schema.sql"]\n', + toml: 'project_id = "test"\n[db]\nmajor_version = 14\n[db.migrations]\nschema_paths = ["schema.sql"]\n[experimental.pgdelta]\nenabled = false\n', files: { "supabase/schema.sql": "create table schema_paths_marker ();" }, args: ["db", "reset", "--local"], isLocal: true, @@ -1676,7 +1676,7 @@ describe("legacy db reset", () => { // `--linked=false` still selects the linked/remote target (Cobra `Changed` // semantics) — exercised here alongside the schema-files branch itself. const { layer, out, conn, resolver, linkedCache } = setup(tmp.current, { - toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n', + toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n\n[experimental.pgdelta]\nenabled = false\n', files: { "supabase/schemas/01_users.sql": "create table schema_users ();", ...migrationFile("20240101000000", "create table migrated_table ();"), @@ -1714,7 +1714,7 @@ describe("legacy db reset", () => { // order ACROSS patterns (no global re-sort) — `zz/*.sql`'s files // must all run before `aa/*.sql`'s, even though "aa" sorts before "zz". const { layer, conn } = setup(tmp.current, { - toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["zz/*.sql", "aa/*.sql"]\n', + toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["zz/*.sql", "aa/*.sql"]\n\n[experimental.pgdelta]\nenabled = false\n', files: { "supabase/zz/b.sql": "create table zz_b ();", "supabase/zz/a.sql": "create table zz_a ();", @@ -1741,7 +1741,7 @@ describe("legacy db reset", () => { // (not a plain-files glob), which expands a directory match to its // regular `.sql` files, recursively — unlike a plain glob pattern. const { layer, conn } = setup(tmp.current, { - toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["some-dir"]\n', + toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["some-dir"]\n\n[experimental.pgdelta]\nenabled = false\n', files: { "supabase/some-dir/01_top.sql": "create table dir_top ();", "supabase/some-dir/nested/02_nested.sql": "create table dir_nested ();", @@ -1764,7 +1764,7 @@ describe("legacy db reset", () => { // schema-files apply is a silent no-op — it does NOT fall back to // replaying migrations (a hard if/else-if). const { layer, out, conn } = setup(tmp.current, { - toml: 'project_id = "test"\n', + toml: 'project_id = "test"\n\n[experimental.pgdelta]\nenabled = false\n', files: migrationFile("20240101000000", "create table migrated_table ();"), experimental: true, confirm: [true], @@ -1803,11 +1803,36 @@ describe("legacy db reset", () => { }, ); + it.live( + "replays migrations instead of schema files on an experimental remote reset with no pgdelta config section (pg-delta default, CLI-1588)", + () => { + // With NO `[experimental.pgdelta]` section, `enabled` now defaults to + // TRUE, so an experimental versionless remote reset suppresses the + // schema-files branch and replays migrations — only an explicit + // `enabled = false` re-enables the schema-files path. + const { layer, out, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n', + files: { + "supabase/schemas/01_users.sql": "create table schema_users ();", + ...migrationFile("20240101000000", "create table migrated_table ();"), + }, + experimental: true, + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + expect(conn.execs.some((s) => s.includes("create table migrated_table"))).toBe(true); + expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(false); + expect(out.stderrText).toContain("Applying migration"); + }); + }, + ); + it.live( "replays migrations instead of schema files on an experimental remote reset with a resolved version", () => { const { layer, conn } = setup(tmp.current, { - toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n', + toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n\n[experimental.pgdelta]\nenabled = false\n', files: { "supabase/schemas/01_users.sql": "create table schema_users ();", ...migrationFile("20240101000000", "create table migrated_table ();"), @@ -1833,7 +1858,7 @@ describe("legacy db reset", () => { "fails an experimental remote reset when no schema_paths pattern matches anything", () => { const { layer, conn } = setup(tmp.current, { - toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["nomatch/*.sql"]\n', + toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["nomatch/*.sql"]\n\n[experimental.pgdelta]\nenabled = false\n', experimental: true, confirm: [true], }); @@ -1859,7 +1884,7 @@ describe("legacy db reset", () => { // The joined glob error only surfaces when NO pattern matched anything // at all; a partial failure is silently dropped. const { layer, out, conn } = setup(tmp.current, { - toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql", "typo/*.sql"]\n', + toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql", "typo/*.sql"]\n\n[experimental.pgdelta]\nenabled = false\n', files: { "supabase/schemas/01_users.sql": "create table schema_users ();", // Present so the (unrelated) seed glob's own "no files matched" WARN line @@ -1880,7 +1905,7 @@ describe("legacy db reset", () => { "attaches Go's schema-file suggestion when a schema file fails to apply on an experimental remote reset", () => { const { layer } = setup(tmp.current, { - toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n', + toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n\n[experimental.pgdelta]\nenabled = false\n', files: { "supabase/schemas/01_users.sql": "not valid sql;" }, experimental: true, confirm: [true], @@ -1915,7 +1940,7 @@ describe("legacy db reset", () => { // must fail WITHOUT the suggestion, unlike the exec-failure case above. const schemaFile = join(tmp.current, "supabase", "schemas", "01_users.sql"); const { layer, conn } = setup(tmp.current, { - toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n', + toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n\n[experimental.pgdelta]\nenabled = false\n', files: { "supabase/schemas/01_users.sql": "create table schema_users ();" }, experimental: true, confirm: [true], @@ -1947,7 +1972,7 @@ describe("legacy db reset", () => { // having applied nothing. const schemasDir = join(tmp.current, "supabase", "schemas"); const { layer, conn } = setup(tmp.current, { - toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas"]\n', + toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas"]\n\n[experimental.pgdelta]\nenabled = false\n', files: { "supabase/schemas/01_users.sql": "create table schema_users ();" }, experimental: true, confirm: [true], @@ -1980,7 +2005,7 @@ describe("legacy db reset", () => { const previous = process.env["SUPABASE_EXPERIMENTAL"]; delete process.env["SUPABASE_EXPERIMENTAL"]; const { layer, out, conn } = setup(tmp.current, { - toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n', + toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n\n[experimental.pgdelta]\nenabled = false\n', files: { "supabase/.env": "SUPABASE_EXPERIMENTAL=true\n", "supabase/schemas/01_users.sql": "create table schema_users ();", @@ -2026,7 +2051,7 @@ describe("legacy db reset", () => { "applies configured schema files and skips seeding on an experimental remote --db-url reset", () => { const { layer, conn, resolver } = setup(tmp.current, { - toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n', + toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n\n[experimental.pgdelta]\nenabled = false\n', files: { "supabase/schemas/01_users.sql": "create table schema_users ();" }, experimental: true, args: ["db", "reset", "--db-url", "postgresql://db.example.com:5432/postgres"], @@ -2243,7 +2268,7 @@ describe("legacy db reset", () => { // branch of the migrate-and-seed step ran — seeding sits outside the // if/else-if, and the seed override is resolved entirely upstream of it. const { layer, out, conn } = setup(tmp.current, { - toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n', + toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n\n[experimental.pgdelta]\nenabled = false\n', files: { "supabase/schemas/01_users.sql": "create table schema_users ();", "supabase/custom-seed.sql": "insert into t values (2);", From 6b1d5457dfc1733734dc8aeeccb70a5173d0bcea Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:51:42 +0000 Subject: [PATCH 06/17] chore(cli): gofmt two stale files in apps/cli-go gofmt -l flagged pkg/config/db.go and pkg/function/deploy_test.go (pre-existing whitespace drift from #6253); fixed so the Go CI format check stays green on branches touching apps/cli-go. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BQAoY9JF4uiZRvKVkSd9Wy --- apps/cli-go/pkg/config/db.go | 2 +- apps/cli-go/pkg/function/deploy_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/cli-go/pkg/config/db.go b/apps/cli-go/pkg/config/db.go index 7b0c76a163..963cfba9c7 100644 --- a/apps/cli-go/pkg/config/db.go +++ b/apps/cli-go/pkg/config/db.go @@ -92,7 +92,7 @@ type ( Seed seed `toml:"seed" json:"seed"` Settings settings `toml:"settings" json:"settings"` NetworkRestrictions networkRestrictions `toml:"network_restrictions" json:"network_restrictions"` - SslEnforcement *sslEnforcement `toml:"ssl_enforcement" json:"ssl_enforcement"` + SslEnforcement *sslEnforcement `toml:"ssl_enforcement" json:"ssl_enforcement"` Vault map[string]Secret `toml:"vault" json:"vault"` } diff --git a/apps/cli-go/pkg/function/deploy_test.go b/apps/cli-go/pkg/function/deploy_test.go index a769c45e22..fef8bf3d52 100644 --- a/apps/cli-go/pkg/function/deploy_test.go +++ b/apps/cli-go/pkg/function/deploy_test.go @@ -265,7 +265,7 @@ func TestDeployAll(t *testing.T) { JSON(map[string]string{"message": "deployment already exists"}) var bulkBody []byte gock.New(mockApiHost). - Put("/v1/projects/"+mockProject+"/functions"). + Put("/v1/projects/" + mockProject + "/functions"). AddMatcher(captureBody(&bulkBody)). Reply(http.StatusOK). JSON(api.BulkUpdateFunctionResponse{}) @@ -340,7 +340,7 @@ func TestDeployAll(t *testing.T) { Reply(http.StatusConflict). JSON(map[string]string{"message": "deployment already exists"}) gock.New(mockApiHost). - Put("/v1/projects/"+mockProject+"/functions"). + Put("/v1/projects/" + mockProject + "/functions"). Reply(http.StatusBadRequest). JSON(map[string]string{"message": "bulk update rejected"}) // Run test From 2684446ebfb51c5554ce048f436b8fdafabe3e9c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:57:51 +0000 Subject: [PATCH 07/17] test(cli): make the two chmod-based permission tests root-safe chmod 0o000 cannot make paths unreadable for root, so the workers-push unlistable-root test now expects the successful deploy on its root/listable branch (mirroring its unreadable-source sibling), and the legacyApplySchemaFiles read-failure test skips under root like reset's own chmod tests. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BQAoY9JF4uiZRvKVkSd9Wy --- .../workers/push/push.integration.test.ts | 19 +++++++++++-------- .../legacy-migration-apply.unit.test.ts | 5 ++++- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts index 38b92c5fa2..0ac1d186ae 100644 --- a/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/workers/push/push.integration.test.ts @@ -712,16 +712,19 @@ describe("legacy workers push", () => { const { layer, http } = setupLegacyWorkers({ workdir: repo.dir, routes: routes() }); return Effect.gen(function* () { - const error = yield* push({ names: [] }).pipe(Effect.flip); - if (listable) { - // Root ignores the permission bits, so the root lists and `api` is found. - expect(error).not.toBeInstanceOf(NoWorkersToDeployError); - } else { - expect(error).not.toBeInstanceOf(NoWorkersToDeployError); - expect(Predicate.isTagged(error, "PlatformError")).toBe(true); - expect(http.requests).toHaveLength(0); + // Root ignores the permission bits, so the root lists, `api` is found, + // and the push deploys it — same early-success handling as the + // unreadable-source test above. + yield* push({ names: [] }); + expect(http.requests.length).toBeGreaterThan(0); + return; } + + const error = yield* push({ names: [] }).pipe(Effect.flip); + expect(error).not.toBeInstanceOf(NoWorkersToDeployError); + expect(Predicate.isTagged(error, "PlatformError")).toBe(true); + expect(http.requests).toHaveLength(0); }).pipe( Effect.provide(layer), Effect.ensuring( diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts index 14b75f1d73..a1824921b8 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts @@ -1201,7 +1201,10 @@ describe("legacySeedGlobals", () => { }); describe("legacyApplySchemaFiles", () => { - it.effect( + // chmod 0o000 cannot make a file unreadable for root, so the read never + // fails there — same guard as reset's own chmod-based permission tests. + const isRoot = typeof process.getuid === "function" && process.getuid() === 0; + it.effect.skipIf(isRoot)( "reports a read failure with the workdir-relative path, not the absolute path used to read it (Go open supabase/... parity)", () => { // Go opens the workdir-relative `fp` from `schema_paths` directly (its process From 2f97b704249bc3d57ea9a73be72f8aa7bae5ebd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 14:59:25 +0000 Subject: [PATCH 08/17] chore(cli): oxfmt the db pull integration test Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BQAoY9JF4uiZRvKVkSd9Wy --- apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 82ef2bbfb8..3570c91e9e 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 @@ -2233,9 +2233,7 @@ describe("legacy db pull", () => { resolvedRef: "abcdefghijklmnopqrst", }); return Effect.gen(function* () { - yield* legacyDbPull( - flags({ linked: Option.some(true), diffEngine: Option.some("migra") }), - ); + yield* legacyDbPull(flags({ linked: Option.some(true), diffEngine: Option.some("migra") })); const createArgs = s.shadowSpawned.find((c) => c.args[0] === "create")?.args ?? []; expect(createArgs).toContain("--tmpfs"); }).pipe(Effect.provide(s.layer)); From 4234d368f05b3f6490ebe1b4496c99ef409ff001 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 06:53:31 +0000 Subject: [PATCH 09/17] fix(cli): drop the Go migrations-catalog warmup now that pg-delta defaults on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With pg-delta enabled by default, the pgcache warmup after SetupLocalDatabase and migration-down resets started firing in the Go binary's db start / db reset paths, opening an extra Postgres connection their pgmock tests never scripted (10s dial timeouts in CI). The catalog it writes is a legacy-engine artifact with no consumer reachable through the delegated command surface, and the TypeScript CLI removed its counterpart in CLI-2211 — remove the two call sites rather than script the connection. Also pin TestWriteDeclarativeSchemas to the explicitly-disabled state its schema_paths config write is now gated on. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BQAoY9JF4uiZRvBVkSd9Wy --- .../internal/db/declarative/declarative_test.go | 11 +++++++++-- apps/cli-go/internal/db/start/start.go | 10 ---------- apps/cli-go/internal/migration/down/down.go | 9 +-------- 3 files changed, 10 insertions(+), 20 deletions(-) diff --git a/apps/cli-go/internal/db/declarative/declarative_test.go b/apps/cli-go/internal/db/declarative/declarative_test.go index 093fa6197a..515e255990 100644 --- a/apps/cli-go/internal/db/declarative/declarative_test.go +++ b/apps/cli-go/internal/db/declarative/declarative_test.go @@ -20,10 +20,17 @@ import ( ) func TestWriteDeclarativeSchemas(t *testing.T) { - // This verifies the main happy path for declarative export materialization: - // files are written to expected locations and config is updated accordingly. + // This verifies the main happy path for declarative export materialization + // with pg-delta explicitly disabled: files are written to expected locations + // and [db.migrations] schema_paths is updated accordingly. (With pg-delta + // enabled — the default — the config update is skipped; see the tests below.) fsys := afero.NewMemMapFs() require.NoError(t, afero.WriteFile(fsys, utils.ConfigPath, []byte("[db]\n"), 0644)) + original := utils.Config.Experimental.PgDelta + utils.Config.Experimental.PgDelta = &config.PgDeltaConfig{Enabled: false} + t.Cleanup(func() { + utils.Config.Experimental.PgDelta = original + }) output := diff.DeclarativeOutput{ Files: []diff.DeclarativeFile{ diff --git a/apps/cli-go/internal/db/start/start.go b/apps/cli-go/internal/db/start/start.go index 6cd411791c..dc8327c816 100644 --- a/apps/cli-go/internal/db/start/start.go +++ b/apps/cli-go/internal/db/start/start.go @@ -20,7 +20,6 @@ import ( "github.com/jackc/pgconn" "github.com/jackc/pgx/v4" "github.com/spf13/afero" - "github.com/supabase/cli/internal/db/pgcache" "github.com/supabase/cli/internal/migration/apply" "github.com/supabase/cli/internal/status" "github.com/supabase/cli/internal/utils" @@ -368,15 +367,6 @@ func SetupLocalDatabase(ctx context.Context, version string, fsys afero.Fs, w io if err := apply.MigrateAndSeed(ctx, version, conn, fsys); err != nil { return err } - if err := pgcache.TryCacheMigrationsCatalog(ctx, pgconn.Config{ - Host: utils.Config.Hostname, - Port: utils.Config.Db.Port, - User: "postgres", - Password: utils.Config.Db.Password, - Database: "postgres", - }, "local", version, fsys, options...); err != nil { - fmt.Fprintln(os.Stderr, "Warning: failed to cache migrations catalog:", err) - } return nil } diff --git a/apps/cli-go/internal/migration/down/down.go b/apps/cli-go/internal/migration/down/down.go index 6fba0c1fb8..35a9fa7e88 100644 --- a/apps/cli-go/internal/migration/down/down.go +++ b/apps/cli-go/internal/migration/down/down.go @@ -9,7 +9,6 @@ import ( "github.com/jackc/pgconn" "github.com/jackc/pgx/v4" "github.com/spf13/afero" - "github.com/supabase/cli/internal/db/pgcache" "github.com/supabase/cli/internal/migration/apply" "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/pkg/migration" @@ -52,13 +51,7 @@ func ResetAll(ctx context.Context, version string, conn *pgx.Conn, fsys afero.Fs if err := vault.UpsertVaultSecrets(ctx, utils.Config.Db.Vault, conn); err != nil { return err } - if err := apply.MigrateAndSeed(ctx, version, conn, fsys); err != nil { - return err - } - if err := pgcache.TryCacheMigrationsCatalog(ctx, conn.Config().Config, "", version, fsys); err != nil { - fmt.Fprintln(os.Stderr, "Warning: failed to cache migrations catalog:", err) - } - return nil + return apply.MigrateAndSeed(ctx, version, conn, fsys) } func confirmResetAll(pending []string) string { From 64831cdca56c5e6b76ea972127ca1aa9ce735292 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 06:59:29 +0000 Subject: [PATCH 10/17] refactor(cli): address review feedback on legacy-migration-list Narrow the not-found read error with Predicate.isTagged instead of reading ._tag directly, and rewrite the module's comments in terms of the legacy shell's own established behavior instead of Go citations. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BQAoY9JF4uiZRvKVkSd9Wy --- .../legacy/shared/legacy-migration-list.ts | 56 +++++++++---------- 1 file changed, 25 insertions(+), 31 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-migration-list.ts b/apps/cli/src/legacy/shared/legacy-migration-list.ts index 8bb168a6a9..26d160d762 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-list.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-list.ts @@ -1,28 +1,27 @@ -import { Effect, Option, type FileSystem, type Path } from "effect"; +import { Effect, Option, Predicate, type FileSystem, type Path } from "effect"; import { Output } from "../../shared/output/output.service.ts"; import { legacyCompareUtf8Bytes } from "./legacy-glob.ts"; import { LegacyMigrationsReadError } from "./legacy-migration.errors.ts"; -// `pkg/migration/list.go` — `<14-digit>_init.sql` first migrations (pre-2021-12-09) are skipped. +// A first migration named `<14-digit>_init.sql` with a timestamp before 2021-12-09 is a +// deprecated init schema and is skipped. const INIT_SCHEMA_PATTERN = /([0-9]{14})_init\.sql/; const INIT_SCHEMA_CUTOFF = 20211209000000; -// `pkg/migration/file.go` — valid migration filenames. +// Valid migration filenames: `_.sql`. const MIGRATE_FILE_PATTERN = /^([0-9]+)_(.*)\.sql$/; /** - * Lists local migration file paths under `migrationsDir`. Mirrors Go's - * `migration.ListLocalMigrations` (`pkg/migration/list.go:33`): entries are sorted by name — Go's - * `fs.ReadDir` byte-wise UTF-8 order, via {@link legacyCompareUtf8Bytes}, not JS's default - * UTF-16-code-unit `Array.prototype.sort()` — directories skipped, a deprecated + * Lists local migration file paths under `migrationsDir`. Entries are sorted byte-wise over each + * name's UTF-8 encoding, via {@link legacyCompareUtf8Bytes} — not JS's default + * UTF-16-code-unit `Array.prototype.sort()`. Directories are skipped, a deprecated * `<14-digit>_init.sql` first migration (pre-2021-12-09) is skipped, and names must match * `_*.sql`. * - * Each skipped file emits a byte-exact stderr warning matching Go's - * `fmt.Fprintf(os.Stderr, …)` (`list.go:45-53`) — same wording for both the - * deprecated-init and misnamed-file cases. Because this is the shared lister, - * the warning fires for the `db diff/pull/schema declarative` and pgcache paths - * too, not only the `migration` commands, exactly as in Go. + * Each skipped file emits the established stderr warning — same wording for both the + * deprecated-init and misnamed-file cases. Because this is the shared lister, the warning + * fires for the `db diff/pull/schema declarative` paths too, not only the `migration` + * commands. */ export const legacyListLocalMigrations = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, @@ -30,14 +29,13 @@ export const legacyListLocalMigrations = Effect.fnUntraced(function* ( migrationsDir: string, ) { const output = yield* Output; - // Mirror Go's single `fs.ReadDir` (`pkg/migration/list.go:34-37`): only a - // not-exist directory is "no migrations"; every other read error (the path is a - // file → `ENOTDIR`, permission denied, …) aborts rather than silently letting + // Only a not-exist directory means "no migrations"; every other read error (the path + // is a file → `ENOTDIR`, permission denied, …) aborts rather than silently letting // smart generate/sync believe there are no local migrations. Effect surfaces // "not found" as a `PlatformError` with a `SystemError` reason tagged `"NotFound"`. const names = yield* fs.readDirectory(migrationsDir).pipe( Effect.catchTag("PlatformError", (error) => - error.reason._tag === "NotFound" + Predicate.isTagged(error.reason, "NotFound") ? Effect.succeed([] as ReadonlyArray) : Effect.fail( new LegacyMigrationsReadError({ @@ -47,27 +45,23 @@ export const legacyListLocalMigrations = Effect.fnUntraced(function* ( ), ); if (names.length === 0) return [] as ReadonlyArray; - // Go's `fs.ReadDir` (`pkg/migration/list.go:34`) returns entries sorted byte-wise over each - // name's UTF-8 encoding — NOT JS's default `Array.prototype.sort()`, which compares UTF-16 code - // units and disagrees with byte/codepoint order for a supplementary-plane filename character - // alongside a BMP private-use one (see {@link legacyCompareUtf8Bytes}'s own doc comment, - // verified empirically there against both Go's `sort.Strings` and `os.ReadDir`). Left - // uncorrected, such a migrations directory would replay in a different order than Go, and a - // dependent migration could fail or produce a different shadow schema (review: - // PRRT_kwDOErm0O86W3OyD). + // Entries must sort byte-wise over each name's UTF-8 encoding — NOT JS's default + // `Array.prototype.sort()`, which compares UTF-16 code units and disagrees with byte/codepoint + // order for a supplementary-plane filename character alongside a BMP private-use one (see + // {@link legacyCompareUtf8Bytes}'s own doc comment). Left uncorrected, such a migrations + // directory would replay in a different order than previous releases, and a dependent + // migration could fail or produce a different shadow schema (review: PRRT_kwDOErm0O86W3OyD). const sorted = [...names].sort(legacyCompareUtf8Bytes); const result: Array = []; for (let index = 0; index < sorted.length; index++) { const name = sorted[index]!; const entryPath = path.join(migrationsDir, name); - // Go's `os.ReadDir`/`DirEntry.IsDir()` (`pkg/migration/list.go:34-43`) classifies a - // directory entry from its own type without following symlinks (verified empirically: - // `DirEntry.IsDir()` reports `false` for a `.sql` symlink whose target is a directory) — - // so a symlinked migration is never skipped as a directory in Go, only later, when - // `ApplyMigrations` fails to read it as a regular file. `fs.stat` below follows + // Directory entries are classified from their own type without following symlinks: a + // `.sql` symlink whose target is a directory is never skipped as a directory here — it + // only fails later, when the migration is read as a regular file. `fs.stat` below follows // symlinks, so it would misclassify a symlink-to-directory as a plain directory and - // silently skip it here instead. Check `readLink` (which only succeeds for a symlink) - // first and skip the directory check entirely for symlinks, matching Go's `IsDir()`. + // silently skip it. Check `readLink` (which only succeeds for a symlink) first and skip + // the directory check entirely for symlinks. const isSymlink = Option.isSome(yield* fs.readLink(entryPath).pipe(Effect.option)); if (!isSymlink) { const stat = yield* fs.stat(entryPath).pipe(Effect.option); From 1b603b7b25976ebadecb403f7a06ff66c58b5e53 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 07:21:37 +0000 Subject: [PATCH 11/17] fix(cli): make the pgdelta config rollback authoritative and fix engine docs Address AI review findings on the default-engine flip: - `[experimental.pgdelta] enabled = false` now always selects migra: the historical SUPABASE_EXPERIMENTAL_PG_DELTA opt-in env var is no longer consulted (TS engine resolution and the Go delegated surface alike). With pg-delta on by default the opt-in added nothing, and honoring a stale one silently defeated the documented config rollback. The now-unused `envLookup` config accessor is removed with its last consumers. - db diff docs no longer open by claiming migra runs by default; the intro describes the pg-delta default with migra as the containerized fallback. - The Go CONTRIBUTING Verdaccio workflow now demonstrates a delegated command (`db pull --experimental` / `db remote commit`) instead of an ordinary `db pull`, which runs the TS in-process engine and ignores PGDELTA_NPM_REGISTRY. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BQAoY9JF4uiZRvKVkSd9Wy --- apps/cli-go/CONTRIBUTING.md | 7 +++- apps/cli-go/cmd/db.go | 6 ++- apps/cli-go/internal/db/pgcache/cache.go | 3 +- apps/cli/docs/supabase/db/diff.md | 2 +- .../legacy/commands/db/diff/SIDE_EFFECTS.md | 8 ++-- .../legacy/commands/db/diff/diff.handler.ts | 4 +- .../legacy/commands/db/pull/SIDE_EFFECTS.md | 1 - .../legacy/commands/db/pull/pull.handler.ts | 2 - .../commands/db/pull/pull.integration.test.ts | 40 +++++++++++-------- .../db/reset/reset.layers.unit.test.ts | 8 ++-- ...eclarative.orchestrate.integration.test.ts | 1 - ...elta-engine.next.layer.integration.test.ts | 1 - .../shared/legacy-db-config.toml-read.ts | 11 +---- .../src/legacy/shared/legacy-diff-engine.ts | 14 +++---- .../shared/legacy-diff-engine.unit.test.ts | 17 ++------ 15 files changed, 55 insertions(+), 70 deletions(-) diff --git a/apps/cli-go/CONTRIBUTING.md b/apps/cli-go/CONTRIBUTING.md index 4ce12329f4..cd1e9b6e44 100644 --- a/apps/cli-go/CONTRIBUTING.md +++ b/apps/cli-go/CONTRIBUTING.md @@ -89,10 +89,13 @@ export PGDELTA_NPM_REGISTRY=http://host.docker.internal:4873 # or: export PGDELTA_NPM_REGISTRY=http://172.17.0.1:4873 ``` -Then run any pg-delta-backed command, for example: +Then run one of the delegated commands that still reach the Go binary's edge-runtime +pg-delta path (ordinary `db diff` / `db pull` run the TypeScript in-process engine and +ignore this registry), for example: ```sh -supabase db pull --db-url "$DATABASE_URL" --diff-engine pg-delta +supabase db pull --experimental --db-url "$DATABASE_URL" +# or: supabase db remote commit ``` When set, the CLI injects a scoped `.npmrc` and forwards `NPM_CONFIG_REGISTRY` into the edge-runtime container (`PgDeltaNpmRegistryOption` in `internal/utils/pgdelta_local.go`). diff --git a/apps/cli-go/cmd/db.go b/apps/cli-go/cmd/db.go index a3b39d83d8..2a22f5ab40 100644 --- a/apps/cli-go/cmd/db.go +++ b/apps/cli-go/cmd/db.go @@ -175,8 +175,12 @@ var ( } ) +// pg-delta is the default engine; an explicit `[experimental.pgdelta] enabled = false` +// is the rollback, overridable per run by --use-pg-delta. The historical +// SUPABASE_EXPERIMENTAL_PG_DELTA opt-in env var is no longer consulted so the +// config rollback stays authoritative. func shouldUsePgDelta() bool { - return utils.IsPgDeltaEnabled() || usePgDelta || viper.GetBool("EXPERIMENTAL_PG_DELTA") + return utils.IsPgDeltaEnabled() || usePgDelta } // resolveDiffEngine reports whether `db diff` should run in pg-delta mode. The config / diff --git a/apps/cli-go/internal/db/pgcache/cache.go b/apps/cli-go/internal/db/pgcache/cache.go index 3cc1ccd4b4..d24015d68b 100644 --- a/apps/cli-go/internal/db/pgcache/cache.go +++ b/apps/cli-go/internal/db/pgcache/cache.go @@ -18,7 +18,6 @@ import ( "github.com/jackc/pgconn" "github.com/jackc/pgx/v4" "github.com/spf13/afero" - "github.com/spf13/viper" "github.com/supabase/cli/internal/gen/types" "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/pkg/config" @@ -91,7 +90,7 @@ func TryCacheMigrationsCatalog(ctx context.Context, config pgconn.Config, prefix } func ShouldCacheMigrationsCatalog() bool { - return utils.IsPgDeltaEnabled() || viper.GetBool("EXPERIMENTAL_PG_DELTA") + return utils.IsPgDeltaEnabled() } func CatalogPrefixFromConfig(config pgconn.Config) string { diff --git a/apps/cli/docs/supabase/db/diff.md b/apps/cli/docs/supabase/db/diff.md index 2d245a1cc8..1bb15f522b 100644 --- a/apps/cli/docs/supabase/db/diff.md +++ b/apps/cli/docs/supabase/db/diff.md @@ -4,7 +4,7 @@ Diffs schema changes made to the local or remote database. Requires the local development stack to be running when diffing against the local database. To diff against a remote or self-hosted database, specify the `--linked` or `--db-url` flag respectively. -Runs [djrobstep/migra](https://github.com/djrobstep/migra) in a container to compare schema differences between the target database and a shadow database. The shadow database is created by applying migrations in local `supabase/migrations` directory in a separate container. Output is written to stdout by default. For convenience, you can also save the schema diff as a new migration file by passing in `-f` flag. +Compares schema differences between the target database and a shadow database, using the bundled pg-delta engine by default. The legacy [djrobstep/migra](https://github.com/djrobstep/migra) engine, which runs in a container, remains available as a fallback (see below). The shadow database is created by applying migrations in local `supabase/migrations` directory in a separate container. Output is written to stdout by default. For convenience, you can also save the schema diff as a new migration file by passing in `-f` flag. Explicit `--from`/`--to` mode always uses pg-delta. In this mode, `-f` is ignored and stdout (or `--output`) is a flattened representation for review, not a portable apply script. Do not apply it directly with plain `psql -f`: transactional units can contain `SET LOCAL` preambles that only take effect inside a transaction, while plans that mix transactional and non-transactional units cannot safely be wrapped in one transaction. To create an applicable migration, use normal target mode with `supabase db diff -f `, then apply it through `supabase db reset` locally or `supabase db push` against the linked project. These paths preserve the plan's per-unit transaction semantics. 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 240bffe850..4db5bbd198 100644 --- a/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md @@ -88,7 +88,6 @@ of this command's own target resolve, ahead of the differ container. | `SUPABASE_NETWORK_ID` (`--network-id`) | forces the shadow container/network onto an existing Docker network | no | | `SUPABASE_HOME` | overrides the `~/.supabase` root used for the shadow baseline cache (and other CLI state) | no | | `SUPABASE_SHADOW_CACHE` | shadow baseline cache; opt-in (`1`/`true`); the shadow's post-baseline PGDATA is snapshotted to a tar and restored into the next run's fresh container (see Notes) | no | -| `SUPABASE_EXPERIMENTAL_PG_DELTA` | force pg-delta engine | no | | `PGDELTA_DEBUG` | pg-delta debug capture | no | | `SUPABASE_SSL_DEBUG` | migra SSL debug logging | no | | `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 | @@ -97,10 +96,9 @@ of this command's own target resolve, ahead of the differ container. `SUPABASE_DB_HEALTH_TIMEOUT` all apply to `--use-pgadmin` too — its shadow is provisioned through the same primitives. -`SUPABASE_EXPERIMENTAL_PG_DELTA` is **read, no effect** on the pgadmin path: the pg-delta -engine-selection lookup (`legacyShouldUsePgDelta`) runs unconditionally, before the -`--use-pgadmin` branch, but the pgadmin branch is chosen first and never consults the -resulting `useDelta` value. +The historical `SUPABASE_EXPERIMENTAL_PG_DELTA` opt-in env var is **no longer read**: +pg-delta is the default engine, and the explicit `[experimental.pgdelta] enabled = false` +config rollback is authoritative. `SUPABASE_INTERNAL_IMAGE_REGISTRY` applies to the differ's own image resolution too. The docker-run layer's resolver (`legacy-docker-run.layer.ts`) is built once, statically, with 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 d94d27fca6..7c6c0cf989 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -40,7 +40,6 @@ import { import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { - legacyParseBoolEnv, legacyResolveDiffEngine, legacySchemaPathsTransitionWarning, legacyShouldUsePgDelta, @@ -550,12 +549,11 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy }; const formatOptions = Option.getOrElse(cfg.pgDelta.formatOptions, () => ""); - // Engine resolution: the pg-delta env/config/flag gate, read from the + // Engine resolution: the pg-delta config/flag gate, read from the // (possibly remote-merged) config. const pgDeltaDefault = legacyShouldUsePgDelta({ configEnabled: cfg.pgDelta.enabled, usePgDeltaFlag: Option.getOrElse(flags.usePgDelta, () => false), - envEnabled: legacyParseBoolEnv(cfg.envLookup("SUPABASE_EXPERIMENTAL_PG_DELTA")), }); const useDelta = legacyResolveDiffEngine({ useMigraChanged: Option.isSome(flags.useMigra), diff --git a/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md index 0609afcb7e..0c6d528fca 100644 --- a/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md @@ -116,7 +116,6 @@ at all, so nothing is cached for it. | `SUPABASE_NETWORK_ID` (`--network-id`) | forces the shadow container/network onto an existing Docker network | no | | `SUPABASE_HOME` | overrides the `~/.supabase` root used for the shadow baseline cache (and other CLI state) | no | | `SUPABASE_SHADOW_CACHE` | shadow baseline cache; opt-in (`1`/`true`); the shadow's post-baseline PGDATA is snapshotted to a tar and restored into the next run's fresh container (see Notes) | no | -| `SUPABASE_EXPERIMENTAL_PG_DELTA` | force pg-delta diff engine | no | | `SUPABASE_EXPERIMENTAL` | selects the deprecated structured-dump branch (still delegates to Go, see below) | no | ## Exit Codes 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 75179e9e0e..f48a05befb 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -50,7 +50,6 @@ import { legacyWriteDeclarativeSchemas, } from "../shared/legacy-pgdelta.write.ts"; import { - legacyParseBoolEnv, legacyResolveDeclarativeFromArgs, legacyResolvePullDiffEngine, legacySchemaPathsTransitionWarning, @@ -435,7 +434,6 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy pgDeltaDefault: legacyShouldUsePgDelta({ configEnabled: toml.pgDelta.enabled, usePgDeltaFlag: false, - envEnabled: legacyParseBoolEnv(toml.envLookup("SUPABASE_EXPERIMENTAL_PG_DELTA")), }), }); 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 3570c91e9e..0b1637cd6d 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 @@ -1939,22 +1939,30 @@ describe("legacy db pull", () => { }, ); - it.effect("a project supabase/.env enabling pg-delta selects the pg-delta engine", () => { - // A project .env must select pg-delta even when the shell env doesn't set it. - // The handler reads it via toml.envLookup, not process.env. - seedMigration(tmp.current, "20240101000000"); - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_EXPERIMENTAL_PG_DELTA=true\n"); - const s = setup(tmp.current, { - remoteVersions: ["20240101000000"], - edgeStdout: pgDeltaDiffEnvelope([{ name: "schema_changes", sql: "create table remote ();" }]), - yes: true, - }); - return Effect.gen(function* () { - yield* legacyDbPull(flags()); - expect(s.engineCalls[0]?.operation).toBe("diff"); - }).pipe(Effect.provide(s.layer)); - }); + it.effect( + "config enabled = false selects migra even with a stale SUPABASE_EXPERIMENTAL_PG_DELTA opt-in", + () => { + // The explicit config rollback is authoritative: the historical + // SUPABASE_EXPERIMENTAL_PG_DELTA opt-in (here in the project .env) is no + // longer consulted, so it cannot silently defeat `enabled = false`. + seedMigration(tmp.current, "20240101000000"); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + "[experimental.pgdelta]\nenabled = false\n", + ); + writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_EXPERIMENTAL_PG_DELTA=true\n"); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: "create table remote ();\n", + yes: true, + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags()); + expect(s.engineCalls).toHaveLength(0); + expect(s.edgeRunCount).toBe(1); + }).pipe(Effect.provide(s.layer)); + }, + ); it.effect( "defaults to the pg-delta engine when config has no [experimental.pgdelta] section", diff --git a/apps/cli/src/legacy/commands/db/reset/reset.layers.unit.test.ts b/apps/cli/src/legacy/commands/db/reset/reset.layers.unit.test.ts index 945a4fbdf0..dede20a3e9 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.layers.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.layers.unit.test.ts @@ -1,11 +1,9 @@ /** * Layer-exposure test for `legacyDbResetRuntimeLayer`. * - * Regression guard (review CLI-1958): the post-reset best-effort pg-delta - * catalog cache (`legacyTryCacheMigrationsCatalog` in `reset.handler.ts`, gated - * on `[experimental.pgdelta].enabled` / `SUPABASE_EXPERIMENTAL_PG_DELTA`) reaches - * `LegacyEdgeRuntimeScript` and `LegacyPgDeltaSslProbe` via - * `legacyExportCatalogPgDelta` (`legacy-pgdelta.ts`). `legacyDbResetRuntimeLayer` + * Regression guard (review CLI-1958): reset code paths can reach + * `LegacyEdgeRuntimeScript` and `LegacyPgDeltaSslProbe` (today through the shared + * pg-delta command runtime backing the migra fallback). `legacyDbResetRuntimeLayer` * previously omitted both services (and the `LegacyDockerRun` layer the real * edge-runtime implementation needs) — unlike `legacyDbPushRuntimeLayer`, which * already composes all three. That gap was invisible to `reset.integration.test.ts` diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts index 6b1e177b2c..0de28622b3 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts @@ -39,7 +39,6 @@ const ctx = (cwd: string, declarativeDir: string): LegacyDeclarativeRunContext = // write a `config.toml`). const toml: LegacyDbTomlValues = { projectEnv: {}, - envLookup: () => undefined, apiSchemas: ["public", "graphql_public"], port: 54322, shadowPort: 54320, diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.integration.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.integration.test.ts index 34c21d14bc..5a8188f627 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.integration.test.ts @@ -26,7 +26,6 @@ const common = { const toml: LegacyDbTomlValues = { projectEnv: {}, - envLookup: () => undefined, apiSchemas: ["public", "graphql_public"], port: 54322, shadowPort: 54320, diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts index 2ed9d2f903..dbfef105d3 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts @@ -44,14 +44,6 @@ type EnvLookup = (name: string) => string | undefined; */ export interface LegacyDbTomlValues { readonly projectEnv: Readonly>; - /** - * Resolves a `SUPABASE_*` env var with Go's precedence: shell env (non-empty) - * wins, then the loaded project `.env*` files (non-empty), else undefined. - * Go writes project `.env` into the process env before viper's `AutomaticEnv` - * reads these, so handlers must consult both - * rather than `process.env` alone (e.g. `SUPABASE_EXPERIMENTAL_PG_DELTA`). - */ - readonly envLookup: (name: string) => string | undefined; readonly apiSchemas: ReadonlyArray; /** `[db] port`, default 54322 (`packages/config/src/db.ts`). */ readonly port: number; @@ -979,7 +971,7 @@ const DEFAULT_SUPABASE_ENV = "development"; * would leave that process.env-only reader blind to a project-`.env`-scoped * override the shell never set. * Everything else is read from {@link legacyLoadProjectEnv}'s returned map - * (`envLookup`, `legacyResolveYesWithProjectEnv`, `resolveDbPassword`) or resolved + * (`envOverride`, `legacyResolveYesWithProjectEnv`, `resolveDbPassword`) or resolved * eagerly from the shell before any `.env` load — Go's root globals (workdir / * profile / `SUPABASE_ENV` / project-ref) are frozen before `loadNestedEnv`, so * writing them here would let our lazily-built resolvers diverge from Go (retarget @@ -2658,7 +2650,6 @@ const readDbTomlCore = Effect.fnUntraced(function* ( const values: LegacyDbTomlValues = { projectEnv, - envLookup: envOverride, apiSchemas, port, shadowPort, diff --git a/apps/cli/src/legacy/shared/legacy-diff-engine.ts b/apps/cli/src/legacy/shared/legacy-diff-engine.ts index 4407377fdf..28f4c21ecf 100644 --- a/apps/cli/src/legacy/shared/legacy-diff-engine.ts +++ b/apps/cli/src/legacy/shared/legacy-diff-engine.ts @@ -5,18 +5,18 @@ export const legacySchemaPathsTransitionWarning = "WARNING: [db.migrations].schema_paths no longer changes the migrations baseline used by db diff or migration-style db pull. These commands always compare local migrations with the selected database. Use `supabase db schema declarative sync` to compare declarative schema files.\n"; /** - * Whether pg-delta is the active default engine. Mirrors `shouldUsePgDelta`: - * `utils.IsPgDeltaEnabled() || usePgDelta || viper.GetBool("EXPERIMENTAL_PG_DELTA")`. - * The three inputs are the resolved config flag (`[experimental.pgdelta].enabled`), - * the command's `--use-pg-delta` flag, and the `SUPABASE_EXPERIMENTAL_PG_DELTA` - * env var. + * Whether pg-delta is the active default engine. pg-delta is on unless the project + * explicitly rolls back with `[experimental.pgdelta] enabled = false`; the command's + * `--use-pg-delta` flag is a per-run opt-in that overrides that rollback. The historical + * `SUPABASE_EXPERIMENTAL_PG_DELTA` opt-in env var is intentionally not an input: now + * that pg-delta is the default it adds nothing when the config is on, and honoring a + * stale opt-in would silently defeat the documented config rollback. */ export function legacyShouldUsePgDelta(inputs: { readonly configEnabled: boolean; readonly usePgDeltaFlag: boolean; - readonly envEnabled: boolean; }): boolean { - return inputs.configEnabled || inputs.usePgDeltaFlag || inputs.envEnabled; + return inputs.configEnabled || inputs.usePgDeltaFlag; } /** diff --git a/apps/cli/src/legacy/shared/legacy-diff-engine.unit.test.ts b/apps/cli/src/legacy/shared/legacy-diff-engine.unit.test.ts index ff81d20ba5..38c7fc505d 100644 --- a/apps/cli/src/legacy/shared/legacy-diff-engine.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-diff-engine.unit.test.ts @@ -9,19 +9,10 @@ import { } from "./legacy-diff-engine.ts"; describe("legacyShouldUsePgDelta", () => { - it("is the OR of config, flag, and env", () => { - expect( - legacyShouldUsePgDelta({ configEnabled: false, usePgDeltaFlag: false, envEnabled: false }), - ).toBe(false); - expect( - legacyShouldUsePgDelta({ configEnabled: true, usePgDeltaFlag: false, envEnabled: false }), - ).toBe(true); - expect( - legacyShouldUsePgDelta({ configEnabled: false, usePgDeltaFlag: true, envEnabled: false }), - ).toBe(true); - expect( - legacyShouldUsePgDelta({ configEnabled: false, usePgDeltaFlag: false, envEnabled: true }), - ).toBe(true); + it("follows the config default and lets --use-pg-delta override an explicit rollback", () => { + expect(legacyShouldUsePgDelta({ configEnabled: false, usePgDeltaFlag: false })).toBe(false); + expect(legacyShouldUsePgDelta({ configEnabled: true, usePgDeltaFlag: false })).toBe(true); + expect(legacyShouldUsePgDelta({ configEnabled: false, usePgDeltaFlag: true })).toBe(true); }); }); From 55531918940aac6cc62afd186378de9452e4bf3d Mon Sep 17 00:00:00 2001 From: avallete Date: Mon, 31 Aug 2026 12:06:41 +0200 Subject: [PATCH 12/17] chore(cli): drop leftover catalog-warmup wiring after the pg-delta default flip The warmup path is gone; keep layers, comments, and docs aligned with pg-delta as the only default engine. Co-authored-by: Cursor --- apps/cli-go/CONTRIBUTING.md | 5 +- apps/cli-go/cmd/db.go | 4 +- .../internal/db/declarative/declarative.go | 30 ---- .../db/declarative/declarative_test.go | 70 --------- apps/cli-go/internal/db/pgcache/cache.go | 102 ------------- .../db/pgcache/cache_template_test.go | 33 ---- apps/cli/docs/go-cli-divergences.md | 16 +- apps/cli/docs/go-cli-reference.md | 2 +- .../commands/bootstrap/bootstrap.handler.ts | 1 - .../bootstrap/bootstrap.integration.test.ts | 13 -- .../commands/bootstrap/bootstrap.layers.ts | 16 +- ...ootstrap.workdir-cache.integration.test.ts | 18 +-- .../legacy/commands/db/diff/SIDE_EFFECTS.md | 7 +- .../legacy/commands/db/diff/diff.command.ts | 7 +- .../legacy/commands/db/push/push.handler.ts | 1 - .../commands/db/push/push.integration.test.ts | 11 -- .../legacy/commands/db/push/push.layers.ts | 11 -- .../legacy/commands/db/reset/reset.handler.ts | 2 +- .../db/reset/reset.integration.test.ts | 12 -- .../legacy/commands/db/reset/reset.layers.ts | 24 +-- .../db/reset/reset.layers.unit.test.ts | 144 ------------------ .../declarative/generate/SIDE_EFFECTS.md | 7 +- .../schema/declarative/sync/SIDE_EFFECTS.md | 5 +- .../db/shared/legacy-shadow-source.ts | 11 -- .../db/start/start.integration.test.ts | 11 -- .../legacy/commands/db/start/start.layers.ts | 13 -- .../legacy/commands/start/start.command.ts | 12 -- .../commands/start/start.integration.test.ts | 11 -- .../shared/db-bootstrap/db-setup.unit.test.ts | 38 ----- .../db-bootstrap/recreate-local-database.ts | 7 - .../src/legacy/shared/legacy-db-push-core.ts | 29 +--- .../src/legacy/shared/legacy-diff-engine.ts | 2 +- .../legacy/shared/legacy-migration-list.ts | 2 +- .../init/project-init.templates.unit.test.ts | 3 - 34 files changed, 36 insertions(+), 644 deletions(-) delete mode 100644 apps/cli-go/internal/db/pgcache/cache_template_test.go delete mode 100644 apps/cli/src/legacy/commands/db/reset/reset.layers.unit.test.ts diff --git a/apps/cli-go/CONTRIBUTING.md b/apps/cli-go/CONTRIBUTING.md index cd1e9b6e44..f50b90ef69 100644 --- a/apps/cli-go/CONTRIBUTING.md +++ b/apps/cli-go/CONTRIBUTING.md @@ -46,7 +46,7 @@ The Supabase API client is generated from OpenAPI spec. See [our guide](api/READ > **Scope:** this workflow only applies to the Go binary's own edge-runtime pg-delta > path, which the TypeScript CLI still reaches through the delegated -> `db remote commit` / `db pull --experimental` commands. The main TypeScript CLI +> `db remote commit` command. The main TypeScript CLI > bundles `@supabase/pg-delta` in-process and reads neither `PGDELTA_NPM_REGISTRY` > nor `supabase/.temp/pgdelta-version` — to test a local pg-delta build there, > update the `@supabase/pg-delta` dependency pin in `apps/cli/package.json` / @@ -94,8 +94,7 @@ pg-delta path (ordinary `db diff` / `db pull` run the TypeScript in-process engi ignore this registry), for example: ```sh -supabase db pull --experimental --db-url "$DATABASE_URL" -# or: supabase db remote commit +supabase db remote commit ``` When set, the CLI injects a scoped `.npmrc` and forwards `NPM_CONFIG_REGISTRY` into the edge-runtime container (`PgDeltaNpmRegistryOption` in `internal/utils/pgdelta_local.go`). diff --git a/apps/cli-go/cmd/db.go b/apps/cli-go/cmd/db.go index 2a22f5ab40..2aa213e5b0 100644 --- a/apps/cli-go/cmd/db.go +++ b/apps/cli-go/cmd/db.go @@ -187,7 +187,7 @@ func shouldUsePgDelta() bool { // env default (pgDeltaDefault) applies unless an explicit non-pg-delta engine is selected: // --use-migra, --use-pgadmin, or --use-pg-schema is an authoritative rollback that clears // pg-delta mode so diff.Run skips pg-delta-specific declarative shadow setup and the -// PGDELTA_DEBUG capture path. --use-migra defaults to true, so only an explicit pass +// PGDELTA_DEBUG capture path. --use-migra is off unless passed, so only an explicit pass // (useMigraChanged) counts as opting out. func resolveDiffEngine(useMigraChanged, usePgAdmin, usePgSchema, pgDeltaDefault bool) bool { if useMigraChanged || usePgAdmin || usePgSchema { @@ -216,7 +216,7 @@ func init() { dbCmd.AddCommand(dbBranchCmd) // Build diff command diffFlags := dbDiffCmd.Flags() - diffFlags.BoolVar(&useMigra, "use-migra", true, "Use migra to generate schema diff.") + diffFlags.BoolVar(&useMigra, "use-migra", false, "Use migra to generate schema diff.") diffFlags.BoolVar(&usePgAdmin, "use-pgadmin", false, "Use pgAdmin to generate schema diff.") diffFlags.BoolVar(&usePgSchema, "use-pg-schema", false, "Use pg-schema-diff to generate schema diff.") diffFlags.BoolVar(&usePgDelta, "use-pg-delta", false, "Use pg-delta to generate schema diff.") diff --git a/apps/cli-go/internal/db/declarative/declarative.go b/apps/cli-go/internal/db/declarative/declarative.go index b84087bf9f..8585064216 100644 --- a/apps/cli-go/internal/db/declarative/declarative.go +++ b/apps/cli-go/internal/db/declarative/declarative.go @@ -789,36 +789,6 @@ func pgDeltaFormatOptions() string { return strings.TrimSpace(utils.Config.Experimental.PgDelta.FormatOptions) } -func TryCacheMigrationsCatalog(ctx context.Context, config pgconn.Config, prefix string, version string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { - if !shouldCacheMigrationsCatalog() || len(version) > 0 { - return nil - } - if len(strings.TrimSpace(prefix)) == 0 { - prefix = catalogPrefixFromConfig(config) - } - hash, err := hashMigrations(fsys) - if err != nil { - return err - } - snapshot, err := exportCatalog(ctx, utils.ToPostgresURL(config), "postgres", options...) - if err != nil { - return err - } - if err := ensureTempDir(fsys); err != nil { - return err - } - _, err = pgcache.WriteMigrationCatalogSnapshot(fsys, prefix, hash, snapshot) - return err -} - -func shouldCacheMigrationsCatalog() bool { - return pgcache.ShouldCacheMigrationsCatalog() -} - -func catalogPrefixFromConfig(config pgconn.Config) string { - return pgcache.CatalogPrefixFromConfig(config) -} - // findDropStatements extracts DROP statements for safety warnings shown when // generating migration output from declarative diffs. func findDropStatements(out string) []string { diff --git a/apps/cli-go/internal/db/declarative/declarative_test.go b/apps/cli-go/internal/db/declarative/declarative_test.go index 515e255990..ae2aeb063f 100644 --- a/apps/cli-go/internal/db/declarative/declarative_test.go +++ b/apps/cli-go/internal/db/declarative/declarative_test.go @@ -2,8 +2,6 @@ package declarative import ( "context" - "crypto/sha256" - "encoding/hex" "path/filepath" "strings" "testing" @@ -83,74 +81,6 @@ func TestWriteDeclarativeSchemasSkipsConfigUpdateWhenPgDeltaEnabled(t *testing.T assert.Equal(t, originalConfig, string(cfg)) } -func TestTryCacheMigrationsCatalogWritesPrefixedCache(t *testing.T) { - fsys := afero.NewMemMapFs() - original := utils.Config.Experimental.PgDelta - utils.Config.Experimental.PgDelta = &config.PgDeltaConfig{Enabled: true} - t.Cleanup(func() { - utils.Config.Experimental.PgDelta = original - exportCatalog = diff.ExportCatalogPgDelta - }) - p := filepath.Join(utils.MigrationsDir, "20240101000000_first.sql") - require.NoError(t, afero.WriteFile(fsys, p, []byte("create table a();"), 0644)) - exportCatalog = func(_ context.Context, targetRef, role string, _ ...func(*pgx.ConnConfig)) (string, error) { - assert.Equal(t, "postgres", role) - assert.Contains(t, targetRef, "db.test.supabase.co") - return `{"version":1}`, nil - } - - err := TryCacheMigrationsCatalog(t.Context(), pgconn.Config{ - Host: "db.test.supabase.co", - Port: 5432, - User: "postgres", - Password: "postgres", - Database: "postgres", - }, "remote-ref", "", fsys) - require.NoError(t, err) - - hash, err := hashMigrations(fsys) - require.NoError(t, err) - cachePath, ok, err := pgcache.ResolveMigrationCatalogPath(fsys, hash, "remote-ref") - require.NoError(t, err) - require.True(t, ok) - cached, err := afero.ReadFile(fsys, cachePath) - require.NoError(t, err) - assert.JSONEq(t, `{"version":1}`, string(cached)) -} - -func TestTryCacheMigrationsCatalogSkipsPartialApply(t *testing.T) { - fsys := afero.NewMemMapFs() - original := utils.Config.Experimental.PgDelta - utils.Config.Experimental.PgDelta = &config.PgDeltaConfig{Enabled: true} - called := false - t.Cleanup(func() { - utils.Config.Experimental.PgDelta = original - exportCatalog = diff.ExportCatalogPgDelta - }) - exportCatalog = func(_ context.Context, _ string, _ string, _ ...func(*pgx.ConnConfig)) (string, error) { - called = true - return `{"version":1}`, nil - } - - err := TryCacheMigrationsCatalog(t.Context(), pgconn.Config{ - Host: "127.0.0.1", Port: 5432, User: "postgres", Password: "postgres", Database: "postgres", - }, "", "20240101000000", fsys) - require.NoError(t, err) - assert.False(t, called) -} - -func TestCatalogPrefixFromConfig(t *testing.T) { - local := catalogPrefixFromConfig(pgconn.Config{Host: utils.Config.Hostname, Port: utils.Config.Db.Port}) - assert.Equal(t, "local", local) - - linked := catalogPrefixFromConfig(pgconn.Config{Host: "db.abcdefghijklmnopqrst.supabase.co", Port: 5432}) - assert.Equal(t, "abcdefghijklmnopqrst", linked) - - custom := catalogPrefixFromConfig(pgconn.Config{Host: "db.example.com", Port: 5432, Database: "postgres", User: "postgres"}) - sum := sha256.Sum256([]byte("postgres@db.example.com:5432/postgres")) - assert.Equal(t, "url-"+hex.EncodeToString(sum[:])[:12], custom) -} - func TestWriteDeclarativeSchemasUsesConfiguredDir(t *testing.T) { fsys := afero.NewMemMapFs() require.NoError(t, afero.WriteFile(fsys, utils.ConfigPath, []byte("[db]\n"), 0644)) diff --git a/apps/cli-go/internal/db/pgcache/cache.go b/apps/cli-go/internal/db/pgcache/cache.go index d24015d68b..c08e82ae70 100644 --- a/apps/cli-go/internal/db/pgcache/cache.go +++ b/apps/cli-go/internal/db/pgcache/cache.go @@ -1,12 +1,9 @@ package pgcache import ( - "bytes" - "context" "crypto/sha256" "encoding/hex" "fmt" - "os" "path/filepath" "regexp" "sort" @@ -14,13 +11,8 @@ import ( "strings" "time" - "github.com/go-errors/errors" - "github.com/jackc/pgconn" - "github.com/jackc/pgx/v4" "github.com/spf13/afero" - "github.com/supabase/cli/internal/gen/types" "github.com/supabase/cli/internal/utils" - "github.com/supabase/cli/pkg/config" "github.com/supabase/cli/pkg/migration" ) @@ -29,82 +21,10 @@ const ( migrationsCatalogName = "catalog-%s-migrations-%s-%d.json" legacyMigrationsCatalogName = "catalog-%s-migrations-%s.json" catalogRetentionCount = 2 - pgDeltaCatalogExportTS = `// This script serializes a database catalog for caching/reuse in declarative -// pg-delta workflows. Uses the same API as pgdelta_catalog_export.ts (main package only, no /catalog subpath). -import { - createManagedPool, - extractCatalog, - serializeCatalog, - stringifyCatalogSnapshot, -} from "npm:@supabase/pg-delta@1.0.0-alpha.20"; -const target = Deno.env.get("TARGET"); -const role = Deno.env.get("ROLE") ?? undefined; -if (!target) { - console.error("TARGET is required"); - throw new Error(""); -} -const { pool, close } = await createManagedPool(target, { role }); -try { - const catalog = await extractCatalog(pool); - console.log(stringifyCatalogSnapshot(serializeCatalog(catalog))); -} catch (e) { - console.error(e); - // Force close event loop - throw new Error(""); -} finally { - await close(); -} -// Force close the event loop on the success path too. The connection pool can -// leave keepalive handles registered even after close() resolves, which keeps -// the Edge Runtime worker (and therefore the container) alive after the catalog -// has already been written to stdout. The CLI streams this container's logs with -// Follow:true, so a worker that never exits hangs the migrations-catalog cache -// path (db start / db push with pg-delta caching) indefinitely at 0% CPU -// (supabase/pg-toolbelt#312). -throw new Error(""); -` ) var catalogPrefixRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]+`) -func TryCacheMigrationsCatalog(ctx context.Context, config pgconn.Config, prefix string, version string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { - if !ShouldCacheMigrationsCatalog() || len(version) > 0 { - return nil - } - if len(strings.TrimSpace(prefix)) == 0 { - prefix = CatalogPrefixFromConfig(config) - } - hash, err := HashMigrations(fsys) - if err != nil { - return err - } - snapshot, err := exportCatalog(ctx, utils.ToPostgresURL(config), options...) - if err != nil { - return err - } - if err := ensureTempDir(fsys); err != nil { - return err - } - _, err = WriteMigrationCatalogSnapshot(fsys, prefix, hash, snapshot) - return err -} - -func ShouldCacheMigrationsCatalog() bool { - return utils.IsPgDeltaEnabled() -} - -func CatalogPrefixFromConfig(config pgconn.Config) string { - if utils.IsLocalDatabase(config) { - return "local" - } - if matches := utils.ProjectHostPattern.FindStringSubmatch(config.Host); len(matches) > 2 { - return matches[2] - } - key := fmt.Sprintf("%s@%s:%d/%s", config.User, config.Host, config.Port, config.Database) - sum := sha256.Sum256([]byte(key)) - return "url-" + hex.EncodeToString(sum[:])[:12] -} - func MigrationCatalogPath(hash, prefix string, createdAt time.Time) string { return filepath.Join(pgDeltaTempPath(), fmt.Sprintf(migrationsCatalogName, SanitizedCatalogPrefix(prefix), hash, createdAt.UnixMilli())) } @@ -253,25 +173,3 @@ func ensureTempDir(fsys afero.Fs) error { func pgDeltaTempPath() string { return filepath.Join(utils.TempDir, pgDeltaTempDir) } - -func exportCatalog(ctx context.Context, targetRef string, options ...func(*pgx.ConnConfig)) (string, error) { - preparedRef, sslEnv, err := types.PreparePgDeltaPostgresRef(ctx, targetRef, types.PgDeltaTargetSSLRootCert, options...) - if err != nil { - return "", err - } - env := append([]string{"TARGET=" + preparedRef, "ROLE=postgres"}, sslEnv...) - binds := []string{utils.EdgeRuntimeId + ":/root/.cache/deno:rw"} - if cwd, err := os.Getwd(); err == nil { - binds = append(binds, cwd+":/workspace") - } - var stdout, stderr bytes.Buffer - script := config.InterpolatePgDeltaScript(config.Config(&utils.Config), pgDeltaCatalogExportTS) - if err := utils.RunEdgeRuntimeScript(ctx, env, script, binds, "error exporting pg-delta catalog", &stdout, &stderr, utils.PgDeltaNpmRegistryOption()); err != nil { - return "", err - } - snapshot := strings.TrimSpace(stdout.String()) - if len(snapshot) == 0 { - return "", errors.Errorf("error exporting pg-delta catalog: edge-runtime script produced no output:\n%s", stderr.String()) - } - return snapshot, nil -} diff --git a/apps/cli-go/internal/db/pgcache/cache_template_test.go b/apps/cli-go/internal/db/pgcache/cache_template_test.go deleted file mode 100644 index 1118853597..0000000000 --- a/apps/cli-go/internal/db/pgcache/cache_template_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package pgcache - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// The migrations-catalog cache script (db start / db push with pg-delta caching) -// opens a connection pool and must force the worker's event loop closed once it -// has written its snapshot. If a keepalive handle lingers after close() resolves -// the worker never exits, so the container never stops and the CLI — which -// follows the container logs with Follow:true — hangs indefinitely at 0% CPU -// (supabase/pg-toolbelt#312). Guard against the success-path force-close being -// dropped. -func TestPgDeltaCatalogExportScriptForceClosesOnSuccess(t *testing.T) { - require.NotEmpty(t, pgDeltaCatalogExportTS) - - lines := strings.Split(pgDeltaCatalogExportTS, "\n") - last := "" - for i := len(lines) - 1; i >= 0; i-- { - line := strings.TrimSpace(lines[i]) - if line == "" || strings.HasPrefix(line, "//") { - continue - } - last = line - break - } - assert.Equal(t, `throw new Error("");`, last, - "success path must force the Edge Runtime worker to exit so the container stops") -} diff --git a/apps/cli/docs/go-cli-divergences.md b/apps/cli/docs/go-cli-divergences.md index 693e2e3328..ab943df3e8 100644 --- a/apps/cli/docs/go-cli-divergences.md +++ b/apps/cli/docs/go-cli-divergences.md @@ -27,9 +27,10 @@ These commands exist in the TS CLI today but have no direct top-level equivalent pg-delta next engine is active (the default): coverage gaps that the engine reports — statements it skipped or objects it could not represent — normally surface as warnings, and `--strict-coverage` promotes them to hard failures. - Under the `SUPABASE_USE_PG_DELTA_NEXT=false` legacy opt-out the flag is - accepted but has no effect, since the legacy edge-runtime engine does not - emit coverage diagnostics. Default behavior (omitted flag) matches Go. + Rolling back to migra (`--use-migra`, `--diff-engine migra`, or + `[experimental.pgdelta] enabled = false`) accepts the flag but has no effect, + since migra does not emit coverage diagnostics. Default behavior (omitted + flag) matches Go. - `db push` has a TS-only `--skip-vault` flag. It applies migrations without resolving or updating `[db.vault]` secrets; default behavior still matches Go. - Every legacy command that resolves a linked project ref for its own database @@ -98,11 +99,10 @@ These commands exist in the TS CLI today but have no direct top-level equivalent behavior or exit codes; a non-interactive sync still fails with Go's "no declarative schema found" message. Inside that directory the bundled (default) pg-delta engine writes one directory per schema at the root — `supabase/schemas/public/tables/x.sql` — - with cluster-level objects under a reserved `supabase/schemas/_cluster/`. The Go reference, - and the opt-out legacy engine (`SUPABASE_USE_PG_DELTA_NEXT=false`, which runs the pinned - `[experimental.pgdelta] npm_version` in Edge Runtime), instead nest everything one level - deeper as `schemas//…` plus `cluster/…`, so a legacy-engine export lands at - `supabase/schemas/schemas/public/tables/x.sql`. + with cluster-level objects under a reserved `supabase/schemas/_cluster/`. The Go + reference (edge-runtime pg-delta on remaining delegated commands) instead nests + everything one level deeper as `schemas//…` plus `cluster/…`, so a Go + export lands at `supabase/schemas/schemas/public/tables/x.sql`. - Local `pg_net` presence now converges with `[experimental.webhooks]` instead of being installed unconditionally: `db-webhook.sql` no longer creates the extension at container init, `supabase start`/`db start` install it (with grants reapplied via the diff --git a/apps/cli/docs/go-cli-reference.md b/apps/cli/docs/go-cli-reference.md index e557eefadf..c75eb9bfac 100644 --- a/apps/cli/docs/go-cli-reference.md +++ b/apps/cli/docs/go-cli-reference.md @@ -248,7 +248,7 @@ Flags: --linked Diffs local migration files against the linked project. --local Diffs local migration files against the local database. (default true) -s, --schema strings Comma separated list of schema to include. - --use-migra Use migra to generate schema diff. (default true) + --use-migra Use migra to generate schema diff. --use-pg-delta Use pg-delta to generate schema diff. --use-pg-schema Use pg-schema-diff to generate schema diff. --use-pgadmin Use pgAdmin to generate schema diff. diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts index 5b774aa226..0f15834b00 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts @@ -377,7 +377,6 @@ export const legacyBootstrap = Effect.fn("legacy.bootstrap")(function* ( includeSeed: true, includeVault: true, dnsResolver, - projectId: cliSettings.projectId, toml, yes: pushYes, emitStructuredResult: false, diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.integration.test.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.integration.test.ts index 0b530fd85e..52ee894420 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.integration.test.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.integration.test.ts @@ -41,8 +41,6 @@ import { type LegacyPgConnInput, } from "../../shared/legacy-db-connection.service.ts"; import { legacyDebugLoggerLayer } from "../../shared/legacy-debug-logger.layer.ts"; -import { LegacyEdgeRuntimeScript } from "../../shared/legacy-edge-runtime-script.service.ts"; -import { LegacyPgDeltaSslProbe } from "../../shared/legacy-pgdelta-ssl-probe.service.ts"; import { LegacyTemplateService, type LegacyStarterTemplate } from "./bootstrap.templates.ts"; import { legacyBootstrap } from "./bootstrap.handler.ts"; import type { LegacyBootstrapFlags } from "./bootstrap.command.ts"; @@ -212,15 +210,6 @@ function setup(opts: SetupOpts = {}) { }); }), }); - const edgeRuntimeLayer = Layer.succeed(LegacyEdgeRuntimeScript, { - run: () => - Effect.die("edge-runtime not needed: scratch/template fixtures never push migrations"), - }); - const sslProbeLayer = Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.die("pg-delta ssl probe not needed for this test"), - requireSslForHost: () => Effect.die("pg-delta ssl probe not needed for this test"), - }); - const loginApi = mockLegacyLoginApi({ gotrueId: "gotrue-user" }); const loginCrypto = mockLegacyLoginCrypto(); @@ -241,8 +230,6 @@ function setup(opts: SetupOpts = {}) { credentials.layer, templateLayer, dbConnectionLayer, - edgeRuntimeLayer, - sslProbeLayer, loginApi.layer, loginCrypto.layer, mockBrowser(), diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.layers.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.layers.ts index 2444442e20..4c00520563 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.layers.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.layers.ts @@ -8,10 +8,7 @@ import { legacyCliSettingsLayer } from "../../config/legacy-cli-settings.layer.t import { legacyProjectRefLayer } from "../../config/legacy-project-ref.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 { legacyEdgeRuntimeScriptLayer } from "../../shared/legacy-edge-runtime-script.layer.ts"; import { legacyIdentityStitchLayer } from "../../shared/legacy-identity-stitch.ts"; -import { legacyPgDeltaSslProbeLayer } from "../../shared/legacy-pgdelta-ssl-probe.layer.ts"; import { legacyLinkedProjectCacheLayer } from "../../telemetry/legacy-linked-project-cache.layer.ts"; import { legacyTelemetryStateLayer } from "../../telemetry/legacy-telemetry-state.layer.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; @@ -50,14 +47,6 @@ const platformApi = legacyPlatformApiLayer.pipe( Layer.provide(legacyIdentityStitchLayer), ); const platformApiFactory = legacyPlatformApiFactoryFromApiLayer.pipe(Layer.provide(platformApi)); -// `legacyDbPushCore` (the native push step, CLI-1953) needs a Postgres connection -// and the edge-runtime/pg-delta stack for its best-effort migrations-catalog cache -// — same sub-layers `db push` itself composes (`push.layers.ts`), reusing this -// file's own `cliSettings` reference rather than a second parallel one. -const edgeRuntime = legacyEdgeRuntimeScriptLayer.pipe( - Layer.provide(legacyDockerRunLayer), - Layer.provide(cliSettings), -); export const legacyBootstrapRuntimeLayer = Layer.mergeAll( platformApi, @@ -74,10 +63,7 @@ export const legacyBootstrapRuntimeLayer = Layer.mergeAll( ), legacyTelemetryStateLayer, legacyDbConnectionLayer, - legacyDockerRunLayer, - edgeRuntime, - legacyPgDeltaSslProbeLayer, - // Exposed bare (not just used to feed sibling sub-layers, as elsewhere in this + // Exposed bare (not just used to feed sibling sub-layers, as elsewhere in this) // file) because `bootstrap.handler.ts` now calls `legacyResolveLinkedConn` // (CLI-1953's IPv4-pooler-fallback push connection) directly, which reads it. debugLogger, diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts index 2a10631188..dce7a074be 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts @@ -41,9 +41,7 @@ import { type LegacyPgConnInput, } from "../../shared/legacy-db-connection.service.ts"; import { legacyDebugLoggerLayer } from "../../shared/legacy-debug-logger.layer.ts"; -import { LegacyEdgeRuntimeScript } from "../../shared/legacy-edge-runtime-script.service.ts"; import { legacyIdentityStitchLayer } from "../../shared/legacy-identity-stitch.ts"; -import { LegacyPgDeltaSslProbe } from "../../shared/legacy-pgdelta-ssl-probe.service.ts"; import { legacyCliSettingsLayer } from "../../config/legacy-cli-settings.layer.ts"; import { legacyLinkedProjectCacheLayer } from "../../telemetry/legacy-linked-project-cache.layer.ts"; import { LegacyTemplateService } from "./bootstrap.templates.ts"; @@ -152,12 +150,7 @@ describe("legacy bootstrap linked-project cache location", () => { // Native push (CLI-1953): `legacyDbPushCore` needs a `LegacyDbConnection` — // tracked here so the test can assert it targets the created project's ref, - // not a divergent one. The pre-seeded migration below (proving the migrations - // lookup is scoped to the bootstrap workdir) makes the scratch config.toml's - // default `[experimental.pgdelta] enabled = true` actually reach the - // migrations-catalog cache path, so `LegacyEdgeRuntimeScript`/ - // `LegacyPgDeltaSslProbe` need real (if trivial) fakes here — not the - // `Effect.die` stubs the no-migrations happy-path tests use. + // not a divergent one. const pushConnectCalls: Array = []; const dbConnectionLayer = Layer.succeed(LegacyDbConnection, { connect: (conn: LegacyPgConnInput) => @@ -173,13 +166,6 @@ describe("legacy bootstrap linked-project cache location", () => { }; }), }); - const edgeRuntimeLayer = Layer.succeed(LegacyEdgeRuntimeScript, { - run: () => Effect.succeed({ stdout: '{"version":1}', stderr: "" }), - }); - const sslProbeLayer = Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }); const templateLayer = Layer.succeed(LegacyTemplateService, { listSamples: Effect.succeed([]), download: () => Effect.void, @@ -242,8 +228,6 @@ describe("legacy bootstrap linked-project cache location", () => { mockAnalytics().layer, templateLayer, dbConnectionLayer, - edgeRuntimeLayer, - sslProbeLayer, mockLegacyLoginApi({ gotrueId: "gotrue-user" }).layer, mockLegacyLoginCrypto().layer, mockBrowser(), 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 4db5bbd198..fae10e297b 100644 --- a/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md @@ -177,9 +177,10 @@ transaction metadata. ## Notes / Delegation -- `--use-migra` (default), `--use-pgadmin`, `--use-pg-schema`, `--use-pg-delta` are a - mutually-exclusive engine group; `--db-url` / `--linked` / `--local` are a - mutually-exclusive target group (default `--local`). +- `--use-migra`, `--use-pgadmin`, `--use-pg-schema`, `--use-pg-delta` are a + mutually-exclusive engine group (pg-delta is the default when none is passed); + `--db-url` / `--linked` / `--local` are a mutually-exclusive target group + (default `--local`). - **`--project-ref`** (TS-only, no Go equivalent on any user-facing `db` command) overrides ONLY the linked-ref resolution `LegacyProjectRefResolver` performs (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`) — unlike diff --git a/apps/cli/src/legacy/commands/db/diff/diff.command.ts b/apps/cli/src/legacy/commands/db/diff/diff.command.ts index 0449450aa1..4e49916069 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.command.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.command.ts @@ -8,10 +8,9 @@ import { legacyDbDiff } from "./diff.handler.ts"; import { legacyDbDiffRuntimeLayer } from "./diff.layers.ts"; const config = { - // The four engine flags are a mutually-exclusive group, and `--use-migra` - // defaults to true, so they are modelled as `Option` to track whether the flag - // was passed: the mutex check and `resolveDiffEngine`'s `useMigraChanged` key - // off whether the flag was passed, not its value. + // The four engine flags are a mutually-exclusive group, modelled as `Option` + // so the mutex check and `resolveDiffEngine`'s `useMigraChanged` key off + // whether the flag was passed, not its value. useMigra: Flag.boolean("use-migra").pipe( Flag.withDescription("Use migra to generate schema diff."), Flag.optional, diff --git a/apps/cli/src/legacy/commands/db/push/push.handler.ts b/apps/cli/src/legacy/commands/db/push/push.handler.ts index 79d638ebbd..5710299cdd 100644 --- a/apps/cli/src/legacy/commands/db/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/db/push/push.handler.ts @@ -127,7 +127,6 @@ export const legacyDbPush = Effect.fn("legacy.db.push")(function* (flags: Legacy includeSeed: flags.includeSeed, includeVault: !flags.skipVault, dnsResolver, - projectId: cliSettings.projectId, toml, yes, emitStructuredResult: true, diff --git a/apps/cli/src/legacy/commands/db/push/push.integration.test.ts b/apps/cli/src/legacy/commands/db/push/push.integration.test.ts index 729b5293d7..42d12ea942 100644 --- a/apps/cli/src/legacy/commands/db/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/push/push.integration.test.ts @@ -31,8 +31,6 @@ import { type LegacyPgConnInput, type LegacyDbSession, } from "../../../shared/legacy-db-connection.service.ts"; -import { LegacyEdgeRuntimeScript } from "../../../shared/legacy-edge-runtime-script.service.ts"; -import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; import { legacyDbPush } from "./push.handler.ts"; import type { LegacyDbPushFlags } from "./push.command.ts"; @@ -203,13 +201,6 @@ function setup( const telemetry = mockLegacyTelemetryStateTracked(); const linkedCache = mockLegacyLinkedProjectCacheTracked(); - const edge = Layer.succeed(LegacyEdgeRuntimeScript, { - run: () => Effect.succeed({ stdout: '{"version":1}', stderr: "" }), - }); - const sslProbe = Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }); const projectRefLayer = Layer.succeed(LegacyProjectRefResolver, { resolve: () => Effect.succeed(opts.projectRef ?? LEGACY_VALID_REF), resolveForLink: () => Effect.succeed(opts.projectRef ?? LEGACY_VALID_REF), @@ -259,8 +250,6 @@ function setup( projectRefLayer, telemetry.layer, linkedCache.layer, - edge, - sslProbe, ); return { layer, diff --git a/apps/cli/src/legacy/commands/db/push/push.layers.ts b/apps/cli/src/legacy/commands/db/push/push.layers.ts index 9109469f18..22dab552c4 100644 --- a/apps/cli/src/legacy/commands/db/push/push.layers.ts +++ b/apps/cli/src/legacy/commands/db/push/push.layers.ts @@ -9,11 +9,8 @@ import { legacyProjectRefLayer } from "../../../config/legacy-project-ref.layer. 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 { legacyEdgeRuntimeScriptLayer } from "../../../shared/legacy-edge-runtime-script.layer.ts"; import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; import { legacyIdentityStitchLayer } from "../../../shared/legacy-identity-stitch.ts"; -import { legacyPgDeltaSslProbeLayer } from "../../../shared/legacy-pgdelta-ssl-probe.layer.ts"; import { legacyLinkedProjectCacheLayer } from "../../../telemetry/legacy-linked-project-cache.layer.ts"; import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; @@ -63,17 +60,9 @@ const dbConfig = legacyDbConfigLayer.pipe( Layer.provide(legacyIdentityStitchLayer), ); -const edgeRuntime = legacyEdgeRuntimeScriptLayer.pipe( - Layer.provide(legacyDockerRunLayer), - Layer.provide(cliSettings), -); - export const legacyDbPushRuntimeLayer = Layer.mergeAll( dbConfig, legacyDbConnectionLayer, - legacyDockerRunLayer, - edgeRuntime, - legacyPgDeltaSslProbeLayer, cliSettings, httpClient, credentials, diff --git a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts index db0b1fd91d..9c6bc9e389 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts @@ -103,7 +103,7 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega // `process.env` for the container image resolution below (review CLI-1958). `db push` // (`push.handler.ts`) scopes this the same way, as the first statement of its own // `body` — mirror that exactly so a private/air-gapped registry configured only in - // `supabase/.env` reaches the catalog export instead of silently falling back to the + // `supabase/.env` reaches image resolution instead of silently falling back to the // default registries. yield* legacyApplyProjectEnv(projectEnv); const target = resolveLegacyDbTargetFlags(cliArgs.args); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts index 9c12686cdd..79e7c57eb8 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts @@ -41,8 +41,6 @@ import { } from "../../../../shared/legacy/global-flags.ts"; import type { OutputFormat } from "../../../../shared/output/types.ts"; import { legacyDockerRunLayer } from "../../../shared/legacy-docker-run.layer.ts"; -import { LegacyEdgeRuntimeScript } from "../../../shared/legacy-edge-runtime-script.service.ts"; -import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import type { LegacyDbConfigFlags, @@ -467,14 +465,6 @@ function setup( }); const route = opts.route ?? defaultLocalResetRoute(opts.routeOpts); const child = mockContainerCliSpawner(route); - const edgeRuntime = Layer.succeed(LegacyEdgeRuntimeScript, { - run: () => Effect.succeed({ stdout: '{"version":1}', stderr: "" }), - }); - const pgDeltaSslProbe = Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }); - const layer = Layer.mergeAll( out.layer, conn.layer, @@ -489,8 +479,6 @@ function setup( Layer.provide(child.layer), Layer.provide(mockProcessControl().layer), ), - edgeRuntime, - pgDeltaSslProbe, Layer.succeed(LegacyNetworkIdFlag, Option.none()), // The remote-reset confirmation is answered through mockOutput's // `promptConfirmResponses` (the TTY/clack path), so mark stdin a TTY. Stdin is diff --git a/apps/cli/src/legacy/commands/db/reset/reset.layers.ts b/apps/cli/src/legacy/commands/db/reset/reset.layers.ts index e9bbe28294..828e9d6786 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.layers.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.layers.ts @@ -10,8 +10,6 @@ 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 { legacyEdgeRuntimeScriptLayer } from "../../../shared/legacy-edge-runtime-script.layer.ts"; -import { legacyPgDeltaSslProbeLayer } from "../../../shared/legacy-pgdelta-ssl-probe.layer.ts"; import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; import { legacyIdentityStitchLayer } from "../../../shared/legacy-identity-stitch.ts"; import { legacyLinkedProjectCacheLayer } from "../../../telemetry/legacy-linked-project-cache.layer.ts"; @@ -29,24 +27,11 @@ import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-s * `legacyDockerRunLayer` backs the native local recreate's PG15+ one-shot migrate * jobs (`legacyStartSetupLocalDatabase`, reused via `legacyRecreateLocalDatabase`) * — same reasoning as `db start`'s own `start.layers.ts`. - * `legacyEdgeRuntimeScriptLayer`/`legacyPgDeltaSslProbeLayer` back that same shared - * setup pipeline's best-effort pg-delta migrations-catalog warmup (`db-setup.ts`'s - * `legacyTryCacheMigrationsCatalog` call, reachable from `db reset`'s PG15 recreate - * too) AND the remote path's own post-reset catalog-cache call — the exact same - * pair `db start`/`db push` already compose for their own calls to that function - * (`db/start/start.layers.ts`, `push.layers.ts`). Without them, a versionless reset - * with pg-delta enabled would hit an unhandled missing-service defect — not caught - * by the handler's typed `Effect.catch` — AFTER the database has already been - * reset, instead of writing the catalog or emitting the established best-effort - * warning (review CLI-1958). `LegacyCliSettings`/`ChildProcessSpawner`/`FileSystem`/`Path`/ - * `RuntimeInfo` are ambient from the root runtime (`shared/cli/run.ts`). + * `LegacyCliSettings`/`ChildProcessSpawner`/`FileSystem`/`Path`/`RuntimeInfo` are + * ambient from the root runtime (`shared/cli/run.ts`). */ const cliSettings = legacyCliSettingsLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); -const edgeRuntime = legacyEdgeRuntimeScriptLayer.pipe( - Layer.provide(legacyDockerRunLayer), - Layer.provide(cliSettings), -); const credentials = legacyCredentialsLayer.pipe( Layer.provide(cliSettings), Layer.provide(legacyDebugLoggerLayer), @@ -97,10 +82,7 @@ export const legacyDbResetRuntimeLayer = Layer.mergeAll( // without it a CI/piped remote `db reset` that reaches the confirmation // prompt fails with a missing-service defect instead of the default. stdinLayer, - // Backs the native local recreate's PG15+ one-shot migrate jobs, and the remote - // path's own post-reset pg-delta catalog-cache call. + // Backs the native local recreate's PG15+ one-shot migrate jobs. legacyDockerRunLayer, - edgeRuntime, - legacyPgDeltaSslProbeLayer, commandRuntimeLayer(["db", "reset"]), ); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.layers.unit.test.ts b/apps/cli/src/legacy/commands/db/reset/reset.layers.unit.test.ts deleted file mode 100644 index dede20a3e9..0000000000 --- a/apps/cli/src/legacy/commands/db/reset/reset.layers.unit.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Layer-exposure test for `legacyDbResetRuntimeLayer`. - * - * Regression guard (review CLI-1958): reset code paths can reach - * `LegacyEdgeRuntimeScript` and `LegacyPgDeltaSslProbe` (today through the shared - * pg-delta command runtime backing the migra fallback). `legacyDbResetRuntimeLayer` - * previously omitted both services (and the `LegacyDockerRun` layer the real - * edge-runtime implementation needs) — unlike `legacyDbPushRuntimeLayer`, which - * already composes all three. That gap was invisible to `reset.integration.test.ts` - * because that suite drives `legacyDbReset` directly with its own hand-built layer - * (which mocks `LegacyEdgeRuntimeScript`/`LegacyPgDeltaSslProbe` in), bypassing - * `reset.layers.ts` entirely — so a versionless remote reset with pg-delta enabled - * would crash on a missing-service defect (uncaught by the handler's typed - * `Effect.catch`) AFTER the remote database was already reset. This test builds - * the REAL `legacyDbResetRuntimeLayer` (not a mock of the pg-delta services) and - * asserts both are actually present in its context. - * - * See `db/lint/lint.layers.unit.test.ts` for the canonical ambient-stub pattern. - */ - -import { describe, expect, it } from "@effect/vitest"; -import { BunServices } from "@effect/platform-bun"; -import { Effect, Layer, Option } from "effect"; - -import { - mockAnalytics, - mockOutput, - mockProcessControl, - mockRuntimeInfo, - mockStdin, - mockTelemetryRuntime, - mockTty, -} from "../../../../../tests/helpers/mocks.ts"; -import { - mockLegacyCliSettings, - mockLegacyCredentialsLayer, - mockLegacyLinkedProjectCacheLayer, - mockLegacyTelemetryStateLayer, -} from "../../../../../tests/helpers/legacy-mocks.ts"; - -import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; -import { - LegacyDebugFlag, - LegacyDnsResolverFlag, - LegacyExperimentalFlag, - LegacyNetworkIdFlag, - LegacyOutputFlag, - LegacyProfileFlag, - LegacyWorkdirFlag, -} from "../../../../shared/legacy/global-flags.ts"; - -import { LegacyPlatformApiFactory } from "../../../auth/legacy-platform-api-factory.service.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 { LegacyEdgeRuntimeScript } from "../../../shared/legacy-edge-runtime-script.service.ts"; -import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; - -import { legacyDbResetRuntimeLayer } from "./reset.layers.ts"; - -/** - * Builds a stub ambient layer that satisfies every external service required by - * `legacyDbResetRuntimeLayer` from the root runtime. Services whose logic is not - * under test are no-op stubs; `LegacyEdgeRuntimeScript` and `LegacyPgDeltaSslProbe` - * are deliberately NOT stubbed here — the point of this test is to prove the real - * `legacyDbResetRuntimeLayer` provides them itself. - */ -function ambientStubs() { - const analytics = mockAnalytics(); - const out = mockOutput(); - - const flagLayers = Layer.mergeAll( - Layer.succeed(LegacyDebugFlag, false), - Layer.succeed(LegacyProfileFlag, "supabase"), - Layer.succeed(LegacyWorkdirFlag, Option.none()), - Layer.succeed(LegacyOutputFlag, Option.none()), - Layer.succeed(LegacyDnsResolverFlag, "native"), - Layer.succeed(LegacyNetworkIdFlag, Option.none()), - Layer.succeed(LegacyExperimentalFlag, false), - Layer.succeed(CliArgs, { args: ["db", "reset"] }), - ); - - // Stub out the heavy service layers so layer construction doesn't require a - // real DB, real API, or real credentials. - const heavyServiceStubs = Layer.mergeAll( - Layer.succeed(LegacyDbConnection, { - connect: () => Effect.die("db-connection not needed for layer-exposure test"), - }), - Layer.succeed(LegacyDbConfigResolver, { - resolve: () => Effect.die("db-config-resolver not needed for layer-exposure test"), - resolvePoolerFallback: () => - Effect.die("db-config-resolver not needed for layer-exposure test"), - }), - Layer.succeed(LegacyProjectRefResolver, { - resolve: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), - resolveForLink: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), - resolveOptional: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), - loadProjectRef: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), - promptProjectRef: () => Effect.die("project-ref-resolver not needed for layer-exposure test"), - }), - Layer.succeed(LegacyPlatformApiFactory, { - make: Effect.die("platform-api-factory not needed for layer-exposure test"), - }), - ); - - return Layer.mergeAll( - BunServices.layer, - mockRuntimeInfo(), - mockTty(), - mockProcessControl().layer, - mockStdin(false), - analytics.layer, - mockTelemetryRuntime(), - out.layer, - flagLayers, - mockLegacyCliSettings({ workdir: "/tmp/reset-layers-test" }), - mockLegacyCredentialsLayer, - mockLegacyLinkedProjectCacheLayer, - mockLegacyTelemetryStateLayer, - heavyServiceStubs, - ); -} - -describe("legacyDbResetRuntimeLayer — pg-delta service exposure (regression guard, review CLI-1958)", () => { - it.live( - "exposes LegacyEdgeRuntimeScript so the post-reset pg-delta catalog cache does not crash on a missing-service defect", - () => { - return Effect.gen(function* () { - const edgeRuntime = yield* Effect.serviceOption(LegacyEdgeRuntimeScript); - expect(Option.isSome(edgeRuntime)).toBe(true); - }).pipe(Effect.provide(legacyDbResetRuntimeLayer), Effect.provide(ambientStubs())); - }, - ); - - it.live( - "exposes LegacyPgDeltaSslProbe so the post-reset pg-delta catalog cache does not crash on a missing-service defect", - () => { - return Effect.gen(function* () { - const sslProbe = yield* Effect.serviceOption(LegacyPgDeltaSslProbe); - expect(Option.isSome(sslProbe)).toBe(true); - }).pipe(Effect.provide(legacyDbResetRuntimeLayer), Effect.provide(ambientStubs())); - }, - ); -}); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md index 4e13d172b4..9a780104d6 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md @@ -51,14 +51,14 @@ formatting without disabling safe compaction. | Code | Condition | | ---- | --------------------------------------------------------------------- | | `0` | success (files written, or skipped after a declined prompt) | -| `1` | pg-delta not enabled (no `--experimental` / `[experimental.pgdelta]`) | +| `1` | pg-delta disabled (`[experimental.pgdelta] enabled = false` and no `--experimental`) | | `1` | conflicting `--db-url`/`--linked`/`--local` (mutually exclusive) | | `1` | non-interactive mode with no explicit target | | `1` | local-database bring-up / pg-delta engine / export failure | The pg-delta gate and the mutex check are both raised before any side effects run, but the gate wins when both conditions apply simultaneously: the gate check runs -first, so a closed gate (missing `--experimental`) surfaces before a +first, so a closed gate (`enabled = false` and no `--experimental`) surfaces before a `--db-url`/`--linked`/`--local` conflict is ever checked. ## Output @@ -75,7 +75,8 @@ always go to stderr, in every `--output-format`. On success: ## Notes -- Requires `--experimental` or `[experimental.pgdelta] enabled = true`. +- pg-delta is on by default. The gate closes only when + `[experimental.pgdelta] enabled = false` and `--experimental` is omitted. - `--db-url` / `--linked` / `--local` are mutually exclusive; absent all three, smart mode prompts (existing-files overwrite → Local/Custom choice + reset offer). - `--output-dir ` selects a destination for this invocation without changing diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md index 2fd11d15be..95f46f4432 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/SIDE_EFFECTS.md @@ -66,7 +66,7 @@ disabling safe compaction. The pg-delta gate and the mutex check are both raised before any side effects run, but the gate wins when both conditions apply simultaneously: the gate check runs -first, so a closed gate (missing `--experimental`) surfaces before an +first, so a closed gate (`enabled = false` and no `--experimental`) surfaces before an `--apply`/`--no-apply` conflict is ever checked. ## Output @@ -98,7 +98,8 @@ existing SQL or creates an export manifest. ## Notes -- Requires `--experimental` or `[experimental.pgdelta] enabled = true`. +- pg-delta is on by default. The gate closes only when + `[experimental.pgdelta] enabled = false` and `--experimental` is omitted. - `--file` sets the migration filename stem (default `declarative_sync`); `--name` overrides it. In a TTY without `--name`/`--yes`, the name is prompted. - When no declarative files exist, a TTY offers to generate them (from local) first. 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 5266f39dfd..a0f4127cdf 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,15 +14,12 @@ */ 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"; -import type { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import type { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; import { legacyBold } from "../../../shared/legacy-colors.ts"; -import type { LegacyEdgeRuntimeScript } from "../../../shared/legacy-edge-runtime-script.service.ts"; import { LegacyDbConnection, type LegacyPgConnInput, @@ -125,14 +122,6 @@ export const legacyPrepareShadowSource = ( | RuntimeInfo | HttpClient.HttpClient | LegacyDbConnection - | LegacyEdgeRuntimeScript - | GlobalFlag.Setting.Identifier<"debug"> - // `legacyApplyDeclarativePgDelta`'s own `legacyResolveDebugWithProjectEnv` (viper - // `AutomaticEnv` `SUPABASE_DEBUG` fallback, plus the project `.env` Go's `loadNestedEnv` - // has already `os.Setenv`'d into the process by this point, review: PRRT_kwDOErm0O86XDr4V, - // PRRT_kwDOErm0O86XL_oz) needs `CliArgs` to detect an explicit `--debug=false`, same as - // `legacyResolveYes`/`legacyResolveExperimental`. - | CliArgs > => Effect.gen(function* () { const { containerId } = handle; diff --git a/apps/cli/src/legacy/commands/db/start/start.integration.test.ts b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts index a6ad5dc3ac..822e486ab7 100644 --- a/apps/cli/src/legacy/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts @@ -33,8 +33,6 @@ import { type LegacyDbSession, } from "../../../shared/legacy-db-connection.service.ts"; import { legacyDockerRunLayer } from "../../../shared/legacy-docker-run.layer.ts"; -import { LegacyEdgeRuntimeScript } from "../../../shared/legacy-edge-runtime-script.service.ts"; -import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; import { legacyDbStart } from "./start.handler.ts"; import type { LegacyDbStartFlags } from "./start.command.ts"; @@ -302,13 +300,6 @@ function setup(opts: SetupOpts = {}) { : baseRoute; const child = mockContainerCliSpawner(route); const dbSession = fakeDbSession(); - const edgeRuntime = Layer.succeed(LegacyEdgeRuntimeScript, { - run: () => Effect.succeed({ stdout: '{"version":1}', stderr: "" }), - }); - const sslProbe = Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }); let connectAttempts = 0; const connectFailures = opts.connectFailures ?? 0; @@ -350,8 +341,6 @@ function setup(opts: SetupOpts = {}) { Layer.succeed(CliArgs, { args: ["db", "start"] }), Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? false), Layer.succeed(LegacyDebugFlag, opts.debug ?? false), - edgeRuntime, - sslProbe, ); return { layer, diff --git a/apps/cli/src/legacy/commands/db/start/start.layers.ts b/apps/cli/src/legacy/commands/db/start/start.layers.ts index f562d6bea8..6139f0d300 100644 --- a/apps/cli/src/legacy/commands/db/start/start.layers.ts +++ b/apps/cli/src/legacy/commands/db/start/start.layers.ts @@ -6,8 +6,6 @@ import { legacyHttpClientLayer } from "../../../auth/legacy-http-debug.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 { legacyEdgeRuntimeScriptLayer } from "../../../shared/legacy-edge-runtime-script.layer.ts"; -import { legacyPgDeltaSslProbeLayer } from "../../../shared/legacy-pgdelta-ssl-probe.layer.ts"; import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; /** @@ -28,18 +26,9 @@ import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-s * wait (`legacyWaitForHealthyServices`) requires `HttpClient.HttpClient` in its type signature * even though `db start` never uses the PostgREST/Edge-Runtime gateway probes — same reasoning * as `start.command.ts`'s own composition of all three. - * - * `legacyEdgeRuntimeScriptLayer`/`legacyPgDeltaSslProbeLayer` back that same fresh-volume - * pipeline's best-effort pg-delta migrations-catalog warmup (`db-setup.ts`'s - * `legacyTryCacheMigrationsCatalog` call) — the exact same pair `db push` already composes - * for its own call to that function (`push.layers.ts`). */ const cliSettings = legacyCliSettingsLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); -const edgeRuntime = legacyEdgeRuntimeScriptLayer.pipe( - Layer.provide(legacyDockerRunLayer), - Layer.provide(cliSettings), -); export const legacyDbStartRuntimeLayer = Layer.mergeAll( cliSettings, @@ -48,6 +37,4 @@ export const legacyDbStartRuntimeLayer = Layer.mergeAll( legacyDockerRunLayer, legacyDbConnectionLayer, httpClient, - edgeRuntime, - legacyPgDeltaSslProbeLayer, ); diff --git a/apps/cli/src/legacy/commands/start/start.command.ts b/apps/cli/src/legacy/commands/start/start.command.ts index 05aa9ce8ab..78fec40859 100644 --- a/apps/cli/src/legacy/commands/start/start.command.ts +++ b/apps/cli/src/legacy/commands/start/start.command.ts @@ -9,8 +9,6 @@ import { legacyCliSettingsLayer } from "../../config/legacy-cli-settings.layer.t 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 { legacyEdgeRuntimeScriptLayer } from "../../shared/legacy-edge-runtime-script.layer.ts"; -import { legacyPgDeltaSslProbeLayer } from "../../shared/legacy-pgdelta-ssl-probe.layer.ts"; import { legacyStringSliceFlag } from "../../shared/legacy-string-slice-flag.ts"; import { legacyTelemetryStateLayer } from "../../telemetry/legacy-telemetry-state.layer.ts"; import { withLegacyCommandInstrumentation } from "../../telemetry/legacy-command-instrumentation.ts"; @@ -58,16 +56,8 @@ export type LegacyStartFlags = CliCommand.Command.Config.Infer; // `SetupLocalDatabase` equivalent (`start.handler.ts`'s `legacyStartSetupLocalDatabase` // call) needs both: the PG15+ one-shot migrate jobs run through `LegacyDockerRun`, and // the schema/globals/API-privileges SQL runs over a direct `LegacyDbConnection` session. -// `legacyEdgeRuntimeScriptLayer`/`legacyPgDeltaSslProbeLayer` back that same fresh-volume -// pipeline's best-effort pg-delta migrations-catalog warmup (`db-setup.ts`'s -// `legacyTryCacheMigrationsCatalog` call) — the exact same pair `db push` already composes -// for its own call to that function (`push.layers.ts`). const cliSettings = legacyCliSettingsLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); -const edgeRuntime = legacyEdgeRuntimeScriptLayer.pipe( - Layer.provide(legacyDockerRunLayer), - Layer.provide(cliSettings), -); const legacyStartRuntimeLayer = Layer.mergeAll( cliSettings, @@ -76,8 +66,6 @@ const legacyStartRuntimeLayer = Layer.mergeAll( legacyDockerRunLayer, legacyDbConnectionLayer, httpClient, - edgeRuntime, - legacyPgDeltaSslProbeLayer, ); export const legacyStartCommand = Command.make("start", config).pipe( diff --git a/apps/cli/src/legacy/commands/start/start.integration.test.ts b/apps/cli/src/legacy/commands/start/start.integration.test.ts index 972af58f14..e45b3239aa 100644 --- a/apps/cli/src/legacy/commands/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/start/start.integration.test.ts @@ -42,8 +42,6 @@ import { type LegacyDbSession, } from "../../shared/legacy-db-connection.service.ts"; import { legacyDockerRunLayer } from "../../shared/legacy-docker-run.layer.ts"; -import { LegacyEdgeRuntimeScript } from "../../shared/legacy-edge-runtime-script.service.ts"; -import { LegacyPgDeltaSslProbe } from "../../shared/legacy-pgdelta-ssl-probe.service.ts"; import { LEGACY_START_EXCLUDABLE_KEYS } from "./start.exclude.ts"; import type { LegacyStartFlags } from "./start.command.ts"; import { legacyStart } from "./start.handler.ts"; @@ -428,13 +426,6 @@ function setup(opts: SetupOpts = {}) { onSecretCopy: opts.onSecretCopy, }); const dbSession = fakeDbSession(); - const edgeRuntime = Layer.succeed(LegacyEdgeRuntimeScript, { - run: () => Effect.succeed({ stdout: '{"version":1}', stderr: "" }), - }); - const sslProbe = Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }); const layer = Layer.mergeAll( BunServices.layer, @@ -471,8 +462,6 @@ function setup(opts: SetupOpts = {}) { Layer.succeed(LegacyNetworkIdFlag, opts.networkId ?? Option.none()), mockTty({ stdinIsTty: false }), mockStdin(false), - edgeRuntime, - sslProbe, ); return { workdir, out, telemetry, analytics, child, dbSession, layer }; diff --git a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts index f156a4a4fd..8a1e031160 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts @@ -13,12 +13,6 @@ import { LegacyDbExecError } from "../legacy-db-connection.errors.ts"; import { LegacyDbConnection, type LegacyDbSession } from "../legacy-db-connection.service.ts"; import { LegacyDockerRun, type LegacyDockerRunOpts } from "../legacy-docker-run.service.ts"; import { LegacyDockerRunError } from "../legacy-docker-run.errors.ts"; -import { LegacyEdgeRuntimeScriptError } from "../legacy-edge-runtime-script.errors.ts"; -import { - LegacyEdgeRuntimeScript, - type LegacyEdgeRuntimeRunOpts, -} from "../legacy-edge-runtime-script.service.ts"; -import { LegacyPgDeltaSslProbe } from "../legacy-pgdelta-ssl-probe.service.ts"; import { LegacyDbSetupError, legacyResolveDbSetupPrelude, @@ -164,35 +158,6 @@ function mockDockerRunFails() { return { layer }; } -/** - * `LegacyEdgeRuntimeScript`/`LegacyPgDeltaSslProbe` back - * `legacyTryCacheMigrationsCatalog`'s own pg-delta catalog-export call (`db-setup.ts`'s - * pgcache-warmup step) — required by {@link legacyStartSetupLocalDatabase}'s own widened - * effect environment regardless of whether a given test's config actually enables - * pg-delta (the early `!params.enabled` return means these mocks are never invoked at - * runtime unless a test opts in via `writeConfigToml`'s `[experimental.pgdelta]`). - */ -function mockEdgeRuntime(opts: { readonly stdout?: string; readonly failWith?: string } = {}) { - const calls: Array = []; - const layer = Layer.succeed(LegacyEdgeRuntimeScript, { - run: (runOpts: LegacyEdgeRuntimeRunOpts) => { - calls.push(runOpts); - if (opts.failWith !== undefined) { - return Effect.fail(new LegacyEdgeRuntimeScriptError({ message: opts.failWith })); - } - return Effect.succeed({ stdout: opts.stdout ?? '{"version":1}', stderr: "" }); - }, - }); - return { layer, calls }; -} - -function mockPgDeltaSslProbeLayer() { - return Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }); -} - function makeWorkdir(): string { return mkdtempSync(join(tmpdir(), "legacy-db-setup-")); } @@ -244,7 +209,6 @@ const run = ( input: Omit, out: ReturnType, docker: ReturnType | ReturnType, - edgeRuntime: ReturnType = mockEdgeRuntime(), ) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -261,8 +225,6 @@ const run = ( out.layer, docker.layer, mockRuntimeInfo({ platform: "darwin" }), - edgeRuntime.layer, - mockPgDeltaSslProbeLayer(), ), ), ); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.ts index d4f74fcd3d..20be57dbcf 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.ts @@ -69,13 +69,6 @@ * NEVER called (Go's `resetDatabase`/`resetDatabase14`/`resetDatabase15` never * call it), and no rollback on failure (Go's `cmd/db.go` only wraps `--mode * start` in a `DockerRemoveAll` cleanup — the recreate dispatch has none). - * - * `pgcache.TryCacheMigrationsCatalog`'s best-effort catalog warmup (part of Go's - * `SetupLocalDatabase`, reachable from the PG15 path above via - * `legacyStartSetupLocalDatabase`) IS reached here too — see `db-setup.ts`'s own - * header for the exact gate/citations. `reset.layers.ts` composes - * `legacyEdgeRuntimeScriptLayer`/`legacyPgDeltaSslProbeLayer` for it, matching - * `db start`'s own layer composition (`db/start/start.layers.ts`). */ import { Data, Effect, Result, Schedule, type FileSystem, type Path } from "effect"; diff --git a/apps/cli/src/legacy/shared/legacy-db-push-core.ts b/apps/cli/src/legacy/shared/legacy-db-push-core.ts index aea0b1dbd2..9c638be0a5 100644 --- a/apps/cli/src/legacy/shared/legacy-db-push-core.ts +++ b/apps/cli/src/legacy/shared/legacy-db-push-core.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Option, Path } from "effect"; +import { Effect, FileSystem, Path } from "effect"; import { legacyPromptYesNo } from "../../shared/legacy/legacy-prompt-yes-no.ts"; import { CONTEXT_CANCELED_MESSAGE } from "../../shared/output/errors.ts"; @@ -99,33 +99,6 @@ export interface LegacyDbPushCoreInput { readonly includeSeed: boolean; readonly includeVault: boolean; readonly dnsResolver: "native" | "https"; - /** - * `LegacyCliSettings.projectId` (`SUPABASE_PROJECT_ID` env override only) — the - * top precedence tier of the pg-delta Docker-volume id. Combined internally - * with `toml.projectId`, `projectRef`, and a workdir-basename default via - * {@link legacyResolveLocalProjectId}, mirroring `Config.ProjectId` - * resolution: env override → config.toml `project_id` → `flags.ProjectRef` - * (when non-empty) → workdir basename. That third tier comes from - * `flags.LoadConfig` seeding - * `utils.Config.ProjectId = ProjectRef` *before* `Config.Load` runs, so on - * the linked path (default `db push`, and bootstrap — both resolve - * `ProjectRef` before loading config) a config.toml that omits `project_id` - * (e.g. a downloaded bootstrap template's own file) keeps the linked ref - * rather than falling to the workdir basename; only `--local`/`--db-url` - * (where Go never seeds `ProjectRef`) fall straight to the basename. - * Passing this env-only tier straight through as the id (as bootstrap's own - * `config.toml` is scaffolded fresh mid-handler, after `LegacyCliSettings` was - * already built) would bind the pg-delta edge-runtime cache volume to the - * generic `supabase_edge_runtime_` name shared by every unrelated project. - * The resolved id is sanitized ({@link legacySanitizeProjectId}) before it - * reaches {@link LegacyPgDeltaContext.projectId} — `Config.Validate` - * rewrites `Config.ProjectId` to its - * sanitized form once at config-load time, so every later reader (including - * `EdgeRuntimeId`) sees the already-sanitized value; an unsanitized - * `project_id` (e.g. `"my app"` from a downloaded bootstrap template) would - * otherwise reach the Docker volume name unescaped. - */ - readonly projectId: Option.Option; /** Already loaded + validated `config.toml`, e.g. via `legacyCheckDbToml`. */ readonly toml: LegacyDbTomlValues; /** Already resolved confirm-prompt default, e.g. via `legacyResolveYesWithProjectEnv`. */ diff --git a/apps/cli/src/legacy/shared/legacy-diff-engine.ts b/apps/cli/src/legacy/shared/legacy-diff-engine.ts index 28f4c21ecf..e698430502 100644 --- a/apps/cli/src/legacy/shared/legacy-diff-engine.ts +++ b/apps/cli/src/legacy/shared/legacy-diff-engine.ts @@ -23,7 +23,7 @@ export function legacyShouldUsePgDelta(inputs: { * Reports whether `db diff` should run in pg-delta mode. Mirrors Go's * `resolveDiffEngine`: an explicit `--use-migra`, * `--use-pgadmin`, or `--use-pg-schema` is an authoritative rollback that clears - * pg-delta mode; `--use-migra` defaults to true so only an explicit pass + * pg-delta mode. `--use-migra` is off unless passed, so only an explicit pass * (`useMigraChanged`) counts as opting out. */ export function legacyResolveDiffEngine(inputs: { diff --git a/apps/cli/src/legacy/shared/legacy-migration-list.ts b/apps/cli/src/legacy/shared/legacy-migration-list.ts index 26d160d762..d92a6890e5 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-list.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-list.ts @@ -50,7 +50,7 @@ export const legacyListLocalMigrations = Effect.fnUntraced(function* ( // order for a supplementary-plane filename character alongside a BMP private-use one (see // {@link legacyCompareUtf8Bytes}'s own doc comment). Left uncorrected, such a migrations // directory would replay in a different order than previous releases, and a dependent - // migration could fail or produce a different shadow schema (review: PRRT_kwDOErm0O86W3OyD). + // migration could fail or produce a different shadow schema. const sorted = [...names].sort(legacyCompareUtf8Bytes); const result: Array = []; for (let index = 0; index < sorted.length; index++) { diff --git a/apps/cli/src/shared/init/project-init.templates.unit.test.ts b/apps/cli/src/shared/init/project-init.templates.unit.test.ts index 8e7252be4d..4350ea5754 100644 --- a/apps/cli/src/shared/init/project-init.templates.unit.test.ts +++ b/apps/cli/src/shared/init/project-init.templates.unit.test.ts @@ -45,9 +45,6 @@ function renderExpectedGoEject(): string { resolveGoTemplateEscapes(readGoTemplate("pkg", "config", "templates", "config.toml")) .replace("{{ .ProjectId }}", "demo-project") .replace("{{ .Experimental.OrioleDBVersion }}", "15.1.0.150") - // supabase init always opts new projects into pg-delta; the Go template renders - // this from a flag set only on the init path (false when deriving defaults). - .replace("{{ .Experimental.PgDeltaInitEnabled }}", "true") ); } From d71f5dd97e5a0484e36fde08fba497d2ef34a9d1 Mon Sep 17 00:00:00 2001 From: avallete Date: Mon, 31 Aug 2026 12:11:28 +0200 Subject: [PATCH 13/17] chore(cli): drop leftover --use-migra docs default and warmup comments The published spec still advertised migra as on by default after the help-text flip. Co-authored-by: Cursor --- apps/cli/src/legacy/commands/bootstrap/bootstrap.layers.ts | 2 +- apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts | 2 -- apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts | 1 - 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.layers.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.layers.ts index 4c00520563..1adb24dddc 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.layers.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.layers.ts @@ -63,7 +63,7 @@ export const legacyBootstrapRuntimeLayer = Layer.mergeAll( ), legacyTelemetryStateLayer, legacyDbConnectionLayer, - // Exposed bare (not just used to feed sibling sub-layers, as elsewhere in this) + // Exposed bare (not just used to feed sibling sub-layers, as elsewhere in this // file) because `bootstrap.handler.ts` now calls `legacyResolveLinkedConn` // (CLI-1953's IPv4-pooler-fallback push connection) directly, which reads it. debugLogger, diff --git a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts index 79e7c57eb8..4d20e1c30d 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts @@ -432,8 +432,6 @@ function setup( replicationSlotCounts?: ReadonlyArray; replicationSlotQueryFails?: boolean; failStatement?: { readonly sql: string; readonly code?: string; readonly message: string }; - // pg-delta migrations-catalog cache, wired into the remote-reset path - // after a successful migrate/schema-files + seed. // Simulates a genuinely unlinked workdir: `loadProjectRef` fails with // `LegacyProjectNotLinkedError` absent an explicit `--project-ref` flag, // instead of silently falling back to `opts.ref ?? LEGACY_VALID_REF`. diff --git a/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts b/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts index 124f2423fa..b398f443f5 100644 --- a/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts +++ b/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts @@ -145,7 +145,6 @@ export const LEGACY_DOCS_DEFAULT_OVERRIDES: Readonly> = { "supabase-db-advisors local": "true", "supabase-db-advisors type": "all", "supabase-db-diff local": "true", - "supabase-db-diff use-migra": "true", "supabase-db-dump linked": "true", "supabase-db-lint fail-on": "none", "supabase-db-lint level": "warning", From 7797c01e71f9caac74421ce7e40348f179b21df4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 10:12:46 +0000 Subject: [PATCH 14/17] chore(cli): fix formatting after warmup-wiring cleanup Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BQAoY9JF4uiZRvKVkSd9Wy --- .../db/schema/declarative/generate/SIDE_EFFECTS.md | 12 ++++++------ .../commands/db/shared/legacy-shadow-source.ts | 6 +----- .../shared/init/project-init.templates.unit.test.ts | 8 +++----- 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md index 9a780104d6..91c1d0348e 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/SIDE_EFFECTS.md @@ -48,13 +48,13 @@ formatting without disabling safe compaction. ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------- | -| `0` | success (files written, or skipped after a declined prompt) | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------ | +| `0` | success (files written, or skipped after a declined prompt) | | `1` | pg-delta disabled (`[experimental.pgdelta] enabled = false` and no `--experimental`) | -| `1` | conflicting `--db-url`/`--linked`/`--local` (mutually exclusive) | -| `1` | non-interactive mode with no explicit target | -| `1` | local-database bring-up / pg-delta engine / export failure | +| `1` | conflicting `--db-url`/`--linked`/`--local` (mutually exclusive) | +| `1` | non-interactive mode with no explicit target | +| `1` | local-database bring-up / pg-delta engine / export failure | The pg-delta gate and the mutex check are both raised before any side effects run, but the gate wins when both conditions apply simultaneously: the gate check runs 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 a0f4127cdf..ff9cfd8717 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 @@ -117,11 +117,7 @@ export const legacyPrepareShadowSource = ( ): Effect.Effect< LegacyShadowSourceResult, LegacyPrepareShadowSourceError | E, - | Output - | LegacyDockerRun - | RuntimeInfo - | HttpClient.HttpClient - | LegacyDbConnection + Output | LegacyDockerRun | RuntimeInfo | HttpClient.HttpClient | LegacyDbConnection > => Effect.gen(function* () { const { containerId } = handle; diff --git a/apps/cli/src/shared/init/project-init.templates.unit.test.ts b/apps/cli/src/shared/init/project-init.templates.unit.test.ts index 4350ea5754..371b90da91 100644 --- a/apps/cli/src/shared/init/project-init.templates.unit.test.ts +++ b/apps/cli/src/shared/init/project-init.templates.unit.test.ts @@ -41,11 +41,9 @@ function resolveGoTemplateEscapes(template: string): string { // Emulates what Go's config.Eject writes to disk for a fresh `supabase init` project. function renderExpectedGoEject(): string { - return ( - resolveGoTemplateEscapes(readGoTemplate("pkg", "config", "templates", "config.toml")) - .replace("{{ .ProjectId }}", "demo-project") - .replace("{{ .Experimental.OrioleDBVersion }}", "15.1.0.150") - ); + return resolveGoTemplateEscapes(readGoTemplate("pkg", "config", "templates", "config.toml")) + .replace("{{ .ProjectId }}", "demo-project") + .replace("{{ .Experimental.OrioleDBVersion }}", "15.1.0.150"); } // The residual Go scaffold still describes `auto_expose_new_tables` as unset-means-revoked and From a10eda15e858dcf0e8266a2fdff68e687f51541b Mon Sep 17 00:00:00 2001 From: avallete Date: Mon, 31 Aug 2026 12:25:27 +0200 Subject: [PATCH 15/17] chore(cli): stop advertising migra and opt-in gates as the published defaults Help and overlays still described the pre-flip engine model after pg-delta became the default. Co-authored-by: Cursor --- apps/cli-go/cmd/db.go | 6 +++--- apps/cli/docs/supabase/db/schema-declarative-generate.md | 2 +- apps/cli/docs/supabase/db/schema-declarative-sync.md | 2 +- .../src/legacy/commands/db/diff/diff.integration.test.ts | 2 +- apps/cli/src/legacy/commands/db/pull/pull.handler.ts | 2 +- .../commands/db/shared/legacy-migra.deno-templates.ts | 4 ++-- apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts | 2 +- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/cli-go/cmd/db.go b/apps/cli-go/cmd/db.go index 2aa213e5b0..cc80bf2067 100644 --- a/apps/cli-go/cmd/db.go +++ b/apps/cli-go/cmd/db.go @@ -77,7 +77,7 @@ var ( useDeclarative bool pullDiffEngine = utils.EnumFlag{ Allowed: []string{"migra", "pg-delta"}, - Value: "migra", + Value: "pg-delta", } diffFrom string diffTo string @@ -199,7 +199,7 @@ func resolveDiffEngine(useMigraChanged, usePgAdmin, usePgSchema, pgDeltaDefault // resolvePullDiffEngine selects whether migration-style db pull uses pg-delta for the // shadow diff step. An explicit --diff-engine flag always wins, so --diff-engine migra is // an authoritative rollback even when pg-delta is enabled in config; otherwise the default -// follows whether pg-delta is the active engine (config / env). +// follows whether pg-delta is the active engine. func resolvePullDiffEngine(engineFlagChanged bool, engine string, pgDeltaDefault bool) bool { if engineFlagChanged { return engine == "pg-delta" @@ -237,7 +237,7 @@ func init() { // schema files exported through pg-delta. --use-pg-delta is the deprecated alias. pullFlags.BoolVar(&useDeclarative, "declarative", false, "Pull schema as declarative files using pg-delta instead of creating a migration.") pullFlags.BoolVar(&useDeclarative, "use-pg-delta", false, "Use pg-delta to pull declarative schema.") - cobra.CheckErr(pullFlags.MarkDeprecated("use-pg-delta", "use --declarative with [experimental.pgdelta] enabled = true in your config.toml instead.")) + cobra.CheckErr(pullFlags.MarkDeprecated("use-pg-delta", "use --declarative instead.")) pullFlags.Var(&pullDiffEngine, "diff-engine", "Diff engine to use for migration-style db pull.") pullFlags.StringSliceVarP(&schema, "schema", "s", []string{}, "Comma separated list of schema to include.") pullFlags.String("db-url", "", "Pulls from the database specified by the connection string (must be percent-encoded).") diff --git a/apps/cli/docs/supabase/db/schema-declarative-generate.md b/apps/cli/docs/supabase/db/schema-declarative-generate.md index 1cd416e747..9e97bdb7c4 100644 --- a/apps/cli/docs/supabase/db/schema-declarative-generate.md +++ b/apps/cli/docs/supabase/db/schema-declarative-generate.md @@ -8,4 +8,4 @@ The bundled pg-delta engine writes one directory per schema at the root of that Emitted SQL uses the same default format as `db pull` (uppercase keywords, indent 2, width 180, column-aligned). Override with `[experimental.pgdelta] format_options`, or set `format_options = "null"` for raw statements. -Requires `--experimental` flag or `[experimental.pgdelta] enabled = true` in config. +pg-delta is on by default. The command is closed only when `[experimental.pgdelta] enabled = false` and `--experimental` is omitted. diff --git a/apps/cli/docs/supabase/db/schema-declarative-sync.md b/apps/cli/docs/supabase/db/schema-declarative-sync.md index 1932b16f11..58a0dbc163 100644 --- a/apps/cli/docs/supabase/db/schema-declarative-sync.md +++ b/apps/cli/docs/supabase/db/schema-declarative-sync.md @@ -4,4 +4,4 @@ Generate a new migration by diffing your declarative schema files against the cu When no declarative schema exists yet, the command offers to run `generate` first. After computing the diff, you can optionally name the migration and apply it to the local database. -Requires `--experimental` flag or `[experimental.pgdelta] enabled = true` in config. +pg-delta is on by default. The command is closed only when `[experimental.pgdelta] enabled = false` and `--experimental` is omitted. 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 f9248320fd..d7d2067ea7 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 @@ -1346,7 +1346,7 @@ describe("legacy db diff", () => { return Effect.gen(function* () { yield* legacyDbDiff(flags({ usePgSchema: Option.some(true) })); // The TS wrapper prints its own deprecation notice pointing at pg-delta / - // the default migra engine, additive to (not a replacement for) the + // the migra rollback, additive to (not a replacement for) the // delegated Go child's own "experimental" warning (unchanged, printed by // the real Go binary rather than this mocked proxy). Assert on a stable // substring so future wording tweaks don't require touching every test site. 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 f48a05befb..515a8dabcf 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -101,7 +101,7 @@ import { legacyUpdateMigrationHistory } from "./pull.sync.ts"; // Established output contract; ends with a `.`. const DEPRECATION_LINE = - "Flag --use-pg-delta has been deprecated, use --declarative with [experimental.pgdelta] enabled = true in your config.toml instead."; + "Flag --use-pg-delta has been deprecated, use --declarative instead."; /** * Explains the in-sync non-zero exit. Go prints its generic diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-migra.deno-templates.ts b/apps/cli/src/legacy/commands/db/shared/legacy-migra.deno-templates.ts index b765f2dd72..0ae44f1e82 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-migra.deno-templates.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-migra.deno-templates.ts @@ -3,8 +3,8 @@ // equality against the Go sources in `apps/cli-go/internal/db/diff/templates/`. // Do not hand-edit — regenerate from Go. // -// migra is `db diff`'s default engine and the non-pg-delta `db pull` diff -// engine. The `.ts` template runs inside Edge Runtime (`@pgkit/migra` + +// Rollback engine for `db diff --use-migra` and `db pull --diff-engine migra`. +// The `.ts` template runs inside Edge Runtime (`@pgkit/migra` + // `@pgkit/client`); the `.sh` template is the OOM bash fallback executed in the // `supabase/migra` Docker image. diff --git a/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts b/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts index b398f443f5..29391034c6 100644 --- a/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts +++ b/apps/cli/src/legacy/docs/legacy-docs-spec.tables.ts @@ -149,7 +149,7 @@ export const LEGACY_DOCS_DEFAULT_OVERRIDES: Readonly> = { "supabase-db-lint fail-on": "none", "supabase-db-lint level": "warning", "supabase-db-lint local": "true", - "supabase-db-pull diff-engine": "migra", + "supabase-db-pull diff-engine": "pg-delta", "supabase-db-pull linked": "true", "supabase-db-push linked": "true", "supabase-db-query local": "true", From 09ebc69e778cfe4116fbe3097a7141418f21fd3c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 10:28:45 +0000 Subject: [PATCH 16/17] chore(cli): fix formatting in pull handler Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BQAoY9JF4uiZRvKVkSd9Wy --- apps/cli/src/legacy/commands/db/pull/pull.handler.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 515a8dabcf..f43e5673ef 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -100,8 +100,7 @@ import { import { legacyUpdateMigrationHistory } from "./pull.sync.ts"; // Established output contract; ends with a `.`. -const DEPRECATION_LINE = - "Flag --use-pg-delta has been deprecated, use --declarative instead."; +const DEPRECATION_LINE = "Flag --use-pg-delta has been deprecated, use --declarative instead."; /** * Explains the in-sync non-zero exit. Go prints its generic From 1e30d996ec113a13817657cdef664d3f3ace3fd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 10:33:57 +0000 Subject: [PATCH 17/17] chore: sync API types from infrastructure The Codegen check regenerates pkg/api from the live Management API spec, which gained an oauth2 security scheme; apply the generator's output so the check passes until develop carries the upstream sync. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BQAoY9JF4uiZRvKVkSd9Wy --- apps/cli-go/pkg/api/types.gen.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/cli-go/pkg/api/types.gen.go b/apps/cli-go/pkg/api/types.gen.go index 70ed69ee58..0c4bb87df1 100644 --- a/apps/cli-go/pkg/api/types.gen.go +++ b/apps/cli-go/pkg/api/types.gen.go @@ -9012,6 +9012,9 @@ type VanitySubdomainConfigResponseStatus string // bearerContextKey is the context key for bearer security scheme type bearerContextKey string +// oauth2ContextKey is the context key for oauth2 security scheme +type oauth2ContextKey string + // V1DeleteABranchParams defines parameters for V1DeleteABranch. type V1DeleteABranchParams struct { // Force If set to false, schedule deletion with 1-hour grace period (only when soft deletion is enabled).