diff --git a/apps/cli/package.json b/apps/cli/package.json index a3c4aa09e6..29edd7f819 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -55,6 +55,8 @@ "@parcel/watcher": "^2.6.0", "@supabase/api": "workspace:*", "@supabase/config": "workspace:*", + "@supabase/pg-delta": "1.0.0-alpha.34", + "@supabase/pg-topo": "1.0.0-alpha.5", "@supabase/process-compose": "workspace:*", "@supabase/stack": "workspace:*", "@tsconfig/bun": "catalog:", diff --git a/apps/cli/scripts/build-binary.integration.test.ts b/apps/cli/scripts/build-binary.integration.test.ts new file mode 100644 index 0000000000..52dc71ceb3 --- /dev/null +++ b/apps/cli/scripts/build-binary.integration.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const fixturePath = fileURLToPath( + new URL("../tests/fixtures/compiled-libpg-query.ts", import.meta.url), +); +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), + ); +}); + +describe("compiled binary assets", () => { + test("embeds and loads libpg-query.wasm", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "supabase-compiled-wasm-")); + temporaryDirectories.push(directory); + const executable = path.join(directory, "parser-probe"); + const bunExecutable = Bun.which("bun"); + if (!bunExecutable) { + throw new Error("Bun executable not found"); + } + + const build = Bun.spawn( + [bunExecutable, "build", fixturePath, "--compile", `--outfile=${executable}`], + { stdout: "pipe", stderr: "pipe" }, + ); + const [buildExitCode, buildStderr] = await Promise.all([ + build.exited, + new Response(build.stderr).text(), + ]); + expect(buildExitCode, buildStderr).toBe(0); + + const probe = Bun.spawn([executable], { + cwd: directory, + env: {}, + stdout: "pipe", + stderr: "pipe", + }); + const [probeExitCode, stdout, stderr] = await Promise.all([ + probe.exited, + new Response(probe.stdout).text(), + new Response(probe.stderr).text(), + ]); + + expect(probeExitCode, stderr).toBe(0); + expect(stdout).toContain("libpg-query.wasm loaded"); + }, 20_000); +}); diff --git a/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md index e5a7694ba9..a510d61e41 100644 --- a/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/bootstrap/SIDE_EFFECTS.md @@ -6,6 +6,12 @@ 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 embedded push step does not warm pg-delta state under the default bundled +engine: no next-engine consumer uses the legacy catalog. Setting +`SUPABASE_USE_PG_DELTA_NEXT=false` retains Go's edge-runtime catalog warmup. +`PGDELTA_NPM_REGISTRY`, `.temp/pgdelta-version`, and catalogs directly under +`.temp/pgdelta/` are meaningful only for that legacy opt-out. + ## Files Read | Path | Format | When | @@ -20,7 +26,8 @@ command — see Notes). | `/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/edge-runtime-version` | plain text | native push step's migrations-catalog cache (pg-delta), when a pinned edge-runtime image tag exists — resolved against the bootstrap workdir explicitly, not `cliConfig.workdir` (which is stale after this handler's own `process.chdir`) | +| `/supabase/.temp/pgdelta-version` | plain text | always read by push config loading for compatibility; affects the legacy opt-out only | +| `/supabase/.temp/edge-runtime-version` | plain text | legacy opt-out only: push-step catalog warmup image tag, resolved against the bootstrap workdir | ## Files Written @@ -31,8 +38,8 @@ command — see Notes). | `/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 | native push step, best-effort, after a successful migration apply, when pg-delta is enabled (a failure only warns on stderr and never fails the push) | -| `/supabase/.temp/pgdelta/pgdelta-target-ca.crt` | PEM | native push step, same pg-delta gate, when the target requires SSL | +| `/supabase/.temp/pgdelta/catalog--migrations--.json` | JSON | legacy opt-out push step, best-effort after a successful migration apply when pg-delta is enabled; failure only warns | +| `/supabase/.temp/pgdelta/pgdelta-target-ca.crt` | PEM | legacy opt-out catalog export 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 `cliConfig.workdir` | | `~/.supabase/telemetry.json` | JSON | PersistentPostRun telemetry flush (`Effect.ensuring`) | @@ -64,17 +71,18 @@ 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 (Go's viper `YES`), read project-`.env`-aware like the standalone `db push` | no | -| `SUPABASE_EXPERIMENTAL_PG_DELTA` | enables the push step's migrations-catalog cache when `[experimental.pgdelta].enabled` is unset, read project-`.env`-aware (see Files Read) | no | -| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | overrides the push step's pg-delta edge-runtime image registry, read project-`.env`-aware (see Files Read) | no | -| `PGDELTA_NPM_REGISTRY` | overrides the push step's pg-delta edge-runtime npm registry (`.npmrc` + `NPM_CONFIG_REGISTRY` forward), read project-`.env`-aware (see Files Read) | 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 (Go's viper `YES`), read project-`.env`-aware like the standalone `db push` | no | +| `SUPABASE_EXPERIMENTAL_PG_DELTA` | enables the legacy opt-out push cache when `[experimental.pgdelta].enabled` is unset, read project-`.env`-aware | no | +| `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` to retain the legacy push-step catalog warmup, read project-`.env`-aware | no | +| `SUPABASE_INTERNAL_IMAGE_REGISTRY` | legacy opt-out only: overrides the push step's edge-runtime image registry, read project-`.env`-aware | no | +| `PGDELTA_NPM_REGISTRY` | legacy opt-out only: overrides the push-step edge-runtime npm registry, read project-`.env`-aware | 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 c27fae89dd..4d711789ea 100644 --- a/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/diff/SIDE_EFFECTS.md @@ -2,8 +2,35 @@ Native Effect port. Diffs the local project's expected schema (a throwaway shadow database) against a target database (local / linked / `--db-url`), using either -the native pg-delta or migra engine (both run inside Docker via edge-runtime). The -`--use-pgadmin` / `--use-pg-schema` engines delegate to the bundled Go binary. +pg-delta or migra. Pg-delta runs in-process by default; migra still runs in Docker +via edge-runtime. The `--use-pgadmin` / `--use-pg-schema` engines delegate to the +bundled Go binary. + +## Pg-delta implementation and compatibility + +- The default implementation is the in-process pg-delta engine bundled into the + CLI binary together with pg-topo. Its version is fixed when the CLI is built; + there is no runtime package download or automatic fallback to the legacy engine. +- `SUPABASE_USE_PG_DELTA_NEXT=false` selects the legacy edge-runtime implementation + from either the shell or project `supabase/.env` (the shell wins). Only that + opt-out reads legacy catalogs under `supabase/.temp/pgdelta/`, + `supabase/.temp/pgdelta-version`, or `PGDELTA_NPM_REGISTRY`. +- With `PGDELTA_DEBUG`, default-engine snapshots, plans, and diagnostics are written + under `supabase/.temp/pgdelta/v2/debug//`. The directory contains + `metadata.json` and, when available, `source-snapshot.json`, + `desired-snapshot.json`, `plan.json`, and `diagnostics.json`. These are diagnostic + artifacts, not reusable catalogs. +- The default engine always refuses extraction errors. Coverage gaps + (`unmodeled_kind` or `unresolved_security_label`) warn and remain unmanaged by + default; `--strict-coverage` turns them into a refusal. Warnings identify the + diagnostic origin and explain that unsupported changes are absent from the diff; + when debug capture is enabled, the bundle is saved before policy evaluation. +- SQL text and file segmentation may differ from the legacy renderer. Applicable + output and convergence (a subsequent diff is empty) are the compatibility contract. +- Default-engine plans retain pg-delta's safe compaction and are formatted with + its human-facing preset (lowercase keywords, max width 180). A JSON object in + `[experimental.pgdelta].format_options` partially overrides that preset; the + JSON literal `null` disables formatting without disabling compaction. ## Files Read @@ -17,32 +44,36 @@ the native pg-delta or migra engine (both run inside Docker via edge-runtime). T | `[db.migrations].schema_paths` globs / `/supabase/database/**` (pg-delta declarative dir) / `/supabase/schemas/**` | SQL | local target: 3-source declarative-schema fallback ladder, first non-empty source wins | | `~/.supabase/access-token` | plain text | `--linked` / `--db-url` with no `SUPABASE_ACCESS_TOKEN` | | `/supabase/.temp/project-ref` | plain text | `--linked` ref resolution | -| `/supabase/.temp/pgdelta/*.json` | JSON | explicit `--from/--to migrations` catalog (cache) | +| `/supabase/.temp/pgdelta-version` | plain text | legacy opt-out only | +| `/supabase/.temp/edge-runtime-version` | plain text | legacy opt-out only: edge-runtime image tag | +| `/supabase/.temp/pgdelta/*.json` | JSON | legacy opt-out only: explicit `--from/--to migrations` catalog cache | ## Files Written -| Path | Format | When | -| ----------------------------------------------------------- | ------ | ----------------------------------------------- | -| `/supabase/migrations/_.sql` | SQL | `--file ` and the diff is non-empty | -| `` (from `--output` / `-o`) | SQL | explicit `--from/--to` mode with `--output` | -| `/supabase/.temp/pgdelta/*.json` | JSON | explicit `--from/--to migrations` catalog cache | -| `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | -| `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | +| Path | Format | When | +| ----------------------------------------------------------- | ------ | ------------------------------------------- | +| `/supabase/migrations/_.sql` | SQL | `--file ` and the diff is non-empty | +| `` (from `--output` / `-o`) | SQL | explicit `--from/--to` mode with `--output` | +| `/supabase/.temp/pgdelta/*.json` | JSON | legacy opt-out only: migrations catalog | +| `/supabase/.temp/pgdelta/pgdelta-target-ca.crt` | PEM | legacy opt-out only: Supabase TLS target | +| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | default engine with `PGDELTA_DEBUG` | +| `~/.supabase//linked-project.json` | JSON | `--linked` (post-run cache) | +| `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | ## Docker -- Edge-runtime container (pg-delta / migra diff scripts; also the declarative - pg-delta apply script for the local-target branch, and runs 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, or pg-delta only under the legacy opt-out). The + legacy explicit `--from/--to migrations` path also runs the native pg-delta + catalog-export script there on a cache miss (CLI-1959; no hidden `__catalog` + subprocess). - 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). + `--from/--to migrations` also provisions natively: the next implementation keeps a live, + config-gated migrated shadow, while the legacy implementation uses + `legacyResolveMigrationsCatalogRef` -> `exportViaShadowCatalog` and its historical + unconditional platform baseline. Neither path uses a `__catalog`-specific shadow or the + retired `mode: "diff"` seam. - `supabase/migra` container — the migra OOM bash fallback only. ## API Routes (linked path, via the db-config resolver) @@ -66,7 +97,8 @@ the native pg-delta or migra engine (both run inside Docker via edge-runtime). T | `SUPABASE_NETWORK_ID` (`--network-id`) | forces the shadow container/network onto an existing Docker network | no | | `SUPABASE_EXPERIMENTAL_PG_DELTA` | force pg-delta engine | no | | `PGDELTA_DEBUG` | pg-delta debug capture | no | -| `PGDELTA_NPM_REGISTRY` | scoped `@supabase` npm registry for edge-runtime | no | +| `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` for the legacy edge-runtime engine | no | +| `PGDELTA_NPM_REGISTRY` | legacy opt-out only: scoped `@supabase` npm registry | no | | `SUPABASE_SSL_DEBUG` | migra SSL debug logging | no | ## Exit Codes @@ -82,13 +114,18 @@ the native pg-delta or migra engine (both run inside Docker via edge-runtime). T Progress to stderr (`Creating shadow database...`, `Diffing schemas[: ]`, `Finished supabase db diff on branch .`, drop-statement warning, and the -`--file` write warning). The SQL diff prints to stdout when neither `--file` nor -explicit `--output` is set. +`--file` write warning). A configured `[db.migrations].schema_paths` also prints a +transition warning because it no longer changes the diff target. The SQL diff +prints to stdout when neither `--file` nor explicit `--output` is set. ### `--output-format json` / `stream-json` Progress strings still go to stderr; stdout carries a single structured envelope -`{ diff, file, schemas, engine, dropStatements }` instead of the raw SQL. +`{ diff, file, files, schemas, engine, dropStatements, advisories? }` instead of +the raw SQL. With the default pg-delta implementation, a non-empty `--file` diff +and a non-empty declarative tree add the informational +`DeclarativeSchemaNotUsedAsDiffBaseline` advisory; the same note is written to +stderr. Inspection is best-effort and never changes command success. ## Notes / Delegation @@ -99,7 +136,13 @@ Progress strings still go to stderr; stdout carries a single structured envelope binary (their side effects are Go's); the Go child's telemetry is disabled so the single `cli_command_executed` event comes from this TS command. - Explicit `--from`/`--to` mode always uses pg-delta and writes to `--output` (or stdout). -- The explicit `migrations` target resolves natively (CLI-1959): a bare +- `--strict-coverage` applies to the bundled pg-delta engine and refuses output when + it encounters schema objects it cannot manage. +- Normal mode always compares the migrations shadow with the selected live + database. Declarative files and `schema_paths` never replace that migrations baseline; use + `supabase db schema declarative sync` for declarative comparison. +- 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`, 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 0aa0d9b1ff..79f1aea427 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.command.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.command.ts @@ -37,6 +37,11 @@ const config = { Flag.withDescription("Use pg-delta to generate schema diff."), Flag.optional, ), + strictCoverage: Flag.boolean("strict-coverage").pipe( + Flag.withDescription( + "Fail when bundled pg-delta finds schema objects it cannot manage instead of leaving them unmanaged.", + ), + ), from: Flag.string("from").pipe( Flag.withDescription("Diff from local, linked, migrations, or a Postgres URL."), Flag.optional, @@ -69,7 +74,9 @@ const config = { ), file: Flag.string("file").pipe( Flag.withAlias("f"), - Flag.withDescription("Saves schema diff to a new migration file."), + Flag.withDescription( + "Names and saves the complete schema diff as a new migration; it does not filter objects.", + ), Flag.optional, ), schema: Flag.string("schema").pipe( @@ -89,7 +96,9 @@ const config = { export type LegacyDbDiffFlags = CliCommand.Command.Config.Infer; export const legacyDbDiffCommand = Command.make("diff", config).pipe( - Command.withDescription("Diffs the local database for schema changes."), + Command.withDescription( + "Compares a shadow built from supabase/migrations with a live database (--local by default, --linked, or --db-url). Declarative files under supabase/database are not part of this baseline. Output is printed by default; -f names and saves the complete diff as a migration and does not filter objects.", + ), Command.withShortDescription("Diffs the local database for schema changes"), Command.withHandler((flags) => legacyDbDiff(flags).pipe( @@ -99,6 +108,7 @@ export const legacyDbDiffCommand = Command.make("diff", config).pipe( "use-pgadmin": flags.usePgAdmin, "use-pg-schema": flags.usePgSchema, "use-pg-delta": flags.usePgDelta, + "strict-coverage": flags.strictCoverage, from: flags.from, to: flags.to, output: flags.output, diff --git a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts index 7e652e0b6f..dca0ebf568 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -13,7 +13,10 @@ import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts" import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; import { legacyAqua, legacyYellow } from "../../../shared/legacy-colors.ts"; -import { legacyReadDbToml } from "../../../shared/legacy-db-config.toml-read.ts"; +import { + legacyReadDbToml, + legacyResolveDeclarativeDir, +} from "../../../shared/legacy-db-config.toml-read.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import type { LegacyDbConnType } from "../../../shared/legacy-db-target-flags.ts"; import { legacyGetHostname } from "../../../shared/legacy-hostname.ts"; @@ -31,6 +34,7 @@ import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state. import { legacyParseBoolEnv, legacyResolveDiffEngine, + legacySchemaPathsTransitionWarning, legacyShouldUsePgDelta, } from "../../../shared/legacy-diff-engine.ts"; import { @@ -38,12 +42,15 @@ import { legacyGetMigrationPath, } from "../../../shared/legacy-migration-file.ts"; import { legacyDiffMigra } from "../shared/legacy-migra.ts"; -import { legacyResolveMigrationsCatalogRef } from "../../../shared/legacy-pgdelta.cache.ts"; +import { + LegacyPgDeltaEngine, + type LegacyPgDeltaDatabaseEndpoint, + type LegacyPgDeltaEndpoint, +} from "../shared/legacy-pgdelta-engine.service.ts"; +import { LegacyLoadPgDeltaSqlFiles } from "../shared/legacy-pgdelta-files.ts"; import { legacyWritePgDeltaMigrations } from "../shared/legacy-pgdelta-migrations.write.ts"; import { type LegacyPgDeltaContext, - legacyDiffPgDelta, - legacyExportCatalogPgDelta, legacyIsPgDeltaDebugEnabled, legacyResolvePgDeltaProjectId, } from "../../../shared/legacy-pgdelta.ts"; @@ -76,6 +83,20 @@ Run ${legacyAqua("supabase db reset")} to verify that the new migration does not // scope for CLI-1960. 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 declarativeBaselineAdvisory = (declarativePath: string | null) => ({ + code: "DeclarativeSchemaNotUsedAsDiffBaseline", + severity: "info", + message: "Declarative schema files were not used as the db diff baseline.", + context: { + baseline: "supabase/migrations", + declarativePath, + fileFlagFiltersObjects: false, + }, +}); + +const declarativeBaselineNote = (displayPath: string) => + `Note: db diff -f uses supabase/migrations as its baseline. Declarative schema files in ${displayPath} are not part of that baseline. If migrations are empty or outdated, the generated migration may include existing declarative objects. -f names the migration; it does not filter objects.\n`; + /** * Rebuilds the `db diff` argv for the pgAdmin / pg-schema delegate path. Flags * stay flags (the Go-proxy channel-parity rule). The explicit `--from`/`--to` and @@ -116,6 +137,7 @@ const rebuildDelegateArgs = (flags: LegacyDbDiffFlags): Array => { export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: LegacyDbDiffFlags) { const output = yield* Output; const resolver = yield* LegacyDbConfigResolver; + const pgDelta = yield* LegacyPgDeltaEngine; const proxy = yield* LegacyGoProxy; const cliConfig = yield* LegacyCliConfig; const telemetryState = yield* LegacyTelemetryState; @@ -225,17 +247,24 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy // runs `LoadConfig(ref)` (`explicit.go:78-86`), re-merging the matching // `[remotes.]` block so a later `local` ref read and the trailing // `pgDeltaFormatOptions()` see the override. Thread the merged config through. - const resolveRef = (ref: string) => + const resolveRef = (ref: string): Effect.Effect => Effect.gen(function* () { switch (legacyClassifyExplicitRef(ref)) { - case "local": - return legacyToPostgresURL({ + case "local": { + const connection = { host: legacyGetHostname(), port: cfg.port, user: "postgres", password: cfg.password, database: "postgres", - }); + }; + return { + kind: "database", + ref: legacyToPostgresURL(connection), + connection, + connectOptions: { isLocal: true, dnsResolver }, + } satisfies LegacyPgDeltaDatabaseEndpoint; + } case "linked": { const resolved = yield* resolver.resolve({ dbUrl: Option.none(), @@ -249,50 +278,36 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy mergedLinkedRef = ref2; cfg = yield* legacyReadDbToml(fs, path, cliConfig.workdir, ref2); } - return legacyToPostgresURL(resolved.conn); - } - case "migrations": { - // Native (CLI-1959 cache mechanics; CLI-1956 native shadow provisioning - // — see `legacyResolveMigrationsCatalogRef`'s doc comment): mirrors Go's - // `resolveMigrationsCatalogRef` (`explicit.go:88-126`) exactly. The - // pg-delta context AND the shadow's own container spec (`cfg` below, - // passed through to `legacyResolveMigrationsCatalogRef`'s `toml` - // parameter) are built from whatever `cfg` is current at this point in - // the cascade (possibly re-merged by an earlier "linked" ref above), - // matching Go's stateful pre-run. - const migrationsCtx: LegacyPgDeltaContext = { - projectId: legacyResolvePgDeltaProjectId( - cliConfig.projectId, - cfg, - cliConfig.workdir, - ), - cwd: cliConfig.workdir, - npmVersion: Option.getOrUndefined(cfg.pgDelta.npmVersion), - denoVersion: cfg.denoVersion, - projectEnv: cfg.projectEnv, - }; - // Pass the linked ref only if one resolved earlier in the cascade, so - // the shadow merges the same remote override Go's in-process - // migrations catalog sees (`explicit.go:88-126`). Absent otherwise → - // base config, matching Go's resolution order. - return yield* legacyResolveMigrationsCatalogRef( - fs, - path, - migrationsCtx, - cfg, - mergedLinkedRef !== undefined ? { projectRef: mergedLinkedRef } : {}, - ); + return { + kind: "database", + ref: legacyToPostgresURL(resolved.conn), + connection: resolved.conn, + connectOptions: { isLocal: resolved.isLocal, dnsResolver }, + } satisfies LegacyPgDeltaDatabaseEndpoint; } + case "migrations": + return { + kind: "migrations", + // Preserve resolution order: only refs resolved before this endpoint + // influence the migrations shadow/catalog. + ...(mergedLinkedRef !== undefined ? { projectRef: mergedLinkedRef } : {}), + } satisfies LegacyPgDeltaEndpoint; case "url": - return ref; + return { + kind: "database", + ref, + // The next engine parses arbitrary explicit URLs itself. They are + // remote by default, matching Go's TLS-safe connection path. + connectOptions: { isLocal: false, dnsResolver }, + } satisfies LegacyPgDeltaDatabaseEndpoint; default: return yield* Effect.fail( new LegacyDbDiffUnknownTargetError({ message: legacyUnknownTargetMessage(ref) }), ); } }); - const sourceRef = yield* resolveRef(from); - const targetRef = yield* resolveRef(to); + const source = yield* resolveRef(from); + const desired = yield* resolveRef(to); const explicitCtx: LegacyPgDeltaContext = { projectId: legacyResolvePgDeltaProjectId(cliConfig.projectId, cfg, cliConfig.workdir), cwd: cliConfig.workdir, @@ -300,11 +315,15 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy denoVersion: cfg.denoVersion, projectEnv: cfg.projectEnv, }; - const result = yield* legacyDiffPgDelta(explicitCtx, { - sourceRef, - targetRef, + const result = yield* pgDelta.diffExplicit({ + context: explicitCtx, + toml: cfg, + source, + desired, schema: flags.schema, formatOptions: Option.getOrElse(cfg.pgDelta.formatOptions, () => ""), + debug: legacyIsPgDeltaDebugEnabled(), + strictCoverage: flags.strictCoverage, }); // Explicit-mode output: `--output` file (Go's `writeOutput`) or stdout // (Go's `fmt.Print`, no trailing newline — pg-delta ends each statement `;\n`). @@ -494,6 +513,9 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy projectEnv: cfg.projectEnv, }; const formatOptions = Option.getOrElse(cfg.pgDelta.formatOptions, () => ""); + if (cfg.schemaPaths !== undefined && cfg.schemaPaths.length > 0) { + yield* output.raw(legacySchemaPathsTransitionWarning, "stderr"); + } // Engine resolution (Go's `db.go:110`): the pg-delta env/config/flag gate, // read from the (possibly remote-merged) config. @@ -511,6 +533,8 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy yield* output.raw("Creating shadow database...\n", "stderr"); const resolvedShadowImage = yield* localInputs.resolvePostgresImage; + const migrationMode: "legacy" | "pgdelta-next" = + useDelta && pgDelta.implementation === "next" ? "pgdelta-next" : "legacy"; const shadowInput = { ...legacyShadowRunInputFromLocalContainerInputs( localInputs, @@ -521,6 +545,7 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy ), 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 // `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS` (`@supabase/config` has no viper-`AutomaticEnv` @@ -565,29 +590,26 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy "stderr", ); if (useDelta) { - // With PGDELTA_DEBUG set, export the shadow's baseline catalog before diffing - // (Go's `DiffDatabase`, `internal/db/diff/diff.go:228-244`, shared by `db diff` - // AND `db pull`) — the snapshot itself is unused here (unlike `db pull`'s - // `legacySaveEmptyPgDeltaPullDebug`, `db diff` has no debug-bundle consumer for - // it); a failed export only warns and the diff continues. - if (legacyIsPgDeltaDebugEnabled()) { - yield* legacyExportCatalogPgDelta(ctx, { - targetRef: shadow.sourceUrl, - role: "postgres", - }).pipe( - Effect.catch((error) => - output.raw( - `Warning: failed to export shadow pg-delta catalog: ${error.message}\n`, - "stderr", - ), - ), - ); - } - const result = yield* legacyDiffPgDelta(ctx, { - sourceRef: shadow.sourceUrl, - targetRef: target, + const result = yield* pgDelta.diffDatabase({ + context: ctx, + source: { + kind: "database", + ref: shadow.sourceUrl, + connectOptions: { isLocal: true, dnsResolver: "native" }, + }, + target: { + kind: "database", + ref: target, + ...(shadow.targetUrlOverride === undefined ? { connection: resolved.conn } : {}), + connectOptions: { + isLocal: shadow.targetUrlOverride !== undefined || resolved.isLocal, + dnsResolver, + }, + }, schema: flags.schema, formatOptions, + debug: legacyIsPgDeltaDebugEnabled(), + strictCoverage: flags.strictCoverage, }); // Keep the per-unit plan files so a multi-unit plan can be written as one // migration file each (Go's `DatabaseDiff.Files`); `sql` stays the flattened @@ -622,6 +644,32 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy const engine = useDelta ? "pg-delta" : "migra"; const drops = 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 + ) { + // 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. + const declarativeDir = legacyResolveDeclarativeDir(path, cfg.pgDelta); + const declarativeDirAbsolute = path.resolve(cliConfig.workdir, declarativeDir); + const hasDeclarativeSql = yield* Effect.gen(function* () { + if (!(yield* fs.exists(declarativeDirAbsolute))) return false; + return (yield* LegacyLoadPgDeltaSqlFiles(fs, path, declarativeDirAbsolute)).length > 0; + }).pipe(Effect.orElseSucceed(() => false)); + if (hasDeclarativeSql) { + const isAbsolute = path.isAbsolute(declarativeDir); + const displayPath = isAbsolute + ? "the configured declarative schema directory" + : declarativeDir.split("\\").join("/"); + ignoredDeclarativeAdvisory = declarativeBaselineAdvisory(isAbsolute ? null : displayPath); + yield* output.raw(declarativeBaselineNote(displayPath), "stderr"); + } + } if (out.length < 2) { yield* output.raw("No schema changes found\n", "stderr"); // Go's `SaveDiff` gates the file write on `len(file) > 0` (`pgadmin.go`), so @@ -645,7 +693,14 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy workdir: cliConfig.workdir, baseMillis: yield* Clock.currentTimeMillis, name: fileName, - files: planFiles.map((file) => ({ name: file.name, sql: file.sql })), + files: planFiles.map((file) => ({ + name: + file.suffix !== undefined && file.suffix !== null + ? file.suffix.replace(/^_/u, "") + : file.name, + sql: file.sql, + transactionMode: file.transactionMode, + })), }).pipe(Effect.mapError((cause) => new LegacyDbDiffWriteError({ message: cause.message }))); for (const unit of writtenUnits) writtenFiles.push(unit.path); } else { @@ -684,6 +739,9 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy schemas: flags.schema, engine, dropStatements: drops, + ...(ignoredDeclarativeAdvisory === undefined + ? {} + : { advisories: [ignoredDeclarativeAdvisory] }), }); } }).pipe( 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 309c894a37..333e87fcc8 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 @@ -1,6 +1,5 @@ -import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { basename, join } from "node:path"; +import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Fiber, Layer, Option } from "effect"; @@ -29,6 +28,7 @@ import { import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; import type { OutputFormat } from "../../../../shared/output/types.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { LegacyDbConfigLoadError } from "../../../shared/legacy-db-config.errors.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import { LegacyDbConnection, @@ -42,6 +42,11 @@ import { LegacyEdgeRuntimeScript, } from "../../../shared/legacy-edge-runtime-script.service.ts"; import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; +import { + LegacyPgDeltaEngine, + type LegacyPgDeltaDatabaseDiffInput, + type LegacyPgDeltaExplicitDiffInput, +} from "../shared/legacy-pgdelta-engine.service.ts"; import type { LegacyDbDiffFlags } from "./diff.command.ts"; import { legacyDbDiff } from "./diff.handler.ts"; @@ -50,9 +55,11 @@ interface SetupOpts { readonly isLocal?: boolean; readonly linkedRef?: string; readonly diffSql?: string; - // When set, the pg-delta edge mock emits a multi-unit plan envelope (one file - // per entry) instead of the single-unit wrap of `diffSql`. + // When set, the pg-delta strategy mock returns one rendered file per entry. readonly diffFiles?: ReadonlyArray<{ readonly name: string; readonly sql: string }>; + // Exact suffixes returned by the next renderer, parallel to `diffFiles`. + readonly diffSuffixes?: ReadonlyArray; + 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 (Go's `DiffDatabase`, @@ -119,6 +126,58 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); const shadowDbConnection = fakeShadowDbConnection(); + const explicitDiffCalls: LegacyPgDeltaExplicitDiffInput[] = []; + const databaseDiffCalls: LegacyPgDeltaDatabaseDiffInput[] = []; + const pgDeltaResult = () => { + const sql = opts.diffSql ?? ""; + const files = + opts.diffFiles !== undefined + ? opts.diffFiles.map((file, index) => ({ + sequence: index + 1, + name: file.name, + ...(opts.diffSuffixes?.[index] !== undefined + ? { suffix: opts.diffSuffixes[index] } + : {}), + sql: file.sql, + transactionMode: "transactional" as const, + })) + : sql.length > 0 + ? [ + { + sequence: 1, + name: "schema_changes", + sql, + transactionMode: "transactional" as const, + }, + ] + : []; + return { + changes: files.length > 0, + sql: opts.diffFiles !== undefined ? files.map((file) => file.sql).join("\n\n") : sql, + files, + }; + }; + 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); + return pgDeltaResult(); + }), + diffDatabase: (input) => + Effect.sync(() => { + databaseDiffCalls.push(input); + return pgDeltaResult(); + }), + exportDeclarativeSchema: () => Effect.die("exportDeclarativeSchema unused"), + planDeclarativeSchema: () => Effect.die("planDeclarativeSchema unused"), + }), + ); + const edgeCalls: LegacyEdgeRuntimeRunOpts[] = []; const edge = Layer.succeed(LegacyEdgeRuntimeScript, { run: (runOpts: LegacyEdgeRuntimeRunOpts) => { @@ -244,6 +303,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { out.layer, telemetry.layer, cache.layer, + pgDeltaEngine, edge, docker, shadowDbConnection.layer, @@ -278,6 +338,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { out, cache, telemetry, + explicitDiffCalls, + databaseDiffCalls, edgeCalls, resolverCalls, proxyCalls, @@ -295,6 +357,7 @@ const flags = (over: Partial = {}): LegacyDbDiffFlags => ({ usePgAdmin: over.usePgAdmin ?? Option.none(), usePgSchema: over.usePgSchema ?? Option.none(), usePgDelta: over.usePgDelta ?? Option.none(), + strictCoverage: over.strictCoverage ?? false, from: over.from ?? Option.none(), to: over.to ?? Option.none(), output: over.output ?? Option.none(), @@ -367,107 +430,68 @@ describe("legacy db diff", () => { it.effect("diffs local with pgdelta when --use-pg-delta is set", () => { const s = setup(tmp.current, { diffSql: "create table p ();\n" }); return Effect.gen(function* () { - yield* legacyDbDiff(flags({ usePgDelta: Option.some(true), schema: ["public"] })); - // pg-delta selection is observable via the edge-runtime script it runs. - expect(s.edgeCalls[0]?.script).toContain("renderPlanFiles"); + yield* legacyDbDiff( + flags({ usePgDelta: Option.some(true), strictCoverage: true, schema: ["public"] }), + ); + expect(s.databaseDiffCalls).toHaveLength(1); + expect(s.databaseDiffCalls[0]).toMatchObject({ + source: { + kind: "database", + connectOptions: { isLocal: true, dnsResolver: "native" }, + }, + schema: ["public"], + strictCoverage: true, + target: { + kind: "database", + connection: { + host: "127.0.0.1", + port: 54322, + user: "postgres", + password: "postgres", + database: "postgres", + }, + connectOptions: { isLocal: true, dnsResolver: "native" }, + }, + }); + // Even the legacy implementation is hidden behind LegacyPgDeltaEngine; + // the handler no longer invokes edge runtime itself. + expect(s.edgeCalls).toEqual([]); expect(stderr(s.out)).toContain("Diffing schemas: public"); expect(stdout(s.out)).toBe("create table p ();\n\n"); }).pipe(Effect.provide(s.layer)); }); - it.effect( - "PGDELTA_DEBUG exports the shadow's baseline catalog before diffing (Go's DiffDatabase)", - () => { - const s = setup(tmp.current, { diffSql: "create table p ();\n" }); - return Effect.gen(function* () { - const prev = process.env["PGDELTA_DEBUG"]; - process.env["PGDELTA_DEBUG"] = "1"; - try { - yield* legacyDbDiff(flags({ usePgDelta: Option.some(true) })); - } finally { - if (prev === undefined) delete process.env["PGDELTA_DEBUG"]; - else process.env["PGDELTA_DEBUG"] = prev; - } - expect(s.edgeCalls.some((c) => c.errPrefix.includes("catalog"))).toBe(true); - expect(stdout(s.out)).toBe("create table p ();\n\n"); - }).pipe(Effect.provide(s.layer)); - }, - ); - - it.effect( - "a failed PGDELTA_DEBUG shadow-catalog export only warns; the diff still succeeds", - () => { - const s = setup(tmp.current, { - diffSql: "create table p ();\n", - catalogExportFailWith: "boom", - }); - return Effect.gen(function* () { - const prev = process.env["PGDELTA_DEBUG"]; - process.env["PGDELTA_DEBUG"] = "1"; - try { - yield* legacyDbDiff(flags({ usePgDelta: Option.some(true) })); - } finally { - if (prev === undefined) delete process.env["PGDELTA_DEBUG"]; - else process.env["PGDELTA_DEBUG"] = prev; - } - expect(stderr(s.out)).toContain("Warning: failed to export shadow pg-delta catalog: boom"); - expect(stdout(s.out)).toBe("create table p ();\n\n"); - }).pipe(Effect.provide(s.layer)); - }, - ); - - it.effect( - "mounts the pg-delta Deno-cache volume by the config/workdir-resolved project id, not just SUPABASE_PROJECT_ID (review: PRRT_kwDOErm0O86XAlIw)", - () => { - // No `SUPABASE_PROJECT_ID` env and no `supabase/config.toml` `project_id` — Go's - // `Config.ProjectId` falls back to the workdir basename (`pkg/config/config.go:563-570`) - // and `UpdateDockerIds` names the edge-runtime volume from that already-sanitized value - // (`internal/utils/config.go:57-76`). Before the fix, `ctx.projectId` came from - // `LegacyCliConfig.projectId` alone (env-only) and resolved to `""`, mounting - // `supabase_edge_runtime_:/root/.cache/deno:rw` regardless of the real project. - const s = setup(tmp.current, { - diffSql: "create table p ();\n", - projectId: Option.none(), - }); - const expectedProjectId = basename(tmp.current); - return Effect.gen(function* () { - yield* legacyDbDiff(flags({ usePgDelta: Option.some(true) })); - expect(s.edgeCalls[0]?.binds).toContain( - `supabase_edge_runtime_${expectedProjectId}:/root/.cache/deno:rw`, - ); - }).pipe(Effect.provide(s.layer)); - }, - ); - - it.effect( - "a linked [remotes.]'s own project_id outranks a conflicting SUPABASE_PROJECT_ID for the pg-delta Deno-cache volume (review: PRRT_kwDOErm0O86XI1w8)", - () => { - // `legacyReadDbToml` already gates `cfg.projectId` behind `remoteOverrideKeys` so it - // reflects the matched remote's OWN `project_id` (review: PRRT_kwDOErm0O86XHGDL) — but - // `legacyResolveLocalProjectId` tries `cliConfig.projectId` (raw, ungated env) FIRST, so - // an ambient `SUPABASE_PROJECT_ID` that differs from the matched remote must be - // suppressed here too, or it silently wins back over the already-gated `cfg.projectId`. - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync( - join(tmp.current, "supabase", "config.toml"), - ["[remotes.staging]", 'project_id = "abcdefghijklmnopqrst"', ""].join("\n"), - ); - const s = setup(tmp.current, { - isLocal: false, - linkedRef: "abcdefghijklmnopqrst", - diffSql: "create table remote ();\n", - // Simulates an ambient `SUPABASE_PROJECT_ID` scoped to an unrelated (e.g. local) - // project — must NOT win over the matched remote's own `project_id`. - projectId: Option.some("unrelated-env-project"), - }); - return Effect.gen(function* () { - yield* legacyDbDiff(flags({ linked: Option.some(true), usePgDelta: Option.some(true) })); - expect(s.edgeCalls[0]?.binds).toContain( - "supabase_edge_runtime_abcdefghijklmnopqrst:/root/.cache/deno:rw", - ); - }).pipe(Effect.provide(s.layer)); - }, - ); + it.effect("next local diff ignores schema_paths and declarative files", () => { + mkdirSync(join(tmp.current, "supabase", "database"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + [ + "[db.migrations]", + 'schema_paths = ["configured.sql"]', + "", + "[experimental.pgdelta]", + "enabled = true", + "", + ].join("\n"), + ); + writeFileSync(join(tmp.current, "supabase", "configured.sql"), "create table configured ();\n"); + writeFileSync( + join(tmp.current, "supabase", "database", "ignored.sql"), + "create table ignored ();\n", + ); + const s = setup(tmp.current, { + pgDeltaImplementation: "next", + diffSql: "create table result ();\n", + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgDelta: Option.some(true) })); + expect(s.databaseDiffCalls[0]).not.toHaveProperty("declarativeFiles"); + expect(s.databaseDiffCalls[0]).not.toHaveProperty("declarativeManifest"); + expect(stderr(s.out)).toContain("schema_paths no longer changes the migrations baseline"); + expect(stderr(s.out)).not.toContain("db diff -f uses supabase/migrations"); + expect(stdout(s.out)).toBe("create table result ();\n\n"); + }).pipe(Effect.provide(s.layer)); + }); it.effect("PG14: provisions a shadow via the SQL-exec init path (no PG15+ one-shot jobs)", () => { // Go's own shadow test coverage hardcodes PG14 (`diff_test.go`); the PG15+ short-id @@ -505,7 +529,6 @@ describe("legacy db diff", () => { }).pipe(Effect.provide(s.layer)); }, ); - it.effect("a linked [remotes.] block enabling pg-delta selects the pg-delta engine", () => { // Go loads the project ref before LoadConfig on the linked path, merging the // matching [remotes.] block before experimental.pgdelta.enabled is read @@ -534,9 +557,8 @@ describe("legacy db diff", () => { }); return Effect.gen(function* () { yield* legacyDbDiff(flags({ linked: Option.some(true) })); - // pg-delta selection (ref-aware: read from the remote-merged `cfg.pgDelta`) is - // observable via the edge-runtime script the diff runs. - expect(s.edgeCalls[0]?.script).toContain("renderPlanFiles"); + expect(s.databaseDiffCalls[0]?.target.connectOptions.isLocal).toBe(false); + expect(s.databaseDiffCalls[0]?.source.connectOptions.isLocal).toBe(true); }).pipe(Effect.provide(s.layer)); }); @@ -736,7 +758,10 @@ describe("legacy db diff", () => { const s = setup(tmp.current, { diffSql: "create table x ();\n" }); return Effect.gen(function* () { const error = yield* legacyDbDiff(flags()).pipe(Effect.flip); - expect(error.message).toContain("failed to read TLS cert"); + expect(error).toBeInstanceOf(LegacyDbConfigLoadError); + if (error instanceof LegacyDbConfigLoadError) { + expect(error.message).toContain("failed to read TLS cert"); + } expect(s.resolverCalls).toHaveLength(0); }).pipe(Effect.provide(s.layer)); }, @@ -831,16 +856,130 @@ describe("legacy db diff", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("writes a timestamped migration when --file is set instead of printing", () => { - const s = setup(tmp.current, { diffSql: "create table f ();\n" }); + it.effect("writes live-only SQL with --file even when declarative targets are configured", () => { + mkdirSync(join(tmp.current, "supabase", "database"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + [ + "[db.migrations]", + 'schema_paths = ["database/*.sql"]', + "", + "[experimental.pgdelta]", + "enabled = true", + "", + ].join("\n"), + ); + writeFileSync( + join(tmp.current, "supabase", "database", "declarative.sql"), + "create table declarative_only ();\n", + ); + const s = setup(tmp.current, { + pgDeltaImplementation: "next", + diffSql: "create table live_only ();\n", + }); return Effect.gen(function* () { - yield* legacyDbDiff(flags({ file: Option.some("my_diff") })); + yield* legacyDbDiff(flags({ usePgDelta: Option.some(true), file: Option.some("my_diff") })); expect(stdout(s.out)).toBe(""); + expect(stderr(s.out)).toContain("schema_paths no longer changes the migrations baseline"); + expect(stderr(s.out)).toContain("db diff -f uses supabase/migrations as its baseline"); + expect(stderr(s.out)).toContain("-f names the migration; it does not filter objects"); expect(stderr(s.out)).toContain("WARNING: The diff tool is not foolproof"); const dir = join(tmp.current, "supabase", "migrations"); const files = readdirSync(dir); expect(files).toHaveLength(1); expect(files[0]).toMatch(/^\d{14}_my_diff\.sql$/); + expect(readFileSync(join(dir, files[0]!), "utf8")).toBe("create table live_only ();\n"); + }).pipe(Effect.provide(s.layer)); + }); + + for (const format of ["json", "stream-json"] as const) { + it.effect(`includes the ignored declarative baseline advisory in ${format} output`, () => { + mkdirSync(join(tmp.current, "supabase", "database"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "database", "items.sql"), + "create table items ();\n", + ); + const s = setup(tmp.current, { + format, + pgDeltaImplementation: "next", + diffSql: "create table dogfood_note ();\n", + }); + return Effect.gen(function* () { + yield* legacyDbDiff( + flags({ usePgDelta: Option.some(true), file: Option.some("dogfood_note") }), + ); + const success = s.out.messages.find((message) => message.type === "success"); + expect(success?.data).toMatchObject({ + diff: "create table dogfood_note ();\n", + engine: "pg-delta", + advisories: [ + { + code: "DeclarativeSchemaNotUsedAsDiffBaseline", + severity: "info", + context: { + baseline: "supabase/migrations", + declarativePath: "supabase/database", + fileFlagFiltersObjects: false, + }, + }, + ], + }); + expect(stderr(s.out)).toContain("db diff -f uses supabase/migrations as its baseline"); + const written = readdirSync(join(tmp.current, "supabase", "migrations")); + expect(written).toHaveLength(1); + expect(readFileSync(join(tmp.current, "supabase", "migrations", written[0]!), "utf8")).toBe( + "create table dogfood_note ();\n", + ); + }).pipe(Effect.provide(s.layer)); + }); + } + + it.effect("does not emit the advisory for the legacy pg-delta implementation", () => { + mkdirSync(join(tmp.current, "supabase", "database"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "database", "items.sql"), + "create table items ();\n", + ); + const s = setup(tmp.current, { + format: "json", + pgDeltaImplementation: "legacy", + diffSql: "create table dogfood_note ();\n", + }); + return Effect.gen(function* () { + yield* legacyDbDiff( + flags({ usePgDelta: Option.some(true), file: Option.some("dogfood_note") }), + ); + const success = s.out.messages.find((message) => message.type === "success"); + expect(success?.data).not.toHaveProperty("advisories"); + expect(stderr(s.out)).not.toContain("db diff -f uses supabase/migrations"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("ignores declarative inspection errors without changing diff success", () => { + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + [ + "[experimental.pgdelta]", + "enabled = true", + 'declarative_schema_path = "not-a-directory.sql"', + "", + ].join("\n"), + ); + 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* () { + yield* legacyDbDiff( + flags({ usePgDelta: Option.some(true), file: Option.some("dogfood_note") }), + ); + const success = s.out.messages.find((message) => message.type === "success"); + expect(success?.data).not.toHaveProperty("advisories"); + expect(success?.data).toMatchObject({ diff: "create table dogfood_note ();\n" }); + expect(stderr(s.out)).not.toContain("db diff -f uses supabase/migrations"); }).pipe(Effect.provide(s.layer)); }); @@ -874,6 +1013,24 @@ describe("legacy db diff", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect("uses exact next-renderer suffixes for multi-file migration names", () => { + const s = setup(tmp.current, { + diffFiles: [ + { name: "ignored_legacy_name", sql: "a" }, + { name: "ignored_legacy_name", sql: "b" }, + ], + diffSuffixes: ["_1", "_2"], + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ usePgDelta: Option.some(true), file: Option.some("my_diff") })); + const dir = join(tmp.current, "supabase", "migrations"); + expect(readdirSync(dir).sort()).toEqual([ + "19700101000000_my_diff_1.sql", + "19700101000001_my_diff_2.sql", + ]); + }).pipe(Effect.provide(s.layer)); + }); + it.effect("creates nested parent directories for a nested single-unit --file name", () => { // `db diff -f snapshots/remote` must create the `_snapshots/` parent dir // before writing, mirroring Go's `utils.WriteFile`. @@ -965,46 +1122,54 @@ describe("legacy db diff", () => { return Effect.gen(function* () { yield* legacyDbDiff(flags({ from: Option.some("local"), to: Option.some("linked") })); // Explicit mode is pg-delta and never provisions a shadow. + expect(s.explicitDiffCalls[0]).toMatchObject({ + source: { + kind: "database", + connection: { + host: "127.0.0.1", + user: "postgres", + database: "postgres", + }, + connectOptions: { isLocal: true, dnsResolver: "native" }, + }, + desired: { + kind: "database", + connection: { + host: "127.0.0.1", + port: 54322, + user: "postgres", + password: "postgres", + database: "postgres", + }, + connectOptions: { isLocal: false, dnsResolver: "native" }, + }, + }); expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toEqual([]); expect(stdout(s.out)).toBe("create table e ();\n"); }).pipe(Effect.provide(s.layer)); }); - it.effect( - "explicit mode mounts the pg-delta Deno-cache volume by the config.toml-resolved project id", - () => { - // `explicitCtx` (built for the actual `--from`/`--to` diff) and `migrationsCtx` - // (built when a `migrations` ref is in the cascade) both used to pass the raw, - // env-only `cliConfig.projectId` straight through — resolving to `""` whenever a - // project relies on config.toml's `project_id` (or the workdir-basename default) - // instead of `SUPABASE_PROJECT_ID`, mounting `supabase_edge_runtime_:...` instead - // of the real project's volume. - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "config.toml"), 'project_id = "demo"\n'); - const s = setup(tmp.current, { diffSql: "create table e ();\n", projectId: Option.none() }); - return Effect.gen(function* () { - yield* legacyDbDiff(flags({ from: Option.some("local"), to: Option.some("local") })); - const diffCall = s.edgeCalls.find((c) => c.script.includes("renderPlanFiles")); - expect(diffCall?.binds).toContain("supabase_edge_runtime_demo:/root/.cache/deno:rw"); - }).pipe(Effect.provide(s.layer)); - }, - ); - - it.effect( - "the migrations-catalog shadow export mounts the same config.toml-resolved project id", - () => { - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "config.toml"), 'project_id = "demo"\n'); - const s = setup(tmp.current, { diffSql: "create table m ();\n", projectId: Option.none() }); - return Effect.gen(function* () { - yield* legacyDbDiff(flags({ from: Option.some("migrations"), to: Option.some("local") })); - const catalogExportCall = s.edgeCalls.find((c) => !c.script.includes("renderPlanFiles")); - expect(catalogExportCall?.binds).toContain( - "supabase_edge_runtime_demo:/root/.cache/deno:rw", - ); - }).pipe(Effect.provide(s.layer)); - }, - ); + it.effect("explicit URL endpoints retain the raw ref and remote connection options", () => { + const s = setup(tmp.current, { diffSql: "create table u ();\n" }); + return Effect.gen(function* () { + yield* legacyDbDiff( + flags({ + from: Option.some("postgresql://source.example/postgres"), + to: Option.some("postgresql://desired.example/postgres"), + }), + ); + expect(s.explicitDiffCalls[0]?.source).toEqual({ + kind: "database", + ref: "postgresql://source.example/postgres", + connectOptions: { isLocal: false, dnsResolver: "native" }, + }); + expect(s.explicitDiffCalls[0]?.desired).toEqual({ + kind: "database", + ref: "postgresql://desired.example/postgres", + connectOptions: { isLocal: false, dnsResolver: "native" }, + }); + }).pipe(Effect.provide(s.layer)); + }); it.effect("explicit --output writes raw SQL to the given path", () => { const s = setup(tmp.current, { diffSql: "create table w ();\n" }); @@ -1064,90 +1229,33 @@ describe("legacy db diff", () => { }, ); - it.effect("explicit --from migrations resolves a shadow catalog natively", () => { - // CLI-1959 (cache mechanics) + CLI-1956 (shadow provisioning): the migrations - // ref now resolves via the SAME native `legacyCreateShadowDatabase`/ - // `legacyPrepareShadowSource`/`legacyRemoveShadowDatabase` primitives `db - // diff`'s own shadow uses, not the retired `db __shadow` seam — a shadow is - // created and torn down (`s.shadowSpawned`). + it.effect("explicit --from migrations routes the migrations endpoint to the strategy", () => { const s = setup(tmp.current, { diffSql: "create table m ();\n" }); return Effect.gen(function* () { yield* legacyDbDiff(flags({ from: Option.some("migrations"), to: Option.some("local") })); - expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); - expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); - // `resolveMigrationsCatalogRef` (Go's `explicit.go:88-126`) calls the shadow - // primitives directly, without `DiffDatabase`'s own progress line — unlike - // `db schema declarative sync`'s `getMigrationsCatalogRef`, which DOES print - // it (`legacy-pgdelta.cache.ts`'s `legacyGetMigrationsCatalogRef`). This - // stderr asymmetry is the parity fix CLI-1959 makes; pin it here even though - // a shadow was actually provisioned on this cache miss. - expect(s.out.stderrText).not.toContain("Creating shadow database..."); + expect(s.explicitDiffCalls[0]?.source).toEqual({ kind: "migrations" }); + expect(s.edgeCalls).toEqual([]); }).pipe(Effect.provide(s.layer)); }); - it.effect( - "explicit --from migrations reuses an already-cached catalog without provisioning a shadow", - () => { - // A cache pre-warmed by a prior `db push` (`legacyTryCacheMigrationsCatalog`) - // or `db diff --from migrations` run keys off the BARE migrations hash - // (`pgcache.HashMigrations` — no setup-inputs token; see - // `legacyResolveMigrationsCatalogRef`'s doc comment), so it must be reused - // here without spinning up a new shadow database at all. - const noMigrationsHash = createHash("sha256").digest("hex"); - const tempDir = join(tmp.current, "supabase", ".temp", "pgdelta"); - mkdirSync(tempDir, { recursive: true }); - const cachedPath = join(tempDir, `catalog-local-migrations-${noMigrationsHash}-1000.json`); - writeFileSync(cachedPath, '{"cached":true}'); - const s = setup(tmp.current, { diffSql: "create table m ();\n" }); - return Effect.gen(function* () { - yield* legacyDbDiff(flags({ from: Option.some("migrations"), to: Option.some("local") })); - expect(s.shadowSpawned).toEqual([]); - const diffCall = s.edgeCalls.find((c) => c.script.includes("renderPlanFiles")); - expect(diffCall?.env["SOURCE"]).toBe( - `/workspace/${join("supabase", ".temp", "pgdelta", `catalog-local-migrations-${noMigrationsHash}-1000.json`)}`, - ); - }).pipe(Effect.provide(s.layer)); - }, - ); - - it.effect( - "explicit --from linked --to migrations provisions the shadow with the linked ref", - () => { - // Go resolves linked first (LoadConfig merges [remotes.]), so the later - // migrations catalog is built from the remote-merged config (explicit.go) — - // and the migrations shadow's OWN container spec must reflect it too, not - // just the pg-delta ref (same probe as "a linked [remotes.] - // db.major_version override reaches the shadow's OWN container spec" above: - // PG <= 14 is the only branch that emits `--tmpfs` on `docker create` argv). - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync( - join(tmp.current, "supabase", "config.toml"), - [ - "[db]", - "major_version = 17", - "", - "[remotes.staging]", - 'project_id = "abcdefghijklmnopqrst"', - "", - "[remotes.staging.db]", - "major_version = 14", - "", - ].join("\n"), - ); - const s = setup(tmp.current, { - isLocal: false, - linkedRef: "abcdefghijklmnopqrst", - diffSql: "create table m ();\n", + it.effect("explicit --from linked --to migrations passes the linked ref to the strategy", () => { + // Go resolves linked first (LoadConfig merges [remotes.]), so the later + // migrations catalog is built from the remote-merged config (explicit.go). + const s = setup(tmp.current, { + isLocal: false, + linkedRef: "abcdefghijklmnopqrst", + diffSql: "create table m ();\n", + }); + return Effect.gen(function* () { + yield* legacyDbDiff(flags({ from: Option.some("linked"), to: Option.some("migrations") })); + expect(s.explicitDiffCalls[0]?.desired).toEqual({ + kind: "migrations", + projectRef: "abcdefghijklmnopqrst", }); - return Effect.gen(function* () { - yield* legacyDbDiff(flags({ from: Option.some("linked"), to: Option.some("migrations") })); - const createArgs = s.shadowSpawned.find((c) => c.args[0] === "create")?.args ?? []; - expect(createArgs).toContain("--tmpfs"); - }).pipe(Effect.provide(s.layer)); - }, - ); + }).pipe(Effect.provide(s.layer)); + }); - it.effect("explicit --from migrations --to linked provisions the shadow with base config", () => { + it.effect("explicit --from migrations --to linked passes base config to the strategy", () => { // Migrations is resolved BEFORE linked here, so Go's LoadConfig(ref) hasn't run // yet — the catalog (and its shadow's own container spec) must use base config // (no ref forwarded), matching order. @@ -1173,8 +1281,7 @@ describe("legacy db diff", () => { }); return Effect.gen(function* () { yield* legacyDbDiff(flags({ from: Option.some("migrations"), to: Option.some("linked") })); - const createArgs = s.shadowSpawned.find((c) => c.args[0] === "create")?.args ?? []; - expect(createArgs).not.toContain("--tmpfs"); + expect(s.explicitDiffCalls[0]?.source).toEqual({ kind: "migrations" }); }).pipe(Effect.provide(s.layer)); }); @@ -1211,8 +1318,10 @@ describe("legacy db diff", () => { linked: Option.some(true), }), ); - const createArgs = s.shadowSpawned.find((c) => c.args[0] === "create")?.args ?? []; - expect(createArgs).toContain("--tmpfs"); + expect(s.explicitDiffCalls[0]?.desired).toEqual({ + kind: "migrations", + projectRef: "abcdefghijklmnopqrst", + }); }).pipe(Effect.provide(s.layer)); }); diff --git a/apps/cli/src/legacy/commands/db/diff/diff.layers.ts b/apps/cli/src/legacy/commands/db/diff/diff.layers.ts index 257a0b3746..d220f35171 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.layers.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.layers.ts @@ -12,6 +12,10 @@ import { legacyIdentityStitchLayer } from "../../../shared/legacy-identity-stitc import { legacyLinkedDbResolverRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; import { legacyPgDeltaSslProbeLayer } from "../../../shared/legacy-pgdelta-ssl-probe.layer.ts"; import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; +import { legacyDeclarativeSeamLayer } from "../shared/legacy-pgdelta.seam.layer.ts"; +import { legacyPgDeltaEngineLayer } from "../shared/legacy-pgdelta-engine.layer.ts"; +import { legacyPgDeltaNextAdapterLayer } from "../shared/legacy-pgdelta-next-adapter.layer.ts"; +import { legacyPgDeltaNextShadowLayer } from "../shared/legacy-pgdelta-next-shadow.layer.ts"; /** * Runtime layer for `supabase db diff`. @@ -48,6 +52,24 @@ const edgeRuntime = legacyEdgeRuntimeScriptLayer.pipe( ); const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const seam = legacyDeclarativeSeamLayer.pipe(Layer.provide(cliConfig)); +const nextShadow = legacyPgDeltaNextShadowLayer.pipe( + Layer.provide(legacyDockerRunLayer), + Layer.provide(legacyDbConnectionLayer), + Layer.provide(httpClient), +); +const pgDeltaEngine = legacyPgDeltaEngineLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyPgDeltaNextAdapterLayer), + Layer.provide(nextShadow), + Layer.provide(edgeRuntime), + Layer.provide(legacyPgDeltaSslProbeLayer), + Layer.provide(seam), + Layer.provide(legacyDockerRunLayer), + Layer.provide(legacyDbConnectionLayer), + Layer.provide(httpClient), + Layer.provide(legacyDebugLoggerLayer), +); export const legacyDbDiffRuntimeLayer = Layer.mergeAll( dbConfig, @@ -55,6 +77,7 @@ export const legacyDbDiffRuntimeLayer = Layer.mergeAll( legacyDockerRunLayer, edgeRuntime, legacyPgDeltaSslProbeLayer, + pgDeltaEngine, httpClient, cliConfig, legacyIdentityStitchLayer, 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 0b3311b03f..b99dfbbcd4 100644 --- a/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md @@ -3,6 +3,9 @@ Native Effect port. Pulls the remote schema into either a new timestamped migration (diffing a throwaway shadow against the remote, native pg-delta or migra) or declarative files (`--declarative`, native pg-delta export). The +migration-style path always compares migrations with the selected live database; +declarative files and `[db.migrations].schema_paths` cannot replace its migrations +baseline. initial-migra pull (no local migrations) seeds the migration file with a native `pg_dump` of the remote schema (a Docker `pg_dump` container, with IPv4 transaction-pooler fallback) and then appends the migra diff. `--experimental`'s @@ -10,7 +13,7 @@ structured-dump sub-branch (Go's `format.WriteStructuredSchemas`) stays delegated to the bundled Go binary rather than retired or ported (CLI-1957): it needs a TS PostgreSQL DDL AST parser with no equivalent in this repo. `--declarative` covers the same per-object-files outcome for schema objects via -pg-delta catalog introspection, though its output tree and cluster-object +pg-delta managed-state extraction, though its output tree and cluster-object coverage differ (see Files Written below), so this mode is on a deprecation path — the same DECISION CLI-1960 makes for `db diff --use-pg-schema` (keep delegating, flag for removal), not the same output: Go's own `--use-pg-schema` @@ -24,6 +27,35 @@ 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 implementation and compatibility + +- Pg-delta diff and declarative export use the in-process engine bundled into the + CLI binary by default. Pg-topo is bundled with it and the version is fixed at + CLI build time; the command never downloads it or falls back automatically. +- `SUPABASE_USE_PG_DELTA_NEXT=false` selects the legacy edge-runtime path from + either the shell or project `supabase/.env` (the shell wins). + `PGDELTA_NPM_REGISTRY`, `supabase/.temp/pgdelta-version`, and legacy catalogs + directly below `supabase/.temp/pgdelta/` apply only to that opt-out. +- With `PGDELTA_DEBUG`, default-engine diagnostic data is stored under + `supabase/.temp/pgdelta/v2/debug//` as `metadata.json` plus available + snapshot, plan, and diagnostics JSON files. These artifacts are never catalog + cache inputs. +- The default engine always refuses extraction errors. Coverage gaps + (`unmodeled_kind` or `unresolved_security_label`) warn and remain unmanaged by + default; `--strict-coverage` turns them into a refusal. Declarative warnings make + clear that unsupported objects are absent from the exported files. Debug + artifacts are saved before policy evaluation when capture is enabled. +- New-engine SQL bytes and transaction-split filenames may differ. Successful + execution and convergence on a subsequent pull/diff are the contract. +- Nontransactional plan files retain pg-delta's exact first-line + `-- pg-delta: transaction=false` directive. Later push/reset/up commands consume + that durable header to keep the whole file outside a CLI-owned transaction. +- Default-engine migration and declarative SQL retains pg-delta's safe compaction + and uses its human-facing formatter (lowercase keywords, max width 180). A JSON + object in `[experimental.pgdelta].format_options` partially overrides the + preset; the JSON literal `null` disables formatting without disabling + compaction. + ## Files Read | Path | Format | When | @@ -36,6 +68,9 @@ Notes/Delegation section below). | `~/.supabase/access-token` | plain text | linked target with no `SUPABASE_ACCESS_TOKEN` | | `/supabase/.temp/project-ref` | plain text | linked ref resolution | | `[db.migrations].schema_paths` globs / `/supabase/database/**` (pg-delta declarative dir) / `/supabase/schemas/**` | SQL | migration-style pull against the local target only: 3-source declarative-schema fallback ladder, first non-empty source wins (same as `db diff`) | +| `/supabase/.temp/pgdelta-version` | plain text | legacy opt-out only | +| `/supabase/.temp/edge-runtime-version` | plain text | legacy opt-out only: edge-runtime image tag | +| `/supabase/.temp/pgdelta/*.json` | JSON | legacy opt-out only: catalog snapshots | ## Files Written @@ -43,17 +78,23 @@ Notes/Delegation section below). | ---------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `/supabase/migrations/_.sql` | SQL | migration-style pull (non-empty diff, or the initial-migra `pg_dump` seed) | | `/supabase/database/**` | SQL | `--declarative` | +| `/supabase/database/.pgdelta-export.json` | JSON | default-engine `--declarative` export metadata | +| `/supabase/.temp/pgdelta/catalog-*.json` | JSON | legacy opt-out only: catalog snapshots | +| `/supabase/.temp/pgdelta/pgdelta-target-ca.crt` | PEM | legacy opt-out only: Supabase TLS target | +| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | default pg-delta 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//linked-project.json` | JSON | linked (post-run cache) | | `~/.supabase/telemetry.json` | JSON | every invocation (post-run) | ## Docker -- Edge-runtime container (pg-delta export / pg-delta or migra diff). +- Edge-runtime container (migra, or pg-delta only under the legacy opt-out). - 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. + both build on), no longer via a Go seam. Migration-style pulls use one for either engine; + declarative export uses the raw shadow only under the legacy opt-out because the bundled + in-process exporter reads the target directly. - `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`). @@ -80,7 +121,8 @@ Notes/Delegation section below). | `SUPABASE_NETWORK_ID` (`--network-id`) | forces the shadow container/network onto an existing Docker network | 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 | -| `PGDELTA_NPM_REGISTRY` | scoped npm registry for edge-runtime | no | +| `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` for the legacy edge-runtime engine | no | +| `PGDELTA_NPM_REGISTRY` | legacy opt-out only: scoped npm registry for edge-runtime | no | ## Exit Codes @@ -104,6 +146,9 @@ written to `. Plus the `--use-pg-delta` deprecation line, the prompt. On success the PostRun line `Finished supabase db pull.` is printed to stdout. +A configured `[db.migrations].schema_paths` prints a transition warning on the +migration path directing users to `supabase db schema declarative sync`. + ### `--output-format json` / `stream-json` Progress strings still go to stderr; stdout carries a single structured envelope @@ -115,6 +160,8 @@ Progress strings still go to stderr; stdout carries a single structured envelope - `--declarative` / deprecated `--use-pg-delta` are mutually exclusive with `--diff-engine`; `--db-url` / `--linked` (default) / `--local` are a target group. - `--use-pg-delta` is hidden and emits the cobra deprecation line to stderr. +- `--strict-coverage` applies to bundled pg-delta diff and declarative-export paths; + it refuses output when pg-delta encounters schema objects it cannot manage. - The initial-migra pull (no local migrations) is native: it streams a `pg_dump` of the remote schema into the migration file, then appends the migra diff. An empty diff after a non-empty dump is swallowed (Go's `swallowInitialInSync`); an empty diff --git a/apps/cli/src/legacy/commands/db/pull/pull.command.ts b/apps/cli/src/legacy/commands/db/pull/pull.command.ts index d024c5e789..0878af696a 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.command.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.command.ts @@ -18,7 +18,7 @@ const config = { // pflag `Changed`. declarative: Flag.boolean("declarative").pipe( Flag.withDescription( - "Pull schema as declarative files using pg-delta instead of creating a migration.", + "Replace the declarative schema tree from the selected database instead of creating a migration; migration history is not updated.", ), Flag.optional, ), @@ -34,6 +34,11 @@ const config = { Flag.withDescription("Diff engine to use for migration-style db pull."), Flag.optional, ), + strictCoverage: Flag.boolean("strict-coverage").pipe( + Flag.withDescription( + "Fail when bundled pg-delta finds schema objects it cannot manage instead of leaving them unmanaged.", + ), + ), schema: Flag.string("schema").pipe( Flag.withAlias("s"), Flag.withDescription("Comma separated list of schema to include."), @@ -67,7 +72,9 @@ const config = { export type LegacyDbPullFlags = CliCommand.Command.Config.Infer; export const legacyDbPullCommand = Command.make("pull", config).pipe( - Command.withDescription("Pull schema from the remote database."), + Command.withDescription( + "Migration mode compares supabase/migrations with the selected live database (--linked by default), writes the complete difference as migration files, and may record them in that database's migration history. --declarative instead replaces the declarative schema tree and does not create migrations or update migration history.", + ), Command.withShortDescription("Pull schema from the remote database"), Command.withHandler((flags) => legacyDbPull(flags).pipe( @@ -76,6 +83,7 @@ export const legacyDbPullCommand = Command.make("pull", config).pipe( declarative: flags.declarative, "use-pg-delta": flags.usePgDelta, "diff-engine": flags.diffEngine, + "strict-coverage": flags.strictCoverage, schema: flags.schema, "db-url": flags.dbUrl, linked: flags.linked, diff --git a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts index a0fd92efaa..c1abe4a1d1 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -55,6 +55,7 @@ import { legacyParseBoolEnv, legacyResolveDeclarativeFromArgs, legacyResolvePullDiffEngine, + legacySchemaPathsTransitionWarning, legacyShouldUsePgDelta, } from "../../../shared/legacy-diff-engine.ts"; import { legacyDiffMigra } from "../shared/legacy-migra.ts"; @@ -71,12 +72,13 @@ import { legacyFormatMigrationTimestamp, legacyGetMigrationPath, } from "../../../shared/legacy-migration-file.ts"; -import { legacyFormatDebugId } from "../shared/legacy-debug-bundle.ts"; +import { legacyDebugBundleMessage, legacyFormatDebugId } from "../shared/legacy-debug-bundle.ts"; +import { + LegacyPgDeltaEngine, + type LegacyPgDeltaDatabaseEndpoint, +} from "../shared/legacy-pgdelta-engine.service.ts"; import { type LegacyPgDeltaContext, - legacyDeclarativeExportPgDelta, - legacyDiffPgDelta, - legacyExportCatalogPgDelta, legacyIsPgDeltaDebugEnabled, legacyResolvePgDeltaProjectId, } from "../../../shared/legacy-pgdelta.ts"; @@ -176,6 +178,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy const output = yield* Output; const resolver = yield* LegacyDbConfigResolver; const connection = yield* LegacyDbConnection; + const pgDeltaEngine = yield* LegacyPgDeltaEngine; const proxy = yield* LegacyGoProxy; const cliConfig = yield* LegacyCliConfig; const telemetryState = yield* LegacyTelemetryState; @@ -382,9 +385,15 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // `db..` connection (Go's `PoolerFallbackEligible` + // `ProjectRefFromDirectDbHost`). The error message embeds the container stderr // (edge-runtime/migra errors wrap it), which is what Go classifies. + const targetEndpoint: LegacyPgDeltaDatabaseEndpoint = { + kind: "database", + ref: targetUrl, + connection: resolved.conn, + connectOptions: { isLocal: resolved.isLocal, dnsResolver }, + }; const withPoolerFallback = ( - directTarget: string, - attempt: (targetRef: string) => Effect.Effect, + directTarget: LegacyPgDeltaDatabaseEndpoint, + attempt: (target: LegacyPgDeltaDatabaseEndpoint) => Effect.Effect, ) => attempt(directTarget).pipe( Effect.catch((error) => @@ -411,7 +420,12 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy .pipe(Effect.orElseSucceed(() => Option.none())); if (Option.isSome(pooler)) { yield* legacyEmitPoolerFallbackWarning(resolved.conn.host); - return yield* attempt(legacyToPostgresURL(pooler.value)); + return yield* attempt({ + kind: "database", + ref: legacyToPostgresURL(pooler.value), + connection: pooler.value, + connectOptions: { isLocal: false, dnsResolver }, + }); } } return yield* Effect.fail(error); @@ -489,24 +503,28 @@ 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(cliConfig.workdir, declarativeDirRel); + const exportSchema = ( + target: LegacyPgDeltaDatabaseEndpoint, + source?: LegacyPgDeltaDatabaseEndpoint, + ) => + pgDeltaEngine.exportDeclarativeSchema({ + context: ctx, + ...(source !== undefined ? { source } : {}), + target, + schema: flags.schema, + formatOptions, + ...(connType === "linked" && linkedRef !== undefined + ? { projectRef: linkedRef } + : {}), + debug: legacyIsPgDeltaDebugEnabled(), + strictCoverage: flags.strictCoverage, + noCache: false, + }); // Built above, before `resolver.resolve()` — see that build's doc comment. // `Option.getOrThrow` is safe here: `useDeclarative` is true in this branch, and // `delegatesExperimentalPull` is defined as `!useDeclarative && (...)`, so // `localInputs` was always built (never the `Option.none()` delegate case) by the // time this branch runs. - const declLocalInputs = Option.getOrThrow(localInputs); - const resolvedDeclShadowImage = yield* declLocalInputs.resolvePostgresImage; - // `legacyPrepareRawShadow` needs none of the `setup`/declarative-branch fields the - // adapter also returns (a bare shadow never runs `MigrateShadowDatabase`) — its own - // input type (`LegacyShadowConnectionInput`) is structurally narrower, so the extra - // fields are simply never read. - const rawShadowInput = legacyShadowRunInputFromLocalContainerInputs( - declLocalInputs, - resolvedDeclShadowImage, - toml, - fs, - path, - ); // `Effect.acquireUseRelease`, NOT a separate `yield* legacyCreateShadowDatabase(...)` // followed by a later `.pipe(Effect.ensuring(...))` (see this file's migration-path // call site below, and `diff.handler.ts`'s identical call site, for the full @@ -526,22 +544,41 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // below instead, so a SIGINT can still interrupt it, matching Go's single cancellable // `ctx` (see `shadow-database.ts`'s own doc comment on `legacyPrepareRawShadow` for // the full rationale, review: PRRT_kwDOErm0O86XMrID). - const exported = yield* Effect.acquireUseRelease( - legacyCreateShadowDatabase(spawner, rawShadowInput), - (handle) => - Effect.gen(function* () { - const shadow = yield* legacyPrepareRawShadow(spawner, handle, rawShadowInput); - return yield* withPoolerFallback(targetUrl, (targetRef) => - legacyDeclarativeExportPgDelta(ctx, { - sourceRef: shadow.sourceUrl, - targetRef, - schema: flags.schema, - formatOptions, - }), - ); - }), - (handle) => legacyRemoveShadowDatabase(spawner, handle.containerId), - ); + 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), + ); + }); yield* legacyWriteDeclarativeSchemas(fs, path, declarativeDir, exported).pipe( Effect.mapError((cause) => new LegacyDbPullWriteError({ message: cause.message })), ); @@ -549,9 +586,10 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // the declarative dir, but only when pg-delta is *disabled* in config // (declarative.go:260-268, gated on IsPgDeltaEnabled which reads the config // value). db pull --declarative does not force-enable pg-delta - // (cmd/db.go:183-186), so unlike generate/sync this branch is reachable: - // without it, subsequent db reset/db diff keep reading supabase/migrations - // and ignore the files just pulled. + // (cmd/db.go:180-182), so unlike generate/sync this branch is reachable: + // it preserves the legacy experimental db-reset schema-files workflow. + // Normal db diff and migration-style db pull still use migrations as + // their baseline and ignore this setting. if (!toml.pgDelta.enabled) { yield* legacyUpdateDeclarativeSchemaPathsConfig( fs, @@ -583,6 +621,14 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy return; } + if ( + !delegatesExperimentalPull && + toml.schemaPaths !== undefined && + toml.schemaPaths.length > 0 + ) { + yield* output.raw(legacySchemaPathsTransitionWarning, "stderr"); + } + // Go's `EXPERIMENTAL` structured-dump branch (`pull.go:49-61`) stays // delegated to Go. pg_dump itself is now native (used by the initial-migra // path below), but this branch also calls `format.WriteStructuredSchemas` @@ -590,7 +636,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // dumped statement with a PostgreSQL DDL AST parser (`multigres`, ~50 node // types) to route objects into structured files. No Postgres DDL parser // exists in TS yet, and `--declarative` already covers the same per-object - // outcome via pg-delta catalog introspection, so this path is deprecated + // outcome via pg-delta managed-state extraction, so this path is deprecated // rather than ported (CLI-1957) — see the deprecation line printed above. if (delegatesExperimentalPull) { // Go's structured-dump path returns before writing a migration or @@ -756,7 +802,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // wrapping the full prepare-shadow-then-diff operation in the retried // closure — each attempt gets its own shadow and its own teardown — instead // of provisioning one shadow and only retrying the diff engine against it. - const runShadowDiff = (targetRef: string) => + const runShadowDiff = (targetEndpoint: LegacyPgDeltaDatabaseEndpoint) => Effect.gen(function* () { // Go's `DiffDatabase` emits these to stderr before provisioning + diffing // (`internal/db/diff/diff.go:212,223-226`); `legacyPrepareShadowSource` @@ -776,6 +822,8 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // utils.IsLocalDatabase(config), …)` (`internal/db/diff/diff.go:213`): a // local target with declarative schema files gets a second // `contrib_regression` shadow returned as the target override. + const migrationMode: "legacy" | "pgdelta-next" = + usePgDeltaDiff && pgDeltaEngine.implementation === "next" ? "pgdelta-next" : "legacy"; const shadowInput = { ...legacyShadowRunInputFromLocalContainerInputs( pullLocalInputs, @@ -786,6 +834,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy ), 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 // applies `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS` (`@supabase/config` has no @@ -824,7 +873,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // Use the declarative target override when present (Go substitutes it // for the diff target, `diff.go:219-220`); for remote pulls it's // undefined, so this is this attempt's resolved target URL. - const target = shadow.targetUrlOverride ?? targetRef; + const target = shadow.targetUrlOverride ?? targetEndpoint.ref; yield* output.raw( diffSchema.length > 0 ? `Diffing schemas: ${diffSchema.join(",")}\n` @@ -832,49 +881,48 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy "stderr", ); if (usePgDeltaDiff) { - // With PGDELTA_DEBUG set, capture the shadow baseline catalog so an - // empty diff can be inspected later (Go's DiffDatabase, - // `internal/db/diff/diff.go:234-244`); a failed export only warns. - const debug = legacyIsPgDeltaDebugEnabled(); - const sourceCatalog = debug - ? yield* legacyExportCatalogPgDelta(ctx, { - targetRef: shadow.sourceUrl, - role: "postgres", - }).pipe( - Effect.catch((error) => - output - .raw( - `Warning: failed to export shadow pg-delta catalog: ${error.message}\n`, - "stderr", - ) - .pipe(Effect.as(undefined)), - ), - ) - : undefined; - const result = yield* legacyDiffPgDelta(ctx, { - sourceRef: shadow.sourceUrl, - targetRef: target, + return yield* pgDeltaEngine.diffDatabase({ + context: ctx, + source: { + kind: "database", + ref: shadow.sourceUrl, + connectOptions: { isLocal: true, dnsResolver: "native" }, + }, + target: { + kind: "database", + ref: target, + ...(shadow.targetUrlOverride === undefined + ? { + ...(targetEndpoint.connection !== undefined + ? { connection: targetEndpoint.connection } + : {}), + connectOptions: targetEndpoint.connectOptions, + } + : { + connectOptions: { isLocal: true, dnsResolver }, + }), + }, schema: diffSchema, formatOptions, + debug: legacyIsPgDeltaDebugEnabled(), + strictCoverage: flags.strictCoverage, }); - return { - sql: result.sql, - files: result.files, - capture: debug ? { sourceCatalog, stderr: result.stderr } : undefined, - }; } const sql = yield* legacyDiffMigra(ctx, { source: shadow.sourceUrl, target, schema: diffSchema, - connectOptions: { isLocal: resolved.isLocal, dnsResolver }, + connectOptions: + shadow.targetUrlOverride === undefined + ? targetEndpoint.connectOptions + : { isLocal: true, dnsResolver }, }); - return { sql, files: undefined, capture: undefined }; + return { sql, files: undefined, debug: undefined }; }), (handle) => legacyRemoveShadowDatabase(spawner, handle.containerId), ); }); - const diffOutcome = yield* withPoolerFallback(targetUrl, runShadowDiff); + const diffOutcome = yield* withPoolerFallback(targetEndpoint, runShadowDiff); const out = diffOutcome.sql; const diffEmpty = out.trim().length === 0; @@ -886,13 +934,13 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // Go saves a pg-delta debug bundle and embeds its path in the in-sync // error when PGDELTA_DEBUG is set (`internal/db/pull/pull.go:192-201`); a // bundle-save failure falls through to the plain in-sync error. - if (diffOutcome.capture !== undefined) { + if (pgDeltaEngine.implementation === "legacy" && diffOutcome.debug !== undefined) { const debugDir = yield* legacySaveEmptyPgDeltaPullDebug({ ctx, conn: resolved.conn, targetUrl, - sourceCatalog: diffOutcome.capture.sourceCatalog, - pgDeltaStderr: diffOutcome.capture.stderr, + sourceCatalog: diffOutcome.debug.sourceSnapshot, + pgDeltaStderr: diffOutcome.debug.stderr, id: legacyFormatDebugId(yield* Clock.currentTimeMillis), fs, path, @@ -915,6 +963,17 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy ); } } + if ( + pgDeltaEngine.implementation === "next" && + diffOutcome.debug?.directory !== undefined + ) { + yield* output.raw(legacyDebugBundleMessage(diffOutcome.debug.directory), "stderr"); + return yield* Effect.fail( + new LegacyDbPullInSyncError({ + message: `No schema changes found (debug bundle: ${diffOutcome.debug.directory})`, + }), + ); + } return yield* Effect.fail( new LegacyDbPullInSyncError({ message: "No schema changes found" }), ); @@ -940,7 +999,12 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy workdir: cliConfig.workdir, baseMillis: nowMillis, name, - files: planFiles.map((file) => ({ name: file.name, sql: file.sql })), + files: planFiles.map((file) => ({ + name: file.name, + suffix: file.suffix, + sql: file.sql, + transactionMode: file.transactionMode, + })), }).pipe( Effect.mapError((cause) => new LegacyDbPullWriteError({ message: cause.message })), ); diff --git a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts index b7412430f4..9f4db7a90a 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 @@ -42,6 +42,10 @@ import { LegacyEdgeRuntimeScript, } from "../../../shared/legacy-edge-runtime-script.service.ts"; import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; +import { + LegacyPgDeltaEngine, + LegacyPgDeltaEngineError, +} from "../shared/legacy-pgdelta-engine.service.ts"; import type { LegacyDbPullFlags } from "./pull.command.ts"; import { legacyDbPull } from "./pull.handler.ts"; @@ -74,6 +78,8 @@ const pgDeltaDiffEnvelope = ( }); interface SetupOpts { + readonly engineImplementation?: "next" | "legacy"; + readonly nextDebugDirectory?: string; readonly format?: OutputFormat; readonly remoteVersions?: ReadonlyArray; readonly edgeStdout?: string; // diff SQL or declarative export JSON @@ -126,6 +132,123 @@ function setup(workdir: string, opts: SetupOpts = {}) { // container create/start/health-inspect/cleanup. const shadowSpawner = mockLegacyShadowContainerCliSpawner(); + const engineCalls: Array<{ + operation: "diff" | "export"; + targetRef: string; + projectRef?: string; + projectId: string; + strictCoverage: boolean; + }> = []; + let engineDiffCount = 0; + const pgDeltaEngine = Layer.succeed( + LegacyPgDeltaEngine, + LegacyPgDeltaEngine.of({ + implementation: opts.engineImplementation ?? "legacy", + diffExplicit: () => Effect.die("diffExplicit unused"), + diffDatabase: (input) => { + engineCalls.push({ + operation: "diff", + targetRef: input.target.ref, + projectRef: input.projectRef, + projectId: input.context.projectId, + strictCoverage: input.strictCoverage, + }); + engineDiffCount += 1; + if (opts.edgeFailFirstWith !== undefined && engineDiffCount === 1) { + return Effect.fail( + new LegacyPgDeltaEngineError({ + message: opts.edgeFailFirstWith, + cause: opts.edgeFailFirstWith, + }), + ); + } + const stdout = opts.edgeStdout ?? ""; + if (stdout.trim().length === 0) { + return Effect.succeed({ + changes: false, + sql: "", + files: [], + ...(process.env["PGDELTA_DEBUG"] !== undefined + ? { + debug: + opts.engineImplementation === "next" + ? { + sourceSnapshot: opts.catalogStdout ?? "", + ...(opts.nextDebugDirectory !== undefined + ? { directory: opts.nextDebugDirectory } + : {}), + } + : { sourceSnapshot: opts.catalogStdout ?? "", stderr: "" }, + } + : {}), + }); + } + try { + const parsed: unknown = JSON.parse(stdout); + if (typeof parsed !== "object" || parsed === null) throw new Error("invalid envelope"); + const rawFiles = Reflect.get(parsed, "files"); + if (!Array.isArray(rawFiles)) throw new Error("invalid envelope"); + const files = rawFiles.map((raw, index) => { + if (typeof raw !== "object" || raw === null) throw new Error("invalid file"); + const sql = Reflect.get(raw, "sql"); + const name = Reflect.get(raw, "name"); + const transactionMode = Reflect.get(raw, "transactionMode"); + if (typeof sql !== "string" || typeof name !== "string") { + throw new Error("invalid file"); + } + if (transactionMode !== "transactional" && transactionMode !== "none") { + throw new Error(`unknown transaction mode ${String(transactionMode)}`); + } + return { + sequence: index + 1, + name, + sql, + transactionMode, + }; + }); + return Effect.succeed({ + changes: files.length > 0, + sql: files.map((file) => file.sql).join("\n"), + files, + }); + } catch (cause) { + return Effect.fail( + new LegacyPgDeltaEngineError({ + message: "failed to parse pg-delta diff output", + cause, + }), + ); + } + }, + exportDeclarativeSchema: (input) => { + engineCalls.push({ + operation: "export", + targetRef: input.target.ref, + projectRef: input.projectRef, + projectId: input.context.projectId, + strictCoverage: input.strictCoverage, + }); + if (opts.edgeFailFirstWith !== undefined && engineCalls.length === 1) { + return Effect.fail( + new LegacyPgDeltaEngineError({ + message: opts.edgeFailFirstWith, + cause: opts.edgeFailFirstWith, + }), + ); + } + return Effect.succeed({ + files: [{ name: "schemas/public/t.sql", sql: "create table t ();" }], + manifest: { + redactSecrets: true, + scope: "database", + profile: "supabase", + }, + }); + }, + planDeclarativeSchema: () => Effect.die("planDeclarativeSchema unused"), + }), + ); + let edgeRunCount = 0; const edgeCalls: LegacyEdgeRuntimeRunOpts[] = []; const edge = Layer.succeed(LegacyEdgeRuntimeScript, { @@ -288,6 +411,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { out.layer, telemetry.layer, cache.layer, + pgDeltaEngine, edge, docker, dbConnection, @@ -331,6 +455,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { poolerFallbackCalls, resolveCalls, dumpCalls, + engineCalls, shadowSpawned: shadowSpawner.spawned, get edgeRunCount() { return edgeRunCount; @@ -345,6 +470,7 @@ const flags = (over: Partial = {}): LegacyDbPullFlags => ({ declarative: over.declarative ?? Option.none(), usePgDelta: over.usePgDelta ?? Option.none(), diffEngine: over.diffEngine ?? Option.none(), + strictCoverage: over.strictCoverage ?? false, schema: over.schema ?? [], dbUrl: over.dbUrl ?? Option.none(), linked: over.linked ?? Option.none(), @@ -382,7 +508,7 @@ describe("legacy db pull", () => { yes: true, }); return Effect.gen(function* () { - yield* legacyDbPull(flags({ diffEngine: Option.some("pg-delta") })); + yield* legacyDbPull(flags({ diffEngine: Option.some("pg-delta"), strictCoverage: true })); const dir = join(tmp.current, "supabase", "migrations"); expect(existsSync(join(dir, `${"20240101000000"}_local.sql`))).toBe(true); // A single-unit plan keeps the unchanged `_remote_schema.sql` filename. @@ -397,6 +523,10 @@ describe("legacy db pull", () => { ); expect(streamText(s.out, "stderr")).not.toContain(tmp.current); expect(s.historyUpserts.length).toBe(1); + expect(s.engineCalls).toHaveLength(1); + expect(s.engineCalls[0]?.operation).toBe("diff"); + expect(s.engineCalls[0]?.strictCoverage).toBe(true); + expect(s.edgeRunCount).toBe(0); expect(streamText(s.out, "stdout")).toContain("Finished supabase db pull."); // The linked ref is pre-loaded (cheap, local-only) before `resolve()` runs, so // the post-run linked-project cache still gets the ref Go would cache via @@ -422,7 +552,7 @@ describe("legacy db pull", () => { { name: "non_transactional", transactionMode: "none", - sql: "-- unit 3\n\ncreate index concurrently i on t (c);", + sql: "-- pg-delta: transaction=false\n-- unit 3\n\ncreate index concurrently i on t (c);", }, ]), yes: true, @@ -441,9 +571,9 @@ describe("legacy db pull", () => { const versions = written.map((f) => f.slice(0, 14)); expect((versions[0] ?? "") < (versions[1] ?? "")).toBe(true); expect((versions[1] ?? "") < (versions[2] ?? "")).toBe(true); - expect(readFileSync(join(dir, written[2] ?? ""), "utf8")).toContain( - "create index concurrently i on t (c);", - ); + const nonTransactional = readFileSync(join(dir, written[2] ?? ""), "utf8"); + expect(nonTransactional.startsWith("-- pg-delta: transaction=false\n")).toBe(true); + expect(nonTransactional).toContain("create index concurrently i on t (c);"); // One "Schema written to" line per unit, each printing the workdir-relative // path (Go's `pull.go:76`), and one history upsert per unit. const err = streamText(s.out, "stderr"); @@ -539,8 +669,12 @@ describe("legacy db pull", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("pulls with the default migra engine", () => { + it.effect("pulls with migra and warns that schema_paths cannot replace the target", () => { seedMigration(tmp.current, "20240101000000"); + writeFileSync( + join(tmp.current, "supabase", "config.toml"), + ["[db.migrations]", 'schema_paths = ["database/*.sql"]', ""].join("\n"), + ); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", @@ -550,7 +684,9 @@ describe("legacy db pull", () => { yield* legacyDbPull(flags()); // Migra engine selection is proven by `edgeStdout` parsing as raw SQL below // (a pg-delta selection would instead try — and fail — to `JSON.parse` it). + expect(s.shadowSpawned.filter((call) => call.args[0] === "create")).toHaveLength(1); const err = streamText(s.out, "stderr"); + expect(err).toContain("schema_paths no longer changes the migrations baseline"); // Go's `ConnectByConfig` prints the Connecting line to stderr before dialing // (`internal/utils/connect.go:348`), ahead of any other pull output. expect(err).toContain("Connecting to remote database...\n"); @@ -601,7 +737,10 @@ describe("legacy db pull", () => { it.effect("pull --declarative exports declarative files (no migration)", () => { const s = setup(tmp.current, { edgeStdout: EXPORT_JSON }); return Effect.gen(function* () { - yield* legacyDbPull(flags({ declarative: Option.some(true) })); + yield* legacyDbPull(flags({ declarative: Option.some(true), strictCoverage: true })); + expect(s.engineCalls[0]?.operation).toBe("export"); + expect(s.engineCalls[0]?.strictCoverage).toBe(true); + expect(s.edgeRunCount).toBe(0); const err = streamText(s.out, "stderr"); // Go's order: `ConnectByConfig` prints Connecting (`pull.go:40`), then // `pullDeclarativePgDelta` prints Preparing (`pull.go:93`). @@ -616,10 +755,31 @@ describe("legacy db pull", () => { expect( existsSync(join(tmp.current, "supabase", "database", "schemas", "public", "t.sql")), ).toBe(true); + expect( + JSON.parse( + readFileSync(join(tmp.current, "supabase", "database", ".pgdelta-export.json"), "utf8"), + ), + ).toMatchObject({ + formatVersion: 1, + redactSecrets: true, + scope: "database", + files: ["schemas/public/t.sql"], + }); // Declarative mode's bare shadow (`legacyPrepareRawShadow`) never connects to set // up a platform baseline or `contrib_regression` template — the only connect is // the top-level target connect (`resolved.conn`, database "postgres"). expect(s.connectedDatabases).toEqual(["postgres"]); + expect(s.shadowSpawned.filter((call) => call.args[0] === "create")).toHaveLength(1); + expect(s.shadowSpawned.filter((call) => call.args[0] === "rm")).toHaveLength(1); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("next declarative export does not provision a baseline shadow", () => { + const s = setup(tmp.current, { engineImplementation: "next" }); + return Effect.gen(function* () { + yield* legacyDbPull(flags({ declarative: Option.some(true) })); + expect(s.engineCalls[0]?.operation).toBe("export"); + expect(s.shadowSpawned.filter((call) => call.args[0] === "create")).toEqual([]); }).pipe(Effect.provide(s.layer)); }); @@ -686,30 +846,25 @@ describe("legacy db pull", () => { }, ); - it.effect( - "mounts the pg-delta Deno-cache volume by the config/workdir-resolved project id, not just SUPABASE_PROJECT_ID (review: PRRT_kwDOErm0O86XAlIw)", - () => { - // No `SUPABASE_PROJECT_ID` env and no `supabase/config.toml` `project_id` — Go's - // `Config.ProjectId` falls back to the workdir basename (`pkg/config/config.go:563-570`) - // and `UpdateDockerIds` names the edge-runtime volume from that already-sanitized value - // (`internal/utils/config.go:57-76`). Before the fix, `ctx.projectId` came from - // `LegacyCliConfig.projectId` alone (env-only) and resolved to `""`, mounting - // `supabase_edge_runtime_:/root/.cache/deno:rw` regardless of the real project — reachable - // here via the declarative-export path (`legacyDeclarativeExportPgDelta`), which reads - // `ctx.projectId` before any local shadow diff even starts. - const s = setup(tmp.current, { edgeStdout: EXPORT_JSON, projectId: Option.none() }); - const expectedProjectId = basename(tmp.current); - return Effect.gen(function* () { - yield* legacyDbPull(flags({ declarative: Option.some(true) })); - expect(s.edgeCalls[0]?.binds).toContain( - `supabase_edge_runtime_${expectedProjectId}:/root/.cache/deno:rw`, - ); - }).pipe(Effect.provide(s.layer)); - }, - ); + it.effect("passes the config/workdir-resolved project id to the pg-delta engine", () => { + // No `SUPABASE_PROJECT_ID` env and no `supabase/config.toml` `project_id` — Go's + // `Config.ProjectId` falls back to the workdir basename (`pkg/config/config.go:563-570`) + // and `UpdateDockerIds` names the edge-runtime volume from that already-sanitized value + // (`internal/utils/config.go:57-76`). Before the fix, `ctx.projectId` came from + // `LegacyCliConfig.projectId` alone (env-only) and resolved to `""`, mounting + // `supabase_edge_runtime_:/root/.cache/deno:rw` regardless of the real project — reachable + // here via the declarative-export path (`legacyDeclarativeExportPgDelta`), which reads + // `ctx.projectId` before any local shadow diff even starts. + const s = setup(tmp.current, { edgeStdout: EXPORT_JSON, projectId: Option.none() }); + const expectedProjectId = basename(tmp.current); + return Effect.gen(function* () { + yield* legacyDbPull(flags({ declarative: Option.some(true) })); + expect(s.engineCalls[0]?.projectId).toBe(expectedProjectId); + }).pipe(Effect.provide(s.layer)); + }); it.effect( - "a linked [remotes.]'s own project_id outranks a conflicting SUPABASE_PROJECT_ID for the pg-delta Deno-cache volume (review: PRRT_kwDOErm0O86XI1w8)", + "a linked [remotes.]'s project_id outranks a conflicting SUPABASE_PROJECT_ID", () => { // `legacyReadDbToml` already gates `toml.projectId` behind `remoteOverrideKeys` so it // reflects the matched remote's OWN `project_id` (review: PRRT_kwDOErm0O86XHGDL) — but @@ -731,9 +886,7 @@ describe("legacy db pull", () => { }); return Effect.gen(function* () { yield* legacyDbPull(flags({ declarative: Option.some(true), linked: Option.some(true) })); - expect(s.edgeCalls[0]?.binds).toContain( - "supabase_edge_runtime_abcdefghijklmnopqrst:/root/.cache/deno:rw", - ); + expect(s.engineCalls[0]?.projectId).toBe("abcdefghijklmnopqrst"); }).pipe(Effect.provide(s.layer)); }, ); @@ -786,6 +939,7 @@ describe("legacy db pull", () => { }); return Effect.gen(function* () { yield* legacyDbPull(flags({ declarative: Option.some(true), usePgDelta: Option.some(true) })); + expect(s.engineCalls[0]?.operation).toBe("export"); // Reaching the declarative write (rather than a migration file / history // upsert) proves the declarative export path ran. expect( @@ -823,6 +977,7 @@ describe("legacy db pull", () => { expect(s.dumpCalls).toHaveLength(1); expect(s.dumpCalls[0]?.env["EXTRA_SED"]).toBe("/^--/d"); expect(s.dumpCalls[0]?.env["EXCLUDED_SCHEMAS"]).toContain("auth"); + expect(s.shadowSpawned.filter((call) => call.args[0] === "create")).toHaveLength(1); // The migration file holds the dump output followed by the appended diff. const dir = join(tmp.current, "supabase", "migrations"); const file = readdirSync(dir).find((f) => f.endsWith("_remote_schema.sql")); @@ -1056,6 +1211,40 @@ describe("legacy db pull", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect("reports the next-generation debug directory for an empty pg-delta diff", () => { + seedMigration(tmp.current, "20240101000000"); + const debugDir = join( + tmp.current, + "supabase", + ".temp", + "pgdelta", + "v2", + "debug", + "20240102-030405-678-diff", + ); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: "", + engineImplementation: "next", + nextDebugDirectory: debugDir, + }); + return Effect.gen(function* () { + const previous = 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).toBe(`No schema changes found (debug bundle: ${debugDir})`); + expect(streamText(s.out, "stderr")).toContain(`Debug information saved to`); + expect(streamText(s.out, "stderr")).toContain(debugDir); + } finally { + if (previous === undefined) delete process.env["PGDELTA_DEBUG"]; + else process.env["PGDELTA_DEBUG"] = previous; + } + }).pipe(Effect.provide(s.layer)); + }); + it.effect("prompts to update history and inserts on yes (tty)", () => { seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { @@ -1593,8 +1782,7 @@ describe("legacy db pull", () => { }); return Effect.gen(function* () { yield* legacyDbPull(flags()); - // pg-delta selection is proven by `edgeStdout`'s envelope shape parsing - // successfully below (a migra selection would instead treat it as raw SQL). + expect(s.engineCalls[0]?.operation).toBe("diff"); }).pipe(Effect.provide(s.layer)); }); @@ -1724,6 +1912,7 @@ describe("legacy db pull", () => { }); return Effect.gen(function* () { yield* legacyDbPull(flags({ linked: Option.some(true) })); + expect(s.engineCalls[0]?.operation).toBe("diff"); // pg-delta selection is ref-aware (read from the remote-merged `toml.pgDelta`) // and is proven by `edgeStdout`'s envelope shape parsing successfully below. expect(streamText(s.out, "stderr")).toMatch( @@ -1831,7 +2020,7 @@ describe("legacy db pull", () => { const err = streamText(s.out, "stderr"); expect(err).toContain("does not support IPv6"); expect(err).toContain("Retrying via the IPv4 connection pooler"); - expect(s.edgeRunCount).toBe(2); + expect(s.engineCalls.filter((call) => call.operation === "diff")).toHaveLength(2); expect(err).toMatch( /Schema written to supabase[/\\]migrations[/\\]\d{14}_remote_schema\.sql\n/u, ); @@ -1861,7 +2050,7 @@ describe("legacy db pull", () => { return Effect.gen(function* () { yield* legacyDbPull(flags({ linked: Option.some(true), declarative: Option.some(true) })); expect(streamText(s.out, "stderr")).toContain("Retrying via the IPv4 connection pooler"); - expect(s.edgeRunCount).toBe(2); + expect(s.engineCalls.filter((call) => call.operation === "export")).toHaveLength(2); expect(streamText(s.out, "stderr")).toContain( `Declarative schema written to ${join("supabase", "database")}\n`, ); @@ -1885,7 +2074,7 @@ describe("legacy db pull", () => { ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(streamText(s.out, "stderr")).not.toContain("Retrying via the IPv4 connection pooler"); - expect(s.edgeRunCount).toBe(1); + expect(s.engineCalls.filter((call) => call.operation === "diff")).toHaveLength(1); }).pipe(Effect.provide(s.layer)); }); @@ -1905,7 +2094,7 @@ describe("legacy db pull", () => { ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(s.poolerFallbackCalls).toHaveLength(0); - expect(s.edgeRunCount).toBe(1); + expect(s.engineCalls.filter((call) => call.operation === "diff")).toHaveLength(1); }).pipe(Effect.provide(s.layer)); }); diff --git a/apps/cli/src/legacy/commands/db/pull/pull.layers.ts b/apps/cli/src/legacy/commands/db/pull/pull.layers.ts index 0a3028fe79..6cec138e72 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.layers.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.layers.ts @@ -13,6 +13,10 @@ import { legacyLinkedDbResolverRuntimeLayer } from "../../../shared/legacy-manag import { legacyPgDeltaSslProbeLayer } from "../../../shared/legacy-pgdelta-ssl-probe.layer.ts"; import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; +import { legacyDeclarativeSeamLayer } from "../shared/legacy-pgdelta.seam.layer.ts"; +import { legacyPgDeltaEngineLayer } from "../shared/legacy-pgdelta-engine.layer.ts"; +import { legacyPgDeltaNextAdapterLayer } from "../shared/legacy-pgdelta-next-adapter.layer.ts"; +import { legacyPgDeltaNextShadowLayer } from "../shared/legacy-pgdelta-next-shadow.layer.ts"; /** * Runtime layer for `supabase db pull`. The db-config resolver, the native pg-delta / migra @@ -40,6 +44,24 @@ const edgeRuntime = legacyEdgeRuntimeScriptLayer.pipe( ); const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const seam = legacyDeclarativeSeamLayer.pipe(Layer.provide(cliConfig)); +const nextShadow = legacyPgDeltaNextShadowLayer.pipe( + Layer.provide(legacyDockerRunLayer), + Layer.provide(legacyDbConnectionLayer), + Layer.provide(httpClient), +); +const pgDeltaEngine = legacyPgDeltaEngineLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyPgDeltaNextAdapterLayer), + Layer.provide(nextShadow), + Layer.provide(edgeRuntime), + Layer.provide(legacyPgDeltaSslProbeLayer), + Layer.provide(seam), + Layer.provide(legacyDockerRunLayer), + Layer.provide(legacyDbConnectionLayer), + Layer.provide(httpClient), + Layer.provide(legacyDebugLoggerLayer), +); export const legacyDbPullRuntimeLayer = Layer.mergeAll( dbConfig, @@ -47,6 +69,7 @@ export const legacyDbPullRuntimeLayer = Layer.mergeAll( legacyDockerRunLayer, edgeRuntime, legacyPgDeltaSslProbeLayer, + pgDeltaEngine, httpClient, cliConfig, legacyIdentityStitchLayer, 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 bc2f42f64c..9b2223d57f 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 @@ -351,6 +351,35 @@ describe("legacy db push", () => { }); }); + it.live("honors pg-delta's no-transaction migration header", () => { + const set = "SET check_function_bodies = off"; + const action = "DROP SUBSCRIPTION app_events"; + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile( + "20240101000000", + `-- pg-delta: transaction=false\n${set};\n${action};\nRESET ALL;`, + ), + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + const setupCommit = conn.execs.indexOf("COMMIT"); + const setIndex = conn.execs.indexOf(set); + const actionIndex = conn.execs.indexOf(action); + const cleanupIndex = conn.execs.lastIndexOf("RESET ALL"); + + expect(conn.execs.filter((sql) => sql === "BEGIN")).toHaveLength(1); + expect(conn.execs.filter((sql) => sql === "COMMIT")).toHaveLength(1); + expect(setIndex).toBeGreaterThan(setupCommit); + expect(actionIndex).toBeGreaterThan(setIndex); + expect(cleanupIndex).toBeGreaterThan(actionIndex); + expect( + conn.queries.some((query) => query.sql.includes("INSERT INTO supabase_migrations")), + ).toBe(true); + }); + }); + 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', @@ -365,12 +394,25 @@ describe("legacy db push", () => { }); }); + 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\n", + "supabase/.env": "SUPABASE_EXPERIMENTAL_PG_DELTA=true\nSUPABASE_USE_PG_DELTA_NEXT=false\n", }, confirm: [true], catalogStdout: '{"snapshot":"ok"}', @@ -390,7 +432,10 @@ describe("legacy db push", () => { 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"), + files: { + ...migrationFile("20240101000000"), + "supabase/.env": "SUPABASE_USE_PG_DELTA_NEXT=false\n", + }, confirm: [true], catalogStdout: '{"snapshot":"ok"}', }); @@ -412,7 +457,10 @@ describe("legacy db push", () => { () => { const { layer, out, edgeRunCalls } = setup(tmp.current, { toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', - files: migrationFile("20240101000000"), + files: { + ...migrationFile("20240101000000"), + "supabase/.env": "SUPABASE_USE_PG_DELTA_NEXT=false\n", + }, confirm: [true], catalogStdout: '{"snapshot":"ok"}', noProjectId: true, @@ -434,7 +482,10 @@ describe("legacy db push", () => { () => { const { layer, out, edgeRunCalls } = setup(tmp.current, { toml: "[experimental.pgdelta]\nenabled = true\n", - files: migrationFile("20240101000000"), + files: { + ...migrationFile("20240101000000"), + "supabase/.env": "SUPABASE_USE_PG_DELTA_NEXT=false\n", + }, confirm: [true], catalogStdout: '{"snapshot":"ok"}', noProjectId: true, @@ -465,7 +516,10 @@ describe("legacy db push", () => { args: ["db", "push", "--linked"], isLocal: false, projectRef: LEGACY_VALID_REF, - files: migrationFile("20240101000000"), + files: { + ...migrationFile("20240101000000"), + "supabase/.env": "SUPABASE_USE_PG_DELTA_NEXT=false\n", + }, confirm: [true], catalogStdout: '{"snapshot":"ok"}', noProjectId: true, @@ -486,7 +540,10 @@ describe("legacy db push", () => { 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"), + files: { + ...migrationFile("20240101000000"), + "supabase/.env": "SUPABASE_USE_PG_DELTA_NEXT=false\n", + }, confirm: [true], catalogStdout: '{"snapshot":"ok"}', noProjectId: true, @@ -506,7 +563,10 @@ describe("legacy db push", () => { 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"), + files: { + ...migrationFile("20240101000000"), + "supabase/.env": "SUPABASE_USE_PG_DELTA_NEXT=false\n", + }, confirm: [true], catalogExportFailWith: "edge-runtime script produced no output", }); @@ -529,7 +589,8 @@ describe("legacy db push", () => { toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', files: { ...migrationFile("20240101000000"), - "supabase/.env": "SUPABASE_INTERNAL_IMAGE_REGISTRY=my-mirror.example.com\n", + "supabase/.env": + "SUPABASE_INTERNAL_IMAGE_REGISTRY=my-mirror.example.com\nSUPABASE_USE_PG_DELTA_NEXT=false\n", }, confirm: [true], catalogStdout: '{"snapshot":"ok"}', 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 37bcd185cd..c023974bb5 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 @@ -1388,6 +1388,34 @@ describe("legacy db reset", () => { }); }); + it.live("honors pg-delta's no-transaction migration header on remote reset", () => { + const set = "SET check_function_bodies = off"; + const action = "DROP SUBSCRIPTION app_events"; + const { layer, conn } = setup(tmp.current, { + toml: 'project_id = "test"\n', + files: migrationFile( + "20240101000000", + `-- pg-delta: transaction=false\n${set};\n${action};\nRESET ALL;`, + ), + confirm: [true], + }); + return Effect.gen(function* () { + yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); + const setupCommit = conn.execs.indexOf("COMMIT"); + const setIndex = conn.execs.indexOf(set); + const actionIndex = conn.execs.indexOf(action); + const cleanupIndex = conn.execs.lastIndexOf("RESET ALL"); + + expect(setIndex).toBeGreaterThan(setupCommit); + expect(actionIndex).toBeGreaterThan(setIndex); + expect(cleanupIndex).toBeGreaterThan(actionIndex); + expect(conn.execs.slice(setIndex, cleanupIndex + 1)).toEqual([set, action, "RESET ALL"]); + expect( + conn.queries.some((query) => query.sql.includes("INSERT INTO supabase_migrations")), + ).toBe(true); + }); + }); + it.live("fails a remote reset before dropping schemas on an undecryptable secret", () => { // Regression: the old point-of-use vault decryption ran AFTER `legacyDropUserSchemas`, // so an undecryptable `encrypted:` secret dropped the schemas before failing. Go runs diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts index 2d24019b64..484729c4c6 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts @@ -101,6 +101,13 @@ export class LegacyDeclarativeDiffError extends Data.TaggedError("LegacyDeclarat } } +/** Sync stopped because a manifest-less legacy schema needs an explicit migration choice. */ +export class LegacyDeclarativeCompatibilityError extends Data.TaggedError( + "LegacyDeclarativeCompatibilityError", +)<{ + readonly message: string; +}> {} + /** * Applying the generated migration to the local database failed. Wraps Go's * `applyMigrationToLocal` error; in interactive mode the handler offers a diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.extension-repair.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.extension-repair.ts new file mode 100644 index 0000000000..3f5db7862a --- /dev/null +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.extension-repair.ts @@ -0,0 +1,50 @@ +import { Effect, FileSystem, Path } from "effect"; + +import { legacyExtensionDeclaration } from "./declarative.flow.ts"; + +interface LegacyExtensionRepairResult { + readonly path: string; + readonly addedExtensions: ReadonlyArray; + readonly addedDeclarations: ReadonlyArray; +} + +const declaredExtensions = (sql: string): ReadonlySet => { + const extensions = new Set(); + const pattern = + /\bCREATE\s+EXTENSION\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:"([^"]+)"|([a-zA-Z_][\w$-]*))/gi; + for (const match of sql.matchAll(pattern)) { + const extension = match[1] ?? match[2]; + if (extension !== undefined) extensions.add(extension); + } + return extensions; +}; + +/** Appends missing legacy extension declarations without replacing existing SQL. */ +export const legacyAppendExtensionDeclarations = Effect.fnUntraced(function* ( + declarativeDir: string, + extensions: ReadonlyArray, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const extensionPath = path.join(declarativeDir, "extension.sql"); + const exists = yield* fs.exists(extensionPath); + const existing = exists ? yield* fs.readFileString(extensionPath) : ""; + const declared = declaredExtensions(existing); + const addedExtensions = [...new Set(extensions)] + .filter((extension) => !declared.has(extension)) + .sort(); + const addedDeclarations = addedExtensions.map(legacyExtensionDeclaration); + + if (addedDeclarations.length > 0) { + const newline = existing.includes("\r\n") ? "\r\n" : "\n"; + const separator = existing.length === 0 || existing.endsWith("\n") ? "" : newline; + const appended = `${separator}${addedDeclarations.join(newline)}${newline}`; + yield* fs.writeFileString(extensionPath, `${existing}${appended}`); + } + + return { + path: extensionPath, + addedExtensions, + addedDeclarations, + } satisfies LegacyExtensionRepairResult; +}); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.extension-repair.unit.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.extension-repair.unit.test.ts new file mode 100644 index 0000000000..ab603f9bf9 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.extension-repair.unit.test.ts @@ -0,0 +1,65 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { useLegacyTempWorkdir } from "../../../../../../tests/helpers/legacy-mocks.ts"; +import { legacyAppendExtensionDeclarations } from "./declarative.extension-repair.ts"; + +describe("legacyAppendExtensionDeclarations", () => { + const tmp = useLegacyTempWorkdir(); + + it.effect("creates root extension.sql with sorted idempotent declarations", () => { + return Effect.gen(function* () { + const result = yield* legacyAppendExtensionDeclarations(tmp.current, [ + "uuid-ossp", + "pgcrypto", + "pgcrypto", + ]); + expect(result.addedExtensions).toEqual(["pgcrypto", "uuid-ossp"]); + expect(readFileSync(join(tmp.current, "extension.sql"), "utf8")).toBe( + [ + 'CREATE EXTENSION IF NOT EXISTS "pgcrypto" WITH SCHEMA "extensions";', + 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA "extensions";', + "", + ].join("\n"), + ); + + const repeated = yield* legacyAppendExtensionDeclarations(tmp.current, ["uuid-ossp"]); + expect(repeated.addedDeclarations).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)); + }); + + it.effect("preserves existing contents and CRLF newlines", () => { + const extensionPath = join(tmp.current, "extension.sql"); + writeFileSync(extensionPath, 'CREATE EXTENSION "pgcrypto";\r\n-- keep me'); + return Effect.gen(function* () { + const result = yield* legacyAppendExtensionDeclarations(tmp.current, ["pgcrypto", "pg_net"]); + expect(result.addedExtensions).toEqual(["pg_net"]); + expect(readFileSync(extensionPath, "utf8")).toBe( + 'CREATE EXTENSION "pgcrypto";\r\n-- keep me\r\n' + + 'CREATE EXTENSION IF NOT EXISTS "pg_net" WITH SCHEMA "extensions";\r\n', + ); + }).pipe(Effect.provide(BunServices.layer)); + }); + + it.effect("appends to the representative legacy root extension.sql", () => { + const fixture = join( + dirname(fileURLToPath(import.meta.url)), + "fixtures", + "legacy", + "extension.sql", + ); + const extensionPath = join(tmp.current, "extension.sql"); + writeFileSync(extensionPath, readFileSync(fixture, "utf8")); + return Effect.gen(function* () { + yield* legacyAppendExtensionDeclarations(tmp.current, ["uuid-ossp"]); + const updated = readFileSync(extensionPath, "utf8"); + expect(updated).toContain('CREATE EXTENSION IF NOT EXISTS "vector"'); + expect(updated).toContain('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); + }).pipe(Effect.provide(BunServices.layer)); + }); +}); 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 008c1e6426..a2e0a34f9a 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,15 +1,23 @@ -/** - * Pure control-flow helpers ported 1:1 from - * `apps/cli-go/cmd/db_schema_declarative.go`. Kept free of Effect/services so - * the precedence rules are unit-testable in isolation; the handlers run the - * actual TTY prompt for the `"prompt"` decision. - */ +import type { LegacyPgDeltaImplementation } from "../../../../shared/legacy-pgdelta-next-flag.ts"; +import type { LegacyPgDeltaRemovalSummary } from "../../shared/legacy-pgdelta-engine.service.ts"; + +/** Extensions that legacy pg-delta treated as part of its implicit Supabase baseline. */ +const LEGACY_IMPLICIT_EXTENSIONS = ["pg_net", "pgcrypto", "uuid-ossp"] as const; + +type LegacyDeclarativeCompatibilityAction = "none" | "repair-extensions" | "stage-next-export"; + +export interface LegacyDeclarativeCompatibilityGap { + readonly repairableExtensions: ReadonlyArray; + readonly extensionIntents: LegacyPgDeltaRemovalSummary["extensionIntents"]; + readonly ambiguousRemovals: ReadonlyArray; + readonly recommendedAction: LegacyDeclarativeCompatibilityAction; +} /** - * Resolves the migration name. The explicit `--name` wins over `--file` - * (default `declarative_sync`). Mirrors Go's `resolveDeclarativeMigrationName` - * (`:99-104`). + * Pure control-flow helpers ported from the legacy Go implementation and kept + * free of Effect/services so handler decisions remain unit-testable. */ + export function legacyResolveDeclarativeMigrationName(name: string, file: string): string { return name.length > 0 ? name : file; } @@ -17,11 +25,6 @@ export function legacyResolveDeclarativeMigrationName(name: string, file: string /** Whether sync applies the generated migration, prompts, or skips. */ export type LegacyDeclarativeApplyDecision = "apply" | "skip" | "prompt"; -/** - * Decides whether to apply the generated migration to the local database. - * Precedence (Go's `resolveDeclarativeSyncShouldApply`, `:106-124`): - * `--no-apply` > `--apply` > global `--yes` > TTY prompt > non-TTY default (skip). - */ export function legacyResolveDeclarativeSyncApplyDecision(opts: { readonly apply: boolean; readonly noApply: boolean; @@ -34,3 +37,69 @@ export function legacyResolveDeclarativeSyncApplyDecision(opts: { if (opts.tty) return "prompt"; return "skip"; } + +const emptyCompatibilityGap = (): LegacyDeclarativeCompatibilityGap => ({ + repairableExtensions: [], + extensionIntents: [], + ambiguousRemovals: [], + recommendedAction: "none", +}); + +/** Classifies manifest-less pg-delta next 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(); + + const extensions = [...new Set(opts.removals.extensions)].sort(); + const repairableExtensions = extensions.filter((extension) => + LEGACY_IMPLICIT_EXTENSIONS.some((implicit) => implicit === extension), + ); + const ambiguousRemovals = extensions.filter( + (extension) => !LEGACY_IMPLICIT_EXTENSIONS.some((implicit) => implicit === extension), + ); + const extensionIntents = opts.removals.extensionIntents; + + if (extensions.length === 0 && extensionIntents.length === 0) return emptyCompatibilityGap(); + const repairable = + repairableExtensions.length > 0 && + ambiguousRemovals.length === 0 && + extensionIntents.length === 0; + return { + repairableExtensions, + extensionIntents, + ambiguousRemovals, + recommendedAction: repairable ? "repair-extensions" : "stage-next-export", + }; +} + +export const legacyExtensionDeclaration = (extension: string): string => + `CREATE EXTENSION IF NOT EXISTS "${extension}" WITH SCHEMA "extensions";`; + +export function legacyFormatStagedExportRecommendation( + gap: LegacyDeclarativeCompatibilityGap, +): string { + const detected = [ + ...(gap.repairableExtensions.length > 0 + ? [`Legacy-implicit extensions: ${gap.repairableExtensions.join(", ")}`] + : []), + ...(gap.ambiguousRemovals.length > 0 + ? [`Extensions: ${gap.ambiguousRemovals.join(", ")}`] + : []), + ...(gap.extensionIntents.length > 0 + ? [ + `Extension-managed objects: ${gap.extensionIntents + .map((intent) => `${intent.extension} ${intent.intentKind} ${intent.key}`) + .join(", ")}`, + ] + : []), + ]; + return [ + "WARNING: pg-delta next manages schema state that the legacy export did not represent.", + ...detected, + "Generate a next-compatible schema into a separate directory, review it, and adopt it when ready:", + "supabase db schema declarative generate --output supabase/database-next", + ].join("\n"); +} 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 388c20c475..d84756e0a3 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 @@ -1,10 +1,90 @@ import { describe, expect, it } from "vitest"; import { + legacyClassifyDeclarativeCompatibilityGap, + legacyExtensionDeclaration, + legacyFormatStagedExportRecommendation, legacyResolveDeclarativeMigrationName, legacyResolveDeclarativeSyncApplyDecision, } from "./declarative.flow.ts"; +const removals = { + extensions: ["pgcrypto", "uuid-ossp"], + extensionIntents: [ + { extension: "pg_cron", intentKind: "job", key: "refresh download metrics" }, + { extension: "pgmq", intentKind: "queue", key: "emails" }, + ], +}; + +describe("legacyClassifyDeclarativeCompatibilityGap", () => { + it("repairs only the known legacy-implicit extension set", () => { + const gap = legacyClassifyDeclarativeCompatibilityGap({ + implementation: "next", + manifestPresent: false, + removals: { extensions: ["uuid-ossp", "pgcrypto", "pgcrypto"], extensionIntents: [] }, + }); + expect(gap).toEqual({ + repairableExtensions: ["pgcrypto", "uuid-ossp"], + extensionIntents: [], + ambiguousRemovals: [], + recommendedAction: "repair-extensions", + }); + expect(legacyExtensionDeclaration("uuid-ossp")).toBe( + 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA "extensions";', + ); + }); + + it("stages a next export for mixed or unknown extension removals", () => { + const gap = legacyClassifyDeclarativeCompatibilityGap({ + implementation: "next", + manifestPresent: false, + removals: { extensions: ["pgcrypto", "postgis"], extensionIntents: [] }, + }); + expect(gap.repairableExtensions).toEqual(["pgcrypto"]); + expect(gap.ambiguousRemovals).toEqual(["postgis"]); + expect(gap.recommendedAction).toBe("stage-next-export"); + }); + + it("stages a next export when extension intents are present", () => { + const gap = legacyClassifyDeclarativeCompatibilityGap({ + implementation: "next", + manifestPresent: false, + removals, + }); + expect(gap.recommendedAction).toBe("stage-next-export"); + expect(legacyFormatStagedExportRecommendation(gap)).toContain( + "generate --output supabase/database-next", + ); + }); + + it("is suppressed for next exports with a manifest", () => { + expect( + legacyClassifyDeclarativeCompatibilityGap({ + implementation: "next", + manifestPresent: true, + removals, + }).recommendedAction, + ).toBe("none"); + }); + + it("is suppressed for the legacy engine and irrelevant removals", () => { + expect( + legacyClassifyDeclarativeCompatibilityGap({ + implementation: "legacy", + manifestPresent: false, + removals, + }), + ).toMatchObject({ recommendedAction: "none" }); + expect( + legacyClassifyDeclarativeCompatibilityGap({ + implementation: "next", + manifestPresent: false, + removals: { extensions: [], extensionIntents: [] }, + }), + ).toMatchObject({ recommendedAction: "none" }); + }); +}); + describe("legacyResolveDeclarativeMigrationName", () => { it("prefers an explicit --name over --file", () => { expect(legacyResolveDeclarativeMigrationName("my_change", "declarative_sync")).toBe( 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 fea0bce9c4..d3a6f8452c 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 @@ -26,6 +26,11 @@ 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, + type LegacyPgDeltaDeclarativePlanInput, +} from "../../shared/legacy-pgdelta-engine.service.ts"; import { legacyBaselineCatalogFileName, legacyBaselineCatalogKey, @@ -155,6 +160,85 @@ const ctx = (cwd: string, declarativeDir: string): LegacyDeclarativeRunContext = declarativeDir, schema: [], noCache: false, + debug: false, + strictCoverage: false, + 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)), + ); + +describe("legacyDiffDeclarativeToMigrations", () => { + it.effect("loads nested SQL and its manifest in stable order for the engine", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-decl-orch-")); + const declDir = join(dir, "supabase", "database"); + mkdirSync(join(declDir, "nested"), { recursive: true }); + writeFileSync(join(declDir, "z.sql"), "select 'z';"); + writeFileSync(join(declDir, "nested", "a.sql"), "select 'a';"); + writeFileSync(join(declDir, "ignored.txt"), "ignored"); + writeFileSync( + join(declDir, ".pgdelta-export.json"), + JSON.stringify({ formatVersion: 1, redactSecrets: true, scope: "database" }), + ); + const calls: LegacyPgDeltaDeclarativePlanInput[] = []; + 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"), + planDeclarativeSchema: (input) => { + calls.push(input); + return Effect.succeed({ + changes: false, + sql: "", + files: [], + sourceRef: "migrations", + targetRef: "declarative", + removals: { + extensions: ["pgcrypto"], + extensionIntents: [ + { extension: "pg_cron", intentKind: "job", key: "refresh metrics" }, + ], + }, + }); + }, + }), + ); + return legacyDiffDeclarativeToMigrations( + { ...ctx(dir, declDir), debug: true, noCache: true, strictCoverage: true }, + toml, + setupInputs, + ).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(calls[0]?.files).toEqual([ + { name: "nested/a.sql", sql: "select 'a';" }, + { name: "z.sql", sql: "select 'z';" }, + ]); + expect(calls[0]?.manifest).toEqual({ redactSecrets: true, scope: "database" }); + expect(calls[0]?.debug).toBe(true); + expect(calls[0]?.noCache).toBe(true); + expect(calls[0]?.strictCoverage).toBe(true); + expect(result.manifestPresent).toBe(true); + expect(result.removals).toEqual({ + extensions: ["pgcrypto"], + extensionIntents: [{ extension: "pg_cron", intentKind: "job", key: "refresh metrics" }], + }); + rmSync(dir, { recursive: true, force: true }); + }), + ), + Effect.provide(Layer.mergeAll(engine, BunServices.layer)), + ); + }); }); // A minimal, valid `LegacySetupInputs` — the exact field values don't matter to @@ -251,7 +335,15 @@ describe("legacyDiffDeclarativeToMigrations", () => { }), ), Effect.provide( - Layer.mergeAll(BunServices.layer, seam.layer, edge.layer, probe, out.layer, shadow.layer), + Layer.mergeAll( + seam.layer, + edge.layer, + probe, + out.layer, + engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), + BunServices.layer, + shadow.layer, + ), ), ); }, @@ -292,7 +384,15 @@ describe("legacyDiffDeclarativeToMigrations", () => { }), ), Effect.provide( - Layer.mergeAll(BunServices.layer, seam.layer, edge.layer, probe, out.layer, shadow.layer), + Layer.mergeAll( + seam.layer, + edge.layer, + probe, + out.layer, + engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), + BunServices.layer, + shadow.layer, + ), ), ); }, @@ -343,7 +443,15 @@ describe("legacyDiffDeclarativeToMigrations", () => { rmSync(dir, { recursive: true, force: true }); }).pipe( Effect.provide( - Layer.mergeAll(BunServices.layer, seam.layer, edge.layer, probe, out.layer, shadow.layer), + Layer.mergeAll( + seam.layer, + edge.layer, + probe, + out.layer, + engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), + BunServices.layer, + shadow.layer, + ), ), ); }, @@ -391,7 +499,15 @@ describe("legacyDiffDeclarativeToMigrations", () => { rmSync(dir, { recursive: true, force: true }); }).pipe( Effect.provide( - Layer.mergeAll(BunServices.layer, seam.layer, edge.layer, probe, out.layer, shadow.layer), + Layer.mergeAll( + seam.layer, + edge.layer, + probe, + out.layer, + engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), + BunServices.layer, + shadow.layer, + ), ), ); }, @@ -444,12 +560,19 @@ describe("legacyDiffDeclarativeToMigrations", () => { rmSync(dir, { recursive: true, force: true }); }).pipe( Effect.provide( - Layer.mergeAll(BunServices.layer, seam.layer, edge.layer, probe, out.layer, shadow.layer), + 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" }); @@ -477,14 +600,83 @@ describe("legacyDiffDeclarativeToMigrations", () => { }), ), Effect.provide( - Layer.mergeAll(BunServices.layer, seam.layer, edge.layer, probe, out.layer, shadow.layer), + Layer.mergeAll( + seam.layer, + edge.layer, + probe, + out.layer, + engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), + BunServices.layer, + shadow.layer, + ), ), ); }); }); describe("legacyGenerateDeclarativeOutput", () => { - it.effect("diffs the baseline catalog against the live DB and returns files", () => { + it.effect("propagates debug, no-cache, and strict coverage to the selected 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: [] }); + }, + planDeclarativeSchema: () => Effect.die("planDeclarativeSchema not used"), + }), + ); + const dir = mkdtempSync(join(tmpdir(), "legacy-decl-export-")); + const shadow = mockShadowInfra(); + const out = mockOutput(); + return legacyGenerateDeclarativeOutput( + { + ...ctx(dir, join(dir, "supabase", "database")), + debug: true, + noCache: true, + strictCoverage: true, + }, + toml, + { + kind: "database", + ref: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", + connectOptions: { isLocal: true, dnsResolver: "native" }, + }, + ).pipe( + Effect.tap(() => + Effect.sync(() => { + 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", @@ -495,22 +687,39 @@ describe("legacyGenerateDeclarativeOutput", () => { files: [{ path: "public.sql", order: 0, statements: 1, sql: "create table a();" }], }; const edge = mockEdge(JSON.stringify(payload)); - return legacyGenerateDeclarativeOutput( - ctx("/proj", "/proj/supabase/database"), - "postgresql://postgres:postgres@127.0.0.1:54322/postgres?connect_timeout=10", - ).pipe( + 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([{ mode: "baseline", noCache: false }]); - expect(output.files[0]?.path).toBe("public.sql"); - // SOURCE = baseline catalog (mapped to /workspace); TARGET = live URL (passthrough). - expect(edge.calls[0]!.env["SOURCE"]).toBe("/workspace/supabase/.temp/pgdelta/base.json"); + 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, BunServices.layer)), + Effect.provide( + Layer.mergeAll( + seam.layer, + edge.layer, + probe, + out.layer, + engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), + BunServices.layer, + shadow.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 e739fc8e2e..d954aaf9d4 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,65 +1,66 @@ import { Effect, FileSystem, Path } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { - type LegacyPgDeltaContext, - legacyDeclarativeExportPgDelta, - legacyDiffPgDelta, -} from "../../../../shared/legacy-pgdelta.ts"; + 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 { - type LegacySetupInputs, - legacyGetMigrationsCatalogRef, -} from "../../../../shared/legacy-pgdelta.cache.ts"; + legacyCreateShadowDatabase, + legacyPrepareRawShadow, + legacyRemoveShadowDatabase, +} 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 { LegacyDeclarativeDiffError } from "./declarative.errors.ts"; -import { LegacyDeclarativeSeam } from "../../shared/legacy-pgdelta.seam.service.ts"; import { legacyFindDropStatements } from "../../../../shared/legacy-sql-split.ts"; +import { + LegacyPgDeltaEngine, + type LegacyPgDeltaDatabaseEndpoint, + type LegacyPgDeltaRemovalSummary, + type LegacyPgDeltaRenderedFile, +} from "../../shared/legacy-pgdelta-engine.service.ts"; +import { + LegacyLoadPgDeltaSqlFiles, + LegacyReadPgDeltaExportManifest, +} from "../../shared/legacy-pgdelta-files.ts"; +import { legacyShadowRunInputFromLocalContainerInputs } from "../../shared/legacy-shadow-source.ts"; +import { LegacyDeclarativeDiffError } from "./declarative.errors.ts"; /** Ambient inputs shared by the orchestration steps. */ export interface LegacyDeclarativeRunContext { readonly pgDelta: LegacyPgDeltaContext; - /** `experimental.pgdelta.format_options` (trimmed; "" when unset). */ readonly formatOptions: string; - /** Resolved declarative schema dir (workdir-relative, e.g. `supabase/database`). */ readonly declarativeDir: string; readonly schema: ReadonlyArray; readonly noCache: boolean; - /** - * Resolved linked project ref for an explicit `generate --linked`. Threaded into - * the baseline `__catalog` export so the Go config load merges the matching - * `[remotes.]` override into the platform baseline (auth/storage/realtime/api/ - * vault settings), matching Go's `Generate`, which builds the baseline from the - * remote-merged config. `undefined` for local/db-url/smart targets. - */ + readonly debug: boolean; + readonly strictCoverage: boolean; + readonly dnsResolver: "native" | "https"; readonly linkedProjectRef?: string; } /** The output of a declarative-to-migrations diff. Mirrors Go's `SyncResult`. */ export interface LegacyDeclarativeSyncResult { readonly diffSQL: string; + readonly files: ReadonlyArray; readonly sourceRef: string; readonly targetRef: string; readonly dropWarnings: ReadonlyArray; + readonly manifestPresent: boolean; + readonly removals: LegacyPgDeltaRemovalSummary; } +const declarativeError = (message: string) => new LegacyDeclarativeDiffError({ message }); + /** * Computes the diff between local migrations state and the declarative schema. * Mirrors Go's `DiffDeclarativeToMigrations` (`declarative.go:170`): the - * declarative catalog (target) is still provisioned via the Go seam (shadow DB + - * `SetupDatabase` + declarative apply); the migrations catalog (source) resolves - * natively (CLI-1959 cache mechanics) via `legacyGetMigrationsCatalogRef`, which - * mirrors Go's `getMigrationsCatalogRef` (`declarative.go:368-430`) exactly — - * including its own shadow provisioning, which is now ALSO native (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). Both catalogs - * are then diffed natively with pg-delta, as before. - * - * `toml` is the caller's own already-loaded `config.toml` read - * (`legacyReadDbToml`'s result), threaded through to - * `legacyGetMigrationsCatalogRef` for the migrations-catalog shadow's own - * container spec — distinct from `setupInputs`, the cache-key/baseline-setup - * subset of the same config. + * 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. */ export const legacyDiffDeclarativeToMigrations = Effect.fnUntraced(function* ( run: LegacyDeclarativeRunContext, @@ -68,58 +69,102 @@ export const legacyDiffDeclarativeToMigrations = Effect.fnUntraced(function* ( ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const seam = yield* LegacyDeclarativeSeam; - + const engine = yield* LegacyPgDeltaEngine; const exists = yield* fs.exists(run.declarativeDir).pipe(Effect.orElseSucceed(() => false)); if (!exists) { return yield* Effect.fail( - new LegacyDeclarativeDiffError({ - message: - "No declarative schema directory found. Run supabase db schema declarative generate first.", - }), + declarativeError( + "No declarative schema directory found. Run supabase db schema declarative generate first.", + ), ); } - - const sourceRef = yield* legacyGetMigrationsCatalogRef(fs, path, run.pgDelta, toml, setupInputs, { - noCache: run.noCache, - ...(run.linkedProjectRef !== undefined ? { projectRef: run.linkedProjectRef } : {}), - }); - const targetRef = yield* seam.exportCatalog({ mode: "declarative", noCache: run.noCache }); - const diff = yield* legacyDiffPgDelta(run.pgDelta, { - sourceRef, - targetRef, + const files = yield* LegacyLoadPgDeltaSqlFiles(fs, path, run.declarativeDir).pipe( + Effect.mapError((error) => declarativeError(error.message)), + ); + const manifest = yield* LegacyReadPgDeltaExportManifest(fs, path, run.declarativeDir).pipe( + Effect.mapError((error) => declarativeError(error.message)), + ); + const result = yield* engine.planDeclarativeSchema({ + context: run.pgDelta, schema: run.schema, formatOptions: run.formatOptions, + debug: run.debug, + strictCoverage: run.strictCoverage, + files, + noCache: run.noCache, + toml, + setupInputs, + ...(run.linkedProjectRef !== undefined ? { projectRef: run.linkedProjectRef } : {}), + ...(manifest !== undefined ? { manifest } : {}), }); return { - diffSQL: diff.sql, - sourceRef, - targetRef, - dropWarnings: legacyFindDropStatements(diff.sql), + diffSQL: result.sql, + files: result.files, + sourceRef: result.sourceRef, + targetRef: result.targetRef, + dropWarnings: legacyFindDropStatements(result.sql), + manifestPresent: manifest !== undefined, + removals: result.removals ?? { extensions: [], extensionIntents: [] }, } satisfies LegacyDeclarativeSyncResult; }); -/** - * Exports a live database's schema as declarative file payloads, diffing it - * against the platform-baseline catalog (provisioned via the Go seam). Mirrors - * the catalog half of Go's `Generate` (`declarative.go:110`): the live database - * URL is the target, the baseline is the source. The handler writes the - * returned files after the overwrite prompt. - */ export const legacyGenerateDeclarativeOutput = Effect.fnUntraced(function* ( run: LegacyDeclarativeRunContext, - targetDbUrl: string, + toml: LegacyDbTomlValues, + target: LegacyPgDeltaDatabaseEndpoint, ) { - const seam = yield* LegacyDeclarativeSeam; - const baselineRef = yield* seam.exportCatalog({ - mode: "baseline", - noCache: run.noCache, - ...(run.linkedProjectRef !== undefined ? { projectRef: run.linkedProjectRef } : {}), - }); - return yield* legacyDeclarativeExportPgDelta(run.pgDelta, { - sourceRef: baselineRef, - targetRef: targetDbUrl, + const engine = yield* LegacyPgDeltaEngine; + const exportInput = { + 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/declarative.shared.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.shared.ts index 1295e979bc..165ca947f2 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.shared.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.shared.ts @@ -16,5 +16,10 @@ export const legacyDbSchemaDeclarativeSharedBase = Command.make("declarative").p noCache: Flag.boolean("no-cache").pipe( Flag.withDescription("Disable catalog cache and force fresh shadow database setup."), ), + strictCoverage: Flag.boolean("strict-coverage").pipe( + Flag.withDescription( + "Fail when bundled pg-delta finds schema objects it cannot manage instead of leaving them unmanaged.", + ), + ), }), ); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts index 38b42c78c3..2a77ceaebd 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts @@ -16,6 +16,7 @@ import { } from "../../../../shared/legacy-db-config.parse.ts"; import { legacyGetHostname } from "../../../../shared/legacy-hostname.ts"; import { legacyToPostgresURL } from "../../../../shared/legacy-postgres-url.ts"; +import type { LegacyPgDeltaDatabaseEndpoint } from "../../shared/legacy-pgdelta-engine.service.ts"; import { LegacyDeclarativeApplyError, LegacyDeclarativeInvalidDbUrlError, @@ -47,30 +48,48 @@ export interface LegacySmartTargetFlags { readonly reset: boolean; } -export const legacyLocalUrl = (local: LegacyLocalConn): string => - legacyToPostgresURL({ - // Go derives the local host from `utils.Config.Hostname` (`GetHostname()`: - // SUPABASE_SERVICES_HOSTNAME → tcp DOCKER_HOST → 127.0.0.1), not a hardcoded - // loopback (`apps/cli-go/internal/utils/misc.go:298-312`). - host: legacyGetHostname(), - port: local.port, - user: "postgres", - password: local.password, - database: "postgres", - }); +const legacyLocalConnection = (local: LegacyLocalConn) => ({ + // Go derives the local host from `utils.Config.Hostname` (`GetHostname()`: + // SUPABASE_SERVICES_HOSTNAME → tcp DOCKER_HOST → 127.0.0.1), not a hardcoded + // loopback (`apps/cli-go/internal/utils/misc.go:298-312`). + host: legacyGetHostname(), + port: local.port, + user: "postgres", + password: local.password, + database: "postgres", +}); + +export const legacyLocalEndpoint = ( + local: LegacyLocalConn, + dnsResolver: "native" | "https", +): LegacyPgDeltaDatabaseEndpoint => { + const connection = legacyLocalConnection(local); + return { + kind: "database", + ref: legacyToPostgresURL(connection), + connection, + connectOptions: { isLocal: true, dnsResolver }, + }; +}; -/** Resolves `--linked` / `--db-url` to a Postgres URL via the shared resolver. */ -export const legacyResolveRemoteUrl = Effect.fnUntraced(function* (flags: LegacySmartTargetFlags) { +/** Resolves a remote target without discarding TLS and connection options. */ +export const legacyResolveRemoteEndpoint = Effect.fnUntraced(function* ( + flags: LegacySmartTargetFlags, +) { const resolver = yield* LegacyDbConfigResolver; const dnsResolver = yield* LegacyDnsResolverFlag; const resolved = yield* resolver.resolve({ dbUrl: flags.dbUrl, - // Remote-only resolution: `--db-url` wins, otherwise the linked project. connType: Option.isSome(flags.dbUrl) ? "db-url" : "linked", dnsResolver, password: flags.password, }); - return legacyToPostgresURL(resolved.conn); + return { + kind: "database", + ref: legacyToPostgresURL(resolved.conn), + connection: resolved.conn, + connectOptions: { isLocal: resolved.isLocal, dnsResolver }, + } satisfies LegacyPgDeltaDatabaseEndpoint; }); /** @@ -79,7 +98,7 @@ export const legacyResolveRemoteUrl = Effect.fnUntraced(function* (flags: Legacy * Shared by `generate` (smart mode) and `sync` (no-declarative-files bootstrap) so * both offer the same local / linked / custom choice and local-reset prompt. */ -export const legacyResolveSmartTargetUrl = Effect.fnUntraced(function* ( +export const legacyResolveSmartTargetEndpoint = Effect.fnUntraced(function* ( flags: LegacySmartTargetFlags, local: LegacyLocalConn, hasMigrations: boolean, @@ -94,7 +113,7 @@ export const legacyResolveSmartTargetUrl = Effect.fnUntraced(function* ( // (db_schema_declarative.go:291), starting a stopped stack. yield* beforeLocalTarget; yield* (yield* LegacyDeclarativeSeam).ensureLocalDatabaseStarted(); - return legacyLocalUrl(local); + return legacyLocalEndpoint(local, yield* LegacyDnsResolverFlag); } const output = yield* Output; @@ -125,7 +144,7 @@ export const legacyResolveSmartTargetUrl = Effect.fnUntraced(function* ( if (choice === "linked") { // Same path as an explicit `--linked` (Go calls `NewDbConfigWithPassword`): // login-role mint + pooler fallback, then `ToPostgresURL`. - return yield* legacyResolveRemoteUrl({ ...flags, linked: Option.some(true) }); + return yield* legacyResolveRemoteEndpoint({ ...flags, linked: Option.some(true) }); } if (choice === "custom") { @@ -151,7 +170,12 @@ export const legacyResolveSmartTargetUrl = Effect.fnUntraced(function* ( }), ); } - return legacyToPostgresURL(conn); + return { + kind: "database", + ref: legacyToPostgresURL(conn), + connection: conn, + connectOptions: { isLocal: false, dnsResolver: yield* LegacyDnsResolverFlag }, + } satisfies LegacyPgDeltaDatabaseEndpoint; } // "Local database" choice: Go runs ensureLocalDatabaseStarted before the reset @@ -189,5 +213,5 @@ export const legacyResolveSmartTargetUrl = Effect.fnUntraced(function* ( ), ); } - return legacyLocalUrl(local); + return legacyLocalEndpoint(local, yield* LegacyDnsResolverFlag); }); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/fixtures/legacy/extension.sql b/apps/cli/src/legacy/commands/db/schema/declarative/fixtures/legacy/extension.sql new file mode 100644 index 0000000000..9c5102c4c0 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/schema/declarative/fixtures/legacy/extension.sql @@ -0,0 +1,2 @@ +-- Representative root extension file from a legacy declarative export. +CREATE EXTENSION IF NOT EXISTS "vector" WITH SCHEMA "extensions"; 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 3e99f70643..9ed124ed19 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 @@ -1,34 +1,64 @@ # `supabase db schema declarative generate` -Generates declarative schema files from a database by diffing a platform-baseline -pg-delta catalog (source) against the target database's catalog (target). +Generates declarative schema files from a database using pg-delta's managed +platform view. + +## Pg-delta implementation and compatibility + +- The default pg-delta engine runs in-process. Pg-delta and pg-topo are bundled + into the CLI binary at build time, so the installed CLI fixes their version and + performs no runtime package download or automatic legacy fallback. +- `SUPABASE_USE_PG_DELTA_NEXT=false` selects the legacy catalog/edge-runtime + implementation from either the shell or project `supabase/.env` (the shell + wins). Only that opt-out uses `supabase/.temp/pgdelta-version`, + `PGDELTA_NPM_REGISTRY`, edge-runtime, or legacy catalogs directly below + `supabase/.temp/pgdelta/`. +- `--no-cache` bypasses legacy catalog reuse/warming. The default engine already + extracts live state and has no reusable catalog cache, so the flag does not + change its extraction behavior. +- With `PGDELTA_DEBUG`, default-engine export diagnostics are written below + `supabase/.temp/pgdelta/v2/debug//`; they are never reused as catalogs. +- The default engine always refuses extraction errors. Coverage gaps + (`unmodeled_kind` or `unresolved_security_label`) warn by default and explain that + unsupported objects are absent from the generated files; `--strict-coverage` + turns them into a refusal. Debug artifacts are saved before policy evaluation + when capture is enabled. +- Generated SQL bytes and grouping may differ between engines. Reloading the + export to the same managed state is the compatibility contract. +- The default engine applies pg-delta's human-facing formatter (lowercase + keywords, max width 180) and export-specific safe constraint folding. A JSON + object in `[experimental.pgdelta].format_options` partially overrides the + formatter; the JSON literal `null` disables formatting without disabling plan + compaction. ## Files Read | Path | Format | When | | ----------------------------------------------- | ---------- | -------------------------------------------------- | | `/supabase/config.toml` | TOML | always — pg-delta gate, ports, format options | -| `/supabase/.temp/pgdelta-version` | plain text | always — pins the `@supabase/pg-delta` npm version | -| `/supabase/.temp/edge-runtime-version` | plain text | always — pins the edge-runtime image tag | +| `/supabase/.temp/pgdelta-version` | plain text | always read for compatibility; affects legacy only | +| `/supabase/.temp/edge-runtime-version` | plain text | legacy opt-out only — edge-runtime image tag | | `/supabase/.temp/postgres-version` | plain text | shadow-DB image resolution (Go seam) | | `/supabase/migrations/*.sql` | SQL | smart mode — detect whether migrations exist | -| `/supabase/.temp/pgdelta/*.json` | JSON | catalog cache (read/written by the Go seam) | +| `/supabase/.temp/pgdelta/*.json` | JSON | legacy opt-out only: catalog cache | | `~/.supabase/access-token` | plain text | `--linked` (token resolution) | ## Files Written -| Path | Format | When | -| --------------------------------------------------------------------------------------------------------------------------- | ------ | -------------------------------------------- | -| `/supabase/database/**/*.sql` (declarative dir; configurable via `[experimental.pgdelta] declarative_schema_path`) | SQL | always — the entire dir is wiped + rewritten | -| `/supabase/.temp/pgdelta/catalog-*.json` | JSON | catalog cache (written by the Go seam) | +| Path | Format | When | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------- | +| `/supabase/database/**/*.sql` (declarative dir; configurable via `[experimental.pgdelta] declarative_schema_path`, or invocation-local `--output`) | SQL | the selected destination is wiped + rewritten after overwrite confirmation | +| `/.pgdelta-export.json` | JSON | default-engine export policy/manifest | +| `/supabase/.temp/pgdelta/catalog-*.json` | JSON | legacy opt-out only: catalog cache | +| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | default engine with `PGDELTA_DEBUG` | ## Subprocesses / Containers -| What | When | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| `supabase-go db schema declarative __catalog --mode baseline --experimental` (hidden seam) — provisions a shadow Postgres + `start.SetupDatabase`, exports the baseline catalog | always | -| Edge-runtime container (`supabase/edge-runtime`) running the pg-delta declarative-export Deno script (host network, deno-cache volume `supabase_edge_runtime_`) | always | -| `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 | +| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| `supabase-go db schema declarative __catalog --mode baseline --experimental` — provisions and exports the legacy baseline catalog | legacy opt-out only | +| Edge-runtime container running the pg-delta declarative-export Deno script | 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`) | ## Environment Variables @@ -36,8 +66,9 @@ pg-delta catalog (source) against the target database's catalog (target). | ---------------------------- | -------------------------------------------------- | --------- | | `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` | no | | `DB_PASSWORD` | password for `--linked` / `--db-url` | no | -| `PGDELTA_NPM_REGISTRY` | private `@supabase` npm registry for pg-delta | no | -| `PGDELTA_DEBUG` | verbose pg-delta diagnostics | no | +| `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` for the legacy edge-runtime engine | no | +| `PGDELTA_NPM_REGISTRY` | legacy opt-out only: private npm registry | no | +| `PGDELTA_DEBUG` | structured default-engine debug artifacts | no | | `SUPABASE_GO_BINARY` | override the `supabase-go` seam binary | no | | `SUPABASE_SERVICES_HOSTNAME` | local DB host for `--local` (Go `GetHostname`) | no | | `DOCKER_HOST` | tcp daemon host used as the local DB host fallback | no | @@ -50,7 +81,7 @@ pg-delta catalog (source) against the target database's catalog (target). | `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 / edge-runtime / export failure | +| `1` | shadow-database / selected 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: Go's @@ -75,10 +106,14 @@ always go to stderr, in every `--output-format`. On success: - Requires `--experimental` or `[experimental.pgdelta] enabled = true`. - `--db-url` / `--linked` / `--local` are mutually exclusive; absent all three, smart mode prompts (existing-files overwrite → Local/Custom choice + reset offer). -- Remote Supabase targets (`--linked` / `--db-url`) get the embedded pg-delta CA - bundle written under `supabase/.temp/pgdelta/` and the URL rewritten to - `sslmode=verify-ca`; local / non-Supabase targets connect without it. -- **Architecture:** the shadow-database platform baseline is provisioned by the - bundled `supabase-go` via the hidden `db schema declarative __catalog` command - (it runs `start.SetupDatabase`'s auth/storage/realtime service migrations). The - rest — orchestration, pg-delta diff/export, file writes, prompts — is native. +- `--output ` selects a destination for this invocation only. Relative paths + resolve from the project workdir; it does not edit config or activate the output + for later syncs. A non-empty destination still requires confirmation or + `--overwrite`, and the configured declarative tree is left untouched. +- The default engine preserves the shared direct/pooler, DNS, TLS, and client + certificate connection behavior. The legacy opt-out retains its embedded CA + file and `sslmode=verify-ca` URL rewrite. +- **Architecture:** the default engine extracts the target directly using the + bundled Supabase management profile, then renders and writes the export + in-process. Under the opt-out, Go provisions/exports a legacy baseline catalog + and edge-runtime runs the Deno script. diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.command.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.command.ts index 05214b0f24..f029fe56b4 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.command.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.command.ts @@ -15,6 +15,13 @@ const config = { overwrite: Flag.boolean("overwrite").pipe( Flag.withDescription("Overwrite declarative schema files without confirmation."), ), + output: Flag.string("output").pipe( + Flag.withAlias("o"), + Flag.withDescription( + "Write the generated declarative schema to this directory without changing the configured declarative schema path.", + ), + Flag.optional, + ), reset: Flag.boolean("reset").pipe( Flag.withDescription("Reset local database before generating (local data will be lost)."), ), @@ -61,16 +68,22 @@ const config = { // so the handler input merges it in alongside the leaf's own flags. export type LegacyDbSchemaDeclarativeGenerateFlags = CliCommand.Command.Config.Infer< typeof config -> & { readonly noCache: boolean }; +> & { readonly noCache: boolean; readonly strictCoverage: boolean }; export const legacyDbSchemaDeclarativeGenerateCommand = Command.make("generate", config).pipe( - Command.withDescription("Generate declarative schema from a database."), + Command.withDescription( + "Exports a live database into the complete declarative schema tree. This replaces declarative files only; it does not create migration files or update migration history. Use --output to stage an export without changing the configured declarative path. In non-interactive use, pass --local, --linked, or --db-url explicitly.", + ), Command.withShortDescription("Generate declarative schema from a database"), Command.withHandler((flags) => Effect.gen(function* () { // `--no-cache` is shared on the parent group; read the resolved value there. const shared = yield* legacyDbSchemaDeclarativeSharedBase; - const merged: LegacyDbSchemaDeclarativeGenerateFlags = { ...flags, noCache: shared.noCache }; + const merged: LegacyDbSchemaDeclarativeGenerateFlags = { + ...flags, + noCache: shared.noCache, + strictCoverage: shared.strictCoverage, + }; return yield* legacyDbSchemaDeclarativeGenerate(merged).pipe( // Go's PostRun prints this on success via `fmt.Println` → stdout // (`cmd/db_schema_declarative.go:93`), so keep it on stdout in text mode. In @@ -91,7 +104,9 @@ export const legacyDbSchemaDeclarativeGenerateCommand = Command.make("generate", withLegacyCommandInstrumentation({ flags: { "no-cache": merged.noCache, + "strict-coverage": merged.strictCoverage, overwrite: merged.overwrite, + output: merged.output, reset: merged.reset, schema: merged.schema, "db-url": merged.dbUrl, @@ -106,7 +121,7 @@ export const legacyDbSchemaDeclarativeGenerateCommand = Command.make("generate", // (StringVarP) (`cmd/db_schema_declarative.go:495,500`); telemetry reports // changed flags by canonical `flag.Name` via `pflag.Visit`, so map the // shorthands so `generate -s public -p secret` logs `schema`/`password`. - aliases: { s: "schema", p: "password" }, + aliases: { o: "output", s: "schema", p: "password" }, }), withJsonErrorHandling, ); 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 e6ad3c01bb..39de18a4b4 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 @@ -1,6 +1,7 @@ import { Effect, FileSystem, Option, Path } from "effect"; import { + LegacyDnsResolverFlag, legacyResolveExperimentalWithProjectEnv, legacyResolveYesWithProjectEnv, } from "../../../../../../shared/legacy/global-flags.ts"; @@ -18,7 +19,15 @@ import { 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 { legacyResolvePgDeltaProjectId } from "../../../../../shared/legacy-pgdelta.ts"; +import { + legacyIsPgDeltaDebugEnabled, + legacyResolvePgDeltaProjectId, +} from "../../../../../shared/legacy-pgdelta.ts"; +import { + LegacyPgDeltaEngine, + type LegacyPgDeltaDatabaseEndpoint, +} from "../../../shared/legacy-pgdelta-engine.service.ts"; +import { LegacyDeclarativeWriteError } from "../../../shared/legacy-pgdelta.errors.ts"; import { LegacyDeclarativeMutuallyExclusiveFlagsError, LegacyDeclarativeNonInteractiveError, @@ -36,9 +45,9 @@ import { import type { LegacyDbSchemaDeclarativeGenerateFlags } from "./generate.command.ts"; import { type LegacyLocalConn, - legacyLocalUrl, - legacyResolveRemoteUrl, - legacyResolveSmartTargetUrl, + legacyLocalEndpoint, + legacyResolveRemoteEndpoint, + legacyResolveSmartTargetEndpoint, } from "../declarative.smart-target.ts"; export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.declarative.generate")( @@ -50,6 +59,8 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec const cliConfig = yield* LegacyCliConfig; 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/ @@ -105,7 +116,7 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec // "Linked project" does NOT re-load in Go, so it is excluded — only `flags.linked`.) let toml = baseToml; // The resolved linked ref (explicit `--linked` only) is threaded into the - // baseline `__catalog` export (so its platform baseline is built from the + // native raw-shadow export source (so its platform setup uses the // remote-merged config, matching Go's `Generate`) and into the post-run // linked-project cache finalizer below. if (Option.isSome(flags.linked)) { @@ -118,14 +129,25 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec } } - // Go prints `utils.GetDeclarativeDir()` verbatim (`declarative.go:156`, - // `db_schema_declarative.go:268`) — the config value, relative unless a user - // configures an absolute `declarative_schema_path` — so user-facing renders use - // `declarativeDirRel`. File I/O needs the resolved dir: `path.resolve` (not - // `path.join`) so an absolute config value is used as-is, matching Go's - // `config.resolve`, which only prefixes the workdir onto a RELATIVE path. - const declarativeDirRel = legacyResolveDeclarativeDir(path, toml.pgDelta); + // Preserve the selected value for user-facing output: invocation-local + // `--output` wins, otherwise use the configured declarative path. File I/O + // resolves relative values from the project workdir while keeping absolute + // values unchanged. + const declarativeDirRel = Option.getOrElse(flags.output, () => + legacyResolveDeclarativeDir(path, toml.pgDelta), + ); const declarativeDir = path.resolve(cliConfig.workdir, declarativeDirRel); + if ( + declarativeDirRel.trim().length === 0 || + declarativeDir === path.resolve(cliConfig.workdir) + ) { + return yield* Effect.fail( + new LegacyDeclarativeWriteError({ + message: + "declarative output directory must not be empty or resolve to the project directory", + }), + ); + } const migrationsDir = path.join(cliConfig.workdir, "supabase", "migrations"); const local: LegacyLocalConn = { port: toml.port, password: toml.password }; @@ -150,13 +172,16 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec declarativeDir, schema: flags.schema, noCache: flags.noCache, + debug: legacyIsPgDeltaDebugEnabled(), + strictCoverage: flags.strictCoverage, + dnsResolver, ...(linkedProjectRef !== undefined ? { linkedProjectRef } : {}), }; const hasExplicitTarget = Option.isSome(flags.local) || Option.isSome(flags.linked) || Option.isSome(flags.dbUrl); - let targetUrl: string; + let target: LegacyPgDeltaDatabaseEndpoint; let overwrite: boolean; if (hasExplicitTarget) { const seam = yield* LegacyDeclarativeSeam; @@ -170,9 +195,9 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec if (Option.getOrElse(flags.local, () => false)) { yield* seam.ensureLocalDatabaseStarted(); } - targetUrl = legacyLocalUrl(local); + target = legacyLocalEndpoint(local, dnsResolver); } else { - targetUrl = yield* legacyResolveRemoteUrl(flags); + target = yield* legacyResolveRemoteEndpoint(flags); } overwrite = flags.overwrite; } else { @@ -228,7 +253,7 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec linkedProjectRef = linkedRef.value; } } - targetUrl = yield* legacyResolveSmartTargetUrl( + target = yield* legacyResolveSmartTargetEndpoint( flags, local, hasMigrations, @@ -241,7 +266,7 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec overwrite = true; } - const result = yield* legacyGenerateDeclarativeOutput(run, targetUrl); + const result = yield* legacyGenerateDeclarativeOutput(run, toml, target); if (!overwrite && (yield* confirmOverwriteHasFiles(fs, declarativeDir))) { // Go's confirmOverwrite goes through Console.PromptYesNo (`internal/db/ @@ -270,14 +295,17 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec // `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 as `SUPABASE_PROJECT_ID` into the - // `__catalog` subprocess (the same channel the baseline export uses), 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`. - if (!flags.noCache) { + // 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 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.output)) { yield* (yield* LegacyDeclarativeSeam).exportCatalog({ mode: "declarative", noCache: flags.noCache, 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 36f97641f5..e616e0fe8d 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 @@ -46,6 +46,8 @@ 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 } from "../../../shared/legacy-pgdelta-engine.service.ts"; import { LegacyDeclarativeShadowDbError } from "../../../shared/legacy-pgdelta.errors.ts"; import { type LegacyCatalogMode, @@ -86,6 +88,7 @@ interface SetupOpts { projectId?: Option.Option; exportFailsForMode?: LegacyCatalogMode; staleLocalImage?: boolean; + engineImplementation?: "legacy" | "next"; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -181,29 +184,80 @@ function setup(workdir: string, opts: SetupOpts = {}) { exec: (args) => Effect.sync(() => void proxyCalls.push(args)), execCapture: () => Effect.succeed(""), }); + const sslProbe = Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.succeed(false), + requireSslForHost: () => Effect.succeed(false), + }); + const runtimeInfo = mockRuntimeInfo({ platform: "linux" }); + const processControl = mockProcessControl(); + const experimentalFlag = Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? true); + const cliArgs = Layer.succeed(CliArgs, { + args: opts.args ?? ["db", "schema", "declarative", "generate"], + }); + const networkIdFlag = Layer.succeed(LegacyNetworkIdFlag, opts.networkId ?? Option.none()); + const debugFlag = Layer.succeed(LegacyDebugFlag, false); + const dockerRun = legacyDockerRunLayer.pipe( + 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: "schemas/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, dbConn, mockLegacyCliConfig({ workdir, projectId: opts.projectId ?? Option.some("test") }), mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), mockStdin(opts.stdinIsTty ?? false), - Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? true), - Layer.succeed(CliArgs, { args: opts.args ?? ["db", "schema", "declarative", "generate"] }), + experimentalFlag, + cliArgs, Layer.succeed(LegacyYesFlag, opts.yes ?? false), - Layer.succeed(LegacyNetworkIdFlag, opts.networkId ?? Option.none()), + networkIdFlag, Layer.succeed(LegacyDnsResolverFlag, "native"), - Layer.succeed(LegacyDebugFlag, false), + debugFlag, // The remote ref is a non-Supabase host that refuses TLS → no SSL env. - Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }), + sslProbe, // The local-reset bucket-seed core statically requires the (lazy) Management-API // factory; never invoked on the local reset (projectRef === ""). Layer.succeed(LegacyPlatformApiFactory, { @@ -214,13 +268,10 @@ function setup(workdir: string, opts: SetupOpts = {}) { // resolves a duplicate service tag to whichever layer is listed LAST, so this // mock overrides Bun's real `ChildProcessSpawner` instead of the reverse. child.layer, - mockRuntimeInfo({ platform: "linux" }), - mockProcessControl().layer, + runtimeInfo, + processControl.layer, alwaysReadyHttpClientLayer, - legacyDockerRunLayer.pipe( - Layer.provide(child.layer), - Layer.provide(mockProcessControl().layer), - ), + dockerRun, ); return { layer, @@ -245,7 +296,9 @@ const flags = ( over: Partial = {}, ): LegacyDbSchemaDeclarativeGenerateFlags => ({ noCache: over.noCache ?? false, + strictCoverage: over.strictCoverage ?? false, overwrite: over.overwrite ?? false, + output: over.output ?? Option.none(), reset: over.reset ?? false, schema: over.schema ?? [], dbUrl: over.dbUrl ?? Option.none(), @@ -403,13 +456,16 @@ describe("legacy db schema declarative generate integration", () => { }, ); - it.effect("explicit --local: provisions baseline, exports, writes declarative files", () => { + it.effect("explicit --local: provisions a raw shadow, exports, and writes files", () => { const s = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })); - // baseline (source catalog) for the diff, then the post-write declarative cache warm. - expect(s.seamCalls).toEqual(["baseline", "declarative"]); - // TARGET is the local DB URL (passthrough); SOURCE is the baseline catalog. + // 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( "postgresql://postgres:postgres@127.0.0.1:54322", ); @@ -434,6 +490,138 @@ describe("legacy db schema declarative generate integration", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect( + "--output writes a complete next 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;"); + const configPath = join(tmp.current, "supabase", "config.toml"); + const config = [ + "[experimental.pgdelta]", + "enabled = true", + 'declarative_schema_path = "supabase/database"', + "", + ].join("\n"); + writeFileSync(configPath, config); + const destination = join("supabase", "database-next"); + const s = setup(tmp.current, { experimental: true, engineImplementation: "next" }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeGenerate( + flags({ local: Option.some(true), output: Option.some(destination) }), + ); + + expect( + readFileSync( + join(tmp.current, destination, "schemas", "public", "tables", "players.sql"), + "utf8", + ), + ).toBe("create table players ();"); + expect( + JSON.parse(readFileSync(join(tmp.current, destination, ".pgdelta-export.json"), "utf8")), + ).toMatchObject({ + formatVersion: 1, + profile: "supabase", + files: ["schemas/public/tables/players.sql"], + }); + expect( + readFileSync(join(tmp.current, "supabase", "database", "configured.sql"), "utf8"), + ).toBe("select 1;"); + expect(readFileSync(configPath, "utf8")).toBe(config); + expect( + s.out.rawChunks.map((chunk) => ({ text: stripAnsi(chunk.text), stream: chunk.stream })), + ).toContainEqual({ + text: `Declarative schema written to ${destination}\n`, + stream: "stderr", + }); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("--output protects a non-empty destination without --overwrite", () => { + const destination = join(tmp.current, "staged-schema"); + mkdirSync(destination, { recursive: true }); + writeFileSync(join(destination, "keep.sql"), "select 'keep';"); + const s = setup(tmp.current, { + experimental: true, + engineImplementation: "next", + promptConfirmResponses: [false], + }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeGenerate( + flags({ local: Option.some(true), output: Option.some(destination) }), + ); + expect(readFileSync(join(destination, "keep.sql"), "utf8")).toBe("select 'keep';"); + expect(existsSync(join(destination, ".pgdelta-export.json"))).toBe(false); + expect(s.out.rawChunks.some((chunk) => chunk.text.includes("Skipped writing"))).toBe(true); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("rejects output paths that could overwrite the project directory", () => { + const sentinel = join(tmp.current, "project-sentinel.txt"); + writeFileSync(sentinel, "keep"); + const s = setup(tmp.current, { experimental: true, engineImplementation: "next" }); + return Effect.gen(function* () { + for (const output of ["", "."]) { + const exit = yield* legacyDbSchemaDeclarativeGenerate( + flags({ local: Option.some(true), output: Option.some(output), overwrite: true }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)).toMatchObject({ + _tag: "LegacyDeclarativeWriteError", + message: + "declarative output directory must not be empty or resolve to the project directory", + }); + expect(readFileSync(sentinel, "utf8")).toBe("keep"); + } + expect(s.localPostgresImageChecks).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("--output does not warm the configured legacy declarative tree", () => { + const s = setup(tmp.current, { experimental: true }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeGenerate( + flags({ local: Option.some(true), output: Option.some("staged-schema") }), + ); + expect(s.seamCalls).toEqual([]); + expect( + existsSync( + join(tmp.current, "staged-schema", "schemas", "public", "tables", "players.sql"), + ), + ).toBe(true); + expect(existsSync(join(tmp.current, "supabase", "database"))).toBe(false); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("--overwrite replaces only the absolute --output destination", () => { + const destination = mkdtempSync(join(tmpdir(), "legacy-decl-output-")); + mkdirSync(join(tmp.current, "supabase", "database"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", "database", "configured.sql"), "select 1;"); + writeFileSync(join(destination, "stale.sql"), "select 'stale';"); + const s = setup(tmp.current, { experimental: true, engineImplementation: "next" }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeGenerate( + flags({ + local: Option.some(true), + output: Option.some(destination), + overwrite: true, + }), + ); + expect(existsSync(join(destination, "stale.sql"))).toBe(false); + expect(existsSync(join(destination, ".pgdelta-export.json"))).toBe(true); + expect( + readFileSync(join(tmp.current, "supabase", "database", "configured.sql"), "utf8"), + ).toBe("select 1;"); + expect( + s.out.rawChunks.map((chunk) => ({ text: stripAnsi(chunk.text), stream: chunk.stream })), + ).toContainEqual({ + text: `Declarative schema written to ${destination}\n`, + stream: "stderr", + }); + rmSync(destination, { recursive: true, force: true }); + }).pipe(Effect.provide(s.layer)); + }); + it.effect("explicit --local checks the local Postgres image before generating", () => { const s = setup(tmp.current, { experimental: true, staleLocalImage: true }); return Effect.gen(function* () { @@ -603,25 +791,20 @@ describe("legacy db schema declarative generate integration", () => { }).pipe(Effect.provide(s.layer)); }); - it.effect("explicit --linked builds the baseline catalog from the remote-merged config", () => { - // Go loads the [remotes.] override before building the baseline catalog, so - // the seam's baseline export must carry the resolved ref (SUPABASE_PROJECT_ID) to - // trigger that merge. Local/smart paths must NOT pass a ref. + it.effect("explicit --linked does not route the export source through the Go seam", () => { const ref = "abcdefghijklmnopqrst"; const s = setup(tmp.current, { experimental: true, projectId: Option.some(ref) }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeGenerate(flags({ linked: Option.some(true) })); - const baseline = s.seamExportCalls.find((c) => c.mode === "baseline"); - expect(baseline?.projectRef).toBe(ref); + expect(s.seamExportCalls.some((call) => call.mode === "baseline")).toBe(false); }).pipe(Effect.provide(s.layer)); }); - it.effect("explicit --local builds the baseline catalog without a project ref", () => { + it.effect("explicit --local keeps raw-shadow export independent of linked state", () => { const s = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })); - const baseline = s.seamExportCalls.find((c) => c.mode === "baseline"); - expect(baseline?.projectRef).toBeUndefined(); + expect(s.seamExportCalls.some((call) => call.mode === "baseline")).toBe(false); // No linked ref resolved → no linked-project cache write (Go gates on ProjectRef). expect(s.cache.cached).toBe(false); }).pipe(Effect.provide(s.layer)); @@ -642,8 +825,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 (baseline built, local URL) ... - expect(s.seamCalls).toContain("baseline"); + // Took the explicit local target and completed the optional legacy warm ... + expect(s.seamCalls).toContain("declarative"); // ... but did NOT auto-start (value is false). expect(s.ensureStartedCalls).toBe(0); expect(s.localPostgresImageChecks).toHaveLength(1); @@ -720,7 +903,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(["baseline", "declarative"]); + expect(s.seamCalls).toEqual(["declarative"]); // 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`). @@ -744,7 +927,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(["baseline", "declarative"]); + expect(s.seamCalls).toEqual(["declarative"]); expect(stripAnsi(s.out.stderrText)).toContain( `Declarative schema already exists at ${join("supabase", "database")}. Regenerate from database? This will overwrite existing files. [y/N] y\n`, ); @@ -763,8 +946,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(true), noCache: true })); - // --no-cache skips the post-write warm, so only the baseline export runs. - expect(s.seamCalls).toEqual(["baseline"]); + // --no-cache skips the post-write warm; the raw source never uses the seam. + expect(s.seamCalls).toEqual([]); }).pipe(Effect.provide(s.layer)); }); @@ -1043,4 +1226,21 @@ describe("legacy db schema declarative generate integration", () => { expect(s.edgeCalls[0]!.env["TARGET"]).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" }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })); + const manifest = JSON.parse( + readFileSync(join(tmp.current, "supabase", "database", ".pgdelta-export.json"), "utf8"), + ); + expect(manifest).toMatchObject({ + formatVersion: 1, + redactSecrets: true, + scope: "database", + files: ["schemas/public/tables/players.sql"], + }); + expect(s.seamCalls).toEqual([]); + }).pipe(Effect.provide(s.layer)); + }); }); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.layers.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.layers.ts index 61eaf96544..400358292c 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.layers.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.layers.ts @@ -2,6 +2,7 @@ import { Layer } from "effect"; import { commandRuntimeLayer } from "../../../../../../shared/runtime/command-runtime.layer.ts"; import { stdinLayer } from "../../../../../../shared/runtime/stdin.layer.ts"; +import { legacyHttpClientLayer } from "../../../../../auth/legacy-http-debug.layer.ts"; import { legacyCliConfigLayer } from "../../../../../config/legacy-cli-config.layer.ts"; import { legacyDbConfigLayer } from "../../../../../shared/legacy-db-config.layer.ts"; import { legacyDbConnectionLayer } from "../../../../../shared/legacy-db-connection.layer.ts"; @@ -13,15 +14,19 @@ import { legacyLinkedDbResolverRuntimeLayer } from "../../../../../shared/legacy import { legacyPgDeltaSslProbeLayer } from "../../../../../shared/legacy-pgdelta-ssl-probe.layer.ts"; import { legacyTelemetryStateLayer } from "../../../../../telemetry/legacy-telemetry-state.layer.ts"; import { legacyDeclarativeSeamLayer } from "../../../shared/legacy-pgdelta.seam.layer.ts"; +import { legacyPgDeltaEngineLayer } from "../../../shared/legacy-pgdelta-engine.layer.ts"; +import { legacyPgDeltaNextAdapterLayer } from "../../../shared/legacy-pgdelta-next-adapter.layer.ts"; +import { legacyPgDeltaNextShadowLayer } from "../../../shared/legacy-pgdelta-next-shadow.layer.ts"; /** * Runtime layer for `supabase db schema declarative generate`. * * `Output` / `LegacyGoProxy` / global flags come from the legacy root; the Bun * platform (FileSystem / Path / ChildProcessSpawner / ProcessControl / Tty) from - * `runCli`. This layer adds the declarative-specific services: the edge-runtime - * pg-delta runner and the Go shadow-database seam, plus the db-config resolver - * for `--linked` / `--db-url`. Per the "provide doesn't share to siblings" rule, + * `runCli`. This layer adds both pg-delta implementations, the native shadow + * runtime, and the db-config resolver for `--linked` / `--db-url`. + * The bundled implementation runs in-process by default; edge-runtime is retained + * only for the explicit legacy opt-out. Per the "provide doesn't share to siblings" rule, * `LegacyCliConfig` is provided to every layer that needs it. `legacyDockerRunLayer` * is ALSO exposed directly (not just provided to `edgeRuntime`): the smart-target * local-reset prompt now calls `legacyResetLocalDatabase` in-process (CLI-2062), @@ -45,7 +50,25 @@ const edgeRuntime = legacyEdgeRuntimeScriptLayer.pipe( Layer.provide(cliConfig), ); +const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); const seam = legacyDeclarativeSeamLayer.pipe(Layer.provide(cliConfig)); +const nextShadow = legacyPgDeltaNextShadowLayer.pipe( + Layer.provide(legacyDockerRunLayer), + Layer.provide(legacyDbConnectionLayer), + Layer.provide(httpClient), +); +const pgDeltaEngine = legacyPgDeltaEngineLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyPgDeltaNextAdapterLayer), + Layer.provide(nextShadow), + Layer.provide(edgeRuntime), + Layer.provide(legacyPgDeltaSslProbeLayer), + Layer.provide(seam), + Layer.provide(legacyDockerRunLayer), + Layer.provide(legacyDbConnectionLayer), + Layer.provide(httpClient), + Layer.provide(legacyDebugLoggerLayer), +); export const legacyDbSchemaDeclarativeGenerateRuntimeLayer = Layer.mergeAll( dbConfig, @@ -54,6 +77,8 @@ export const legacyDbSchemaDeclarativeGenerateRuntimeLayer = Layer.mergeAll( edgeRuntime, legacyPgDeltaSslProbeLayer, seam, + pgDeltaEngine, + httpClient, cliConfig, legacyIdentityStitchLayer, legacyTelemetryStateLayer, 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 522daa9e92..3b7f3c32a7 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,44 +3,74 @@ Diffs local migrations state against declarative schema files and writes the delta as a new timestamped migration. +## Pg-delta implementation and compatibility + +- The default pg-delta and bundled pg-topo run in-process at the versions fixed + when the CLI is built. There is no runtime download or automatic fallback. +- `SUPABASE_USE_PG_DELTA_NEXT=false` selects the legacy catalog/edge-runtime + implementation from either the shell or project `supabase/.env` (the shell + wins). `supabase/.temp/pgdelta-version`, `PGDELTA_NPM_REGISTRY`, and + catalogs directly below `supabase/.temp/pgdelta/` are legacy-only. +- `--no-cache` bypasses legacy catalog reuse/warming. The default engine always + extracts current state and maintains no reusable catalog cache. +- With `PGDELTA_DEBUG`, default-engine snapshots, plan, and diagnostics are + written below `supabase/.temp/pgdelta/v2/debug//` and are not reusable. +- The default engine always refuses extraction or declarative-loading errors. + Fatal diagnostics are always shown. By default, `unmodeled_kind` coverage gaps + are summarized once while nonfatal internal diagnostics remain quiet; + `--strict-coverage` refuses coverage gaps and prints the exact blockers. Debug + mode prints every diagnostic. Artifacts are saved before policy evaluation. +- Default-engine migrations may differ byte-for-byte and may be split into + ordered files to preserve transaction boundaries. Successful execution and an + empty subsequent sync are the compatibility contract. +- Default-engine migrations use pg-delta's human-facing formatter (lowercase + keywords, max width 180) after safe plan compaction. A JSON object in + `[experimental.pgdelta].format_options` partially overrides the formatter; + the JSON literal `null` disables formatting without disabling compaction. + ## Files Read -| Path | Format | When | -| -------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always — pg-delta gate, format options | -| `/supabase/.temp/pgdelta-version` | plain text | always — pins the `@supabase/pg-delta` npm version | -| `/supabase/.temp/edge-runtime-version` | plain text | always — pins the edge-runtime image tag | -| `/supabase/database/**/*.sql` (declarative dir) | SQL | always — must exist (else error) | -| `/supabase/migrations/*.sql` | SQL | migrations-catalog resolution (native, CLI-1959) — hashed for the cache key and, on a miss, replayed onto a natively-provisioned shadow (CLI-1956) | -| `/supabase/roles.sql` | SQL | native migrations-catalog cache key (setup-inputs token; empty when absent) | -| `/supabase/.temp/pgdelta/*.json` | JSON | migrations catalog cache (native, CLI-1959); declarative catalog cache (still the Go seam) | +| Path | Format | When | +| -------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always — pg-delta gate, format options | +| `/supabase/.temp/pgdelta-version` | plain text | always read for compatibility; affects legacy only | +| `/supabase/.temp/edge-runtime-version` | plain text | legacy opt-out only — edge-runtime image tag | +| `/supabase/database/**/*.sql` (declarative dir) | SQL | always — must exist (else error) | +| `/supabase/migrations/*.sql` | SQL | default: applied to live shadow; legacy: native migrations-catalog resolution/cache | +| `/supabase/roles.sql` | SQL | legacy migrations-catalog cache key (empty when absent) | +| `/supabase/database/.pgdelta-export.json` | JSON | default-engine export policy, when present | +| `/supabase/.temp/pgdelta/*.json` | JSON | legacy opt-out only: migrations/declarative catalog cache | ## Files Written -| Path | Format | When | -| --------------------------------------------------------------- | ------ | --------------------------------------------------- | -| `/supabase/migrations/_.sql` | SQL | when schema changes are found | -| `/supabase/.temp/pgdelta/catalog-*-migrations-*.json` | JSON | migrations catalog cache write (native, CLI-1959) | -| `/supabase/.temp/pgdelta/catalog-*-declarative-*.json` | JSON | declarative catalog cache write (still the Go seam) | +| Path | Format | When | +| ------------------------------------------------------------------ | ------ | ---------------------------------------------------- | +| `/supabase/migrations/_[_].sql` | SQL | changes; default engine may emit ordered segments | +| `/supabase/database/extension.sql` | SQL | interactive, explicit legacy-extension repair only | +| `/supabase/.temp/pgdelta/catalog-*.json` | JSON | legacy opt-out only: native/Go-backed catalog caches | +| `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | default engine with `PGDELTA_DEBUG` | ## Subprocesses / Containers -| What | When | -| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -| Natively-provisioned shadow Postgres container (CLI-1956 — `legacyCreateShadowDatabase`/`legacyPrepareShadowSource`, no longer a `supabase-go db __shadow` subprocess) + native migrate; the catalog itself is exported natively via edge-runtime (CLI-1959 — no longer the hidden `db schema declarative __catalog --mode migrations` subprocess) | migrations-catalog cache miss only | -| `supabase-go db schema declarative __catalog --mode declarative --experimental` (seam) — shadow Postgres + `SetupDatabase` + apply declarative → catalog | always | -| Edge-runtime container running the pg-delta diff Deno script, and (on a migrations-catalog cache miss) the pg-delta catalog-export Deno script | always / cache miss | -| `docker`/`podman` container recreate for the local `db` (+ satellite restarts, Kong reload) — the same primitives `db start`/`db reset` use, via `legacyResetLocalDatabase` (CLI-2062: in-process, no `supabase-go` child) — only on the failed-apply recovery path | TTY only, apply failed, and the user confirms "reset and reapply" | +| What | When | +| --------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| Two scoped, natively-provisioned shadow Postgres databases: migrated source and declarative target | default engine | +| Natively-provisioned raw shadow used as the declarative export source | no files, bootstrap generation accepted, legacy opt-out | +| Natively-provisioned migrated shadow plus native migration replay and catalog export | legacy opt-out, migrations-catalog cache miss | +| `supabase-go db schema declarative __catalog --mode declarative --experimental` — declarative catalog target | legacy opt-out | +| Edge-runtime container running pg-delta diff/catalog-export scripts | legacy opt-out | +| `docker`/`podman` container recreate for local `db` (+ satellite restarts, Kong reload) via in-process `legacyResetLocalDatabase` | TTY only, apply failed, and the user confirms "reset and reapply" | ## Environment Variables -| Variable | Purpose | Required? | -| ---------------------------- | ----------------------------------------------------------- | --------- | -| `PGDELTA_NPM_REGISTRY` | private `@supabase` npm registry for pg-delta | no | -| `PGDELTA_DEBUG` | verbose pg-delta diagnostics | no | -| `SUPABASE_GO_BINARY` | override the `supabase-go` seam binary | no | -| `SUPABASE_SERVICES_HOSTNAME` | local DB host for the bootstrap generate (Go `GetHostname`) | no | -| `DOCKER_HOST` | tcp daemon host used as the local DB host fallback | no | +| Variable | Purpose | Required? | +| ---------------------------- | ------------------------------------------------------- | --------- | +| `SUPABASE_USE_PG_DELTA_NEXT` | set to `false` for the legacy edge-runtime engine | no | +| `PGDELTA_NPM_REGISTRY` | legacy opt-out only: private npm registry | no | +| `PGDELTA_DEBUG` | structured default-engine debug artifacts | no | +| `SUPABASE_GO_BINARY` | override the `supabase-go` seam binary | no | +| `SUPABASE_SERVICES_HOSTNAME` | local DB host for native bootstrap shadow orchestration | no | +| `DOCKER_HOST` | tcp daemon host used as the local DB host fallback | no | ## Exit Codes @@ -50,8 +80,9 @@ as a new timestamped migration. | `1` | pg-delta not enabled | | `1` | conflicting `--apply`/`--no-apply` (mutually exclusive) | | `1` | no declarative schema files found | -| `1` | shadow-database / edge-runtime / diff failure | +| `1` | shadow-database / selected pg-delta engine / diff failure | | `1` | apply failure (when applied) — propagated from the native migration apply (`applyMigrationToLocal`) | +| `1` | repairable legacy extension omissions in non-interactive mode | 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: Go's @@ -64,15 +95,35 @@ surfaces before an `--apply`/`--no-apply` conflict is ever checked. 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, Go's -`GetDeclarativeDir()`) to stderr after generating, writing, and warming the -catalog cache — on both the interactive-accept and `--yes` paths. +`GetDeclarativeDir()`) to stderr after generation and writing. Under the legacy +opt-out it prints after catalog warming — 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. +Before writing a migration, a manifest-less legacy tree that would remove only +`pgcrypto`, `uuid-ossp`, or `pg_net` offers three explicit choices: append the +detected declarations to root `extension.sql` and re-plan, continue with the +removals, or cancel. The repair uses `CREATE EXTENSION IF NOT EXISTS ... WITH +SCHEMA "extensions"`, never overwrites existing SQL, never creates a next-export +manifest, and proceeds only when the re-plan removes the compatibility gap. +Non-interactive execution, including global `--yes`, does not modify declarations +and stops with the exact SQL to add. + ## Notes - Requires `--experimental` or `[experimental.pgdelta] enabled = true`. +- The declarative directory is the complete, hand-authored desired state. An + object omitted from it is intended to be removed, including extensions. This + is deterministic regardless of whether the directory was generated, written + by hand, or has a `.pgdelta-export.json` manifest. +- For gaps involving unknown extensions or extension-managed state such as + `pg_cron` jobs, generate a staged next-compatible tree with + `generate --output supabase/database-next`, review it, and adopt or + merge it explicitly. `--output` neither changes `config.toml` nor activates the + staged tree. +- The targeted `extension.sql` repair preserves detected installed extensions; + it does not certify the legacy tree as a complete pg-delta next export. - `--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. @@ -82,7 +133,9 @@ are mutually exclusive. (the reset itself is native too — `legacyResetLocalDatabase`, CLI-2062 — run in-process, sharing this command's own telemetry/linked-project-cache finalizer cycle rather than firing a second one from a `supabase-go` child). -- **Architecture:** the migrations-catalog diff source resolves natively (CLI-1959): +- **Architecture:** the default engine uses two scoped live shadow databases and + plans/renders in-process. Under the legacy opt-out, the migrations-catalog diff + source resolves natively (CLI-1959): the setup-inputs-folded cache key, the zero-local-migrations → platform-baseline reuse, and the pg-delta catalog export are all native TS; the shadow-database platform-baseline provisioning + migrations apply is native too now (CLI-1956 — @@ -91,5 +144,5 @@ are mutually exclusive. declarative-catalog diff target still provisions its shadow-database platform baseline (and applies declarative files) via the hidden `db schema declarative __catalog --mode declarative` seam, since neither a baseline-only shadow nor - `pgdelta.ApplyDeclarative` has a native TS port yet (tracked by CLI-1823). The diff - itself is native pg-delta either way. + `pgdelta.ApplyDeclarative` has a native TS port yet (tracked by CLI-1823). The + legacy opt-out still runs its diff through the edge-runtime Deno script. diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.command.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.command.ts index db9da924de..5ca71bd824 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.command.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.command.ts @@ -52,20 +52,28 @@ const config = { // so the handler input merges it in alongside the leaf's own flags. export type LegacyDbSchemaDeclarativeSyncFlags = CliCommand.Command.Config.Infer & { readonly noCache: boolean; + readonly strictCoverage: boolean; }; export const legacyDbSchemaDeclarativeSyncCommand = Command.make("sync", config).pipe( - Command.withDescription("Generate a new migration from declarative schema."), + Command.withDescription( + "Compares the supabase/migrations baseline with the complete declarative schema tree and writes the difference as migration files. When a legacy export omits known implicit extensions, interactive sync can add declarations and re-plan before writing. Use --no-apply for non-interactive generation without changing the local database; --apply or global --yes applies locally and updates local migration history.", + ), Command.withShortDescription("Generate a new migration from declarative schema"), Command.withHandler((flags) => Effect.gen(function* () { // `--no-cache` is shared on the parent group; read the resolved value there. const shared = yield* legacyDbSchemaDeclarativeSharedBase; - const merged: LegacyDbSchemaDeclarativeSyncFlags = { ...flags, noCache: shared.noCache }; + const merged: LegacyDbSchemaDeclarativeSyncFlags = { + ...flags, + noCache: shared.noCache, + strictCoverage: shared.strictCoverage, + }; return yield* legacyDbSchemaDeclarativeSync(merged).pipe( withLegacyCommandInstrumentation({ flags: { "no-cache": merged.noCache, + "strict-coverage": merged.strictCoverage, schema: merged.schema, file: merged.file, name: merged.name, 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 a5bb4bc4d2..096e062675 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 @@ -28,8 +28,13 @@ import { legacyPgDeltaTempPath, legacyResolveSetupInputs, } from "../../../../../shared/legacy-pgdelta.cache.ts"; -import { legacyResolvePgDeltaProjectId } from "../../../../../shared/legacy-pgdelta.ts"; -import { legacyResolveSmartTargetUrl } from "../declarative.smart-target.ts"; +import { LegacyPgDeltaEngine } from "../../../shared/legacy-pgdelta-engine.service.ts"; +import { + legacyIsPgDeltaDebugEnabled, + legacyResolvePgDeltaProjectId, +} from "../../../../../shared/legacy-pgdelta.ts"; +import { legacyWritePgDeltaMigrations } from "../../../shared/legacy-pgdelta-migrations.write.ts"; +import { legacyResolveSmartTargetEndpoint } from "../declarative.smart-target.ts"; import { type LegacyDebugBundle, legacyCollectMigrationsList, @@ -39,15 +44,20 @@ import { } from "../../../shared/legacy-debug-bundle.ts"; import { LegacyDeclarativeApplyError, + LegacyDeclarativeCompatibilityError, LegacyDeclarativeMutuallyExclusiveFlagsError, LegacyDeclarativeNoFilesGeneratedError, LegacyDeclarativeNonInteractiveError, legacyReadErrorSuggestion, } from "../declarative.errors.ts"; import { + legacyClassifyDeclarativeCompatibilityGap, + legacyExtensionDeclaration, + legacyFormatStagedExportRecommendation, legacyResolveDeclarativeMigrationName, legacyResolveDeclarativeSyncApplyDecision, } from "../declarative.flow.ts"; +import { legacyAppendExtensionDeclarations } from "../declarative.extension-repair.ts"; import { legacyRequirePgDelta } from "../declarative.gate.ts"; import { type LegacyDeclarativeRunContext, @@ -94,6 +104,7 @@ 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 @@ -160,6 +171,9 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara declarativeDir, schema: flags.schema, noCache: flags.noCache, + debug: legacyIsPgDeltaDebugEnabled(), + strictCoverage: flags.strictCoverage, + dnsResolver, }; const ensureLocalPostgresImageCurrent = seam.ensureLocalPostgresImageCurrent(); const declarativeFilesExist = yield* declarativeDirHasFiles(fs, declarativeDir); @@ -233,7 +247,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara } // sync has no target flags (Go passes its target-less `cmd` into generate), // so reset stays interactive (the prompt fires under the local choice). - const targetUrl = yield* legacyResolveSmartTargetUrl( + const target = yield* legacyResolveSmartTargetEndpoint( { dbUrl: Option.none(), linked: Option.none(), password: Option.none(), reset: false }, { port: toml.port, password: toml.password }, hasMigrations, @@ -243,7 +257,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara linkedRef, ensureLocalPostgresImageCurrent, ); - const generated = yield* legacyGenerateDeclarativeOutput(run, targetUrl); + const generated = yield* legacyGenerateDeclarativeOutput(run, toml, target); yield* legacyWriteDeclarativeSchemas(fs, path, declarativeDir, generated); if (!(yield* declarativeDirHasFiles(fs, declarativeDir))) { return yield* Effect.fail( @@ -259,7 +273,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara // 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) { + 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 @@ -283,29 +297,101 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara Option.getOrUndefined(toml.orioledbVersion), toml.baseline, ); - const result: LegacyDeclarativeSyncResult = yield* legacyDiffDeclarativeToMigrations( - run, - toml, - setupInputs, - ).pipe( - Effect.tapError((error) => - Effect.gen(function* () { - const migrations = yield* legacyCollectMigrationsList(fs, path, migrationsDir); - yield* legacySaveDebugBundle(fs, path, cliConfig.workdir, tempDir, migrationsDir, { - id: formatDebugId(yield* Clock.currentTimeMillis), - error: error.message, - migrations, - }).pipe( - Effect.matchEffect({ - // Go prints nothing when SaveDebugBundle errors on the diff path - // (`db_schema_declarative.go:337-340`: `if saveErr == nil`). - onFailure: () => Effect.void, - onSuccess: (debugDir) => output.raw(legacyDebugBundleMessage(debugDir), "stderr"), + const planDeclarativeSync = () => + legacyDiffDeclarativeToMigrations(run, toml, setupInputs).pipe( + Effect.tapError((error) => + Effect.gen(function* () { + const migrations = yield* legacyCollectMigrationsList(fs, path, migrationsDir); + yield* legacySaveDebugBundle(fs, path, cliConfig.workdir, tempDir, migrationsDir, { + id: formatDebugId(yield* Clock.currentTimeMillis), + error: error.message, + migrations, + }).pipe( + Effect.matchEffect({ + // Go prints nothing when SaveDebugBundle errors on the diff path + // (`db_schema_declarative.go:337-340`: `if saveErr == nil`). + onFailure: () => Effect.void, + onSuccess: (debugDir) => output.raw(legacyDebugBundleMessage(debugDir), "stderr"), + }), + ); + }), + ), + ); + let result: LegacyDeclarativeSyncResult = yield* planDeclarativeSync(); + + // Resolve manifest-less legacy compatibility before printing or writing a + // migration. A repair is always explicit, even when global --yes is set. + const compatibility = legacyClassifyDeclarativeCompatibilityGap({ + implementation: engine.implementation, + manifestPresent: result.manifestPresent, + removals: result.removals, + }); + if (compatibility.recommendedAction === "repair-extensions") { + const statements = compatibility.repairableExtensions.map(legacyExtensionDeclaration); + const explanation = [ + "This declarative schema appears to use legacy pg-delta behavior. Legacy pg-delta treated these installed extensions as implicit, while pg-delta next treats their omission as removal:", + "", + ...compatibility.repairableExtensions.map((extension) => `- ${extension}`), + ].join("\n"); + if (!tty.stdinIsTty || yes) { + return yield* Effect.fail( + new LegacyDeclarativeCompatibilityError({ + message: [ + explanation, + "", + "Non-interactive sync will not modify the declarative schema automatically. Add these statements to extension.sql, then run sync again:", + ...statements, + "", + "Or generate a next-compatible schema into a separate directory:", + "supabase db schema declarative generate --output supabase/database-next", + ].join("\n"), + }), + ); + } + + yield* output.raw(`${legacyYellow(explanation)}\n`, "stderr"); + const choice = yield* output.promptSelect("How would you like to continue?", [ + { + value: "repair", + label: "Add declarations and re-plan", + hint: "recommended", + }, + { value: "continue", label: "Continue with removals" }, + { value: "cancel", label: "Cancel" }, + ]); + if (choice === "cancel") return; + if (choice === "repair") { + const repaired = yield* legacyAppendExtensionDeclarations( + declarativeDir, + compatibility.repairableExtensions, + ); + yield* output.raw( + `Updated ${legacyBold(repaired.path)} with:\n${repaired.addedDeclarations.join("\n")}\n`, + "stderr", + ); + result = yield* planDeclarativeSync(); + const remaining = legacyClassifyDeclarativeCompatibilityGap({ + implementation: engine.implementation, + manifestPresent: result.manifestPresent, + removals: result.removals, + }); + if (remaining.recommendedAction !== "none") { + return yield* Effect.fail( + new LegacyDeclarativeCompatibilityError({ + message: [ + "The compatibility removals remain after adding extension declarations.", + legacyFormatStagedExportRecommendation(remaining), + ].join("\n"), }), ); - }), - ), - ); + } + } + } else if (compatibility.recommendedAction === "stage-next-export") { + yield* output.raw( + `${legacyYellow(legacyFormatStagedExportRecommendation(compatibility))}\n`, + "stderr", + ); + } // Step 3: empty diff. if (result.diffSQL.trim().length < 2) { @@ -327,11 +413,28 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara } // Step 5: write the timestamped migration file. - const timestamp = formatTimestamp(yield* Clock.currentTimeMillis); - const migrationPath = path.join(migrationsDir, `${timestamp}_${migrationName}.sql`); - yield* legacyMakeDir(fs, migrationsDir); - yield* fs.writeFileString(migrationPath, result.diffSQL); - yield* output.raw(`Created new migration at ${legacyBold(migrationPath)}\n`, "stderr"); + const nowMillis = yield* Clock.currentTimeMillis; + let migrationPaths: ReadonlyArray; + if (engine.implementation === "next" && result.files.length > 1) { + const written = yield* legacyWritePgDeltaMigrations(fs, path, { + workdir: cliConfig.workdir, + baseMillis: nowMillis, + name: migrationName, + files: result.files, + }).pipe( + Effect.mapError((error) => new LegacyDeclarativeApplyError({ message: error.message })), + ); + migrationPaths = written.map((migration) => migration.path); + } else { + const timestamp = formatTimestamp(nowMillis); + const migrationPath = path.join(migrationsDir, `${timestamp}_${migrationName}.sql`); + yield* legacyMakeDir(fs, migrationsDir); + yield* fs.writeFileString(migrationPath, result.diffSQL); + migrationPaths = [migrationPath]; + } + for (const migrationPath of migrationPaths) { + yield* output.raw(`Created new migration at ${legacyBold(migrationPath)}\n`, "stderr"); + } // Step 6: drop warnings. if (result.dropWarnings.length > 0) { @@ -365,7 +468,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara yield* ensureLocalPostgresImageCurrent; const applyExit = yield* applyMigrationToLocal( { port: toml.port, password: toml.password, dnsResolver }, - migrationPath, + migrationPaths, ).pipe(Effect.exit); if (Exit.isSuccess(applyExit)) { @@ -492,10 +595,10 @@ const declarativeDirHasFiles = Effect.fnUntraced(function* ( return entries.length > 0; }); -/** Connects to the local database and applies the single migration file (Go's `applyMigrationToLocal`). */ +/** Connects once and applies the ordered migration files (Go's `applyMigrationToLocal`). */ const applyMigrationToLocal = ( local: { port: number; password: string; dnsResolver: "native" | "https" }, - migrationPath: string, + migrationPaths: ReadonlyArray, ) => Effect.gen(function* () { const dbConnection = yield* LegacyDbConnection; @@ -520,11 +623,13 @@ const applyMigrationToLocal = ( (error) => new LegacyDeclarativeApplyError({ message: error.message, connect: true }), ), ); - yield* legacyApplyMigrationFile( - session, - fs, - path, - migrationPath, - (message) => new LegacyDeclarativeApplyError({ message }), - ); + for (const migrationPath of migrationPaths) { + yield* legacyApplyMigrationFile( + session, + fs, + path, + migrationPath, + (message) => new LegacyDeclarativeApplyError({ message }), + ); + } }).pipe(Effect.scoped); 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 de97ff9464..39606729df 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 @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; @@ -47,6 +47,12 @@ 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, + type LegacyPgDeltaRemovalSummary, + type LegacyPgDeltaRenderedFile, +} 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 { LegacyDbSchemaDeclarativeSyncFlags } from "./sync.command.ts"; @@ -71,6 +77,7 @@ interface SetupOpts { yes?: boolean; stdinIsTty?: boolean; diffSql?: string; + replannedDiffSql?: string; applyFails?: boolean; /** * Makes the recovery reset's `legacyResetLocalDatabase` fail immediately with @@ -85,6 +92,9 @@ interface SetupOpts { projectId?: Option.Option; staleLocalImage?: boolean; exportJson?: string; + engineImplementation?: "legacy" | "next"; + renderedFiles?: ReadonlyArray; + removals?: LegacyPgDeltaRemovalSummary; } function setup(workdir: string, opts: SetupOpts = {}) { @@ -218,31 +228,106 @@ function setup(workdir: string, opts: SetupOpts = {}) { }), resolvePoolerFallback: () => Effect.succeed(Option.none()), }); + const sslProbe = Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.succeed(false), + requireSslForHost: () => Effect.succeed(false), + }); + const runtimeInfo = mockRuntimeInfo({ platform: "linux" }); + const processControl = mockProcessControl(); + const experimentalFlag = Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? true); + const cliArgs = Layer.succeed(CliArgs, { + args: opts.args ?? ["db", "schema", "declarative", "sync"], + }); + const networkIdFlag = Layer.succeed( + LegacyNetworkIdFlag, + opts.networkId === undefined ? Option.none() : Option.some(opts.networkId), + ); + const debugFlag = Layer.succeed(LegacyDebugFlag, false); + const dockerRun = legacyDockerRunLayer.pipe( + 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 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: () => + Effect.succeed({ + files: [ + { name: "schemas/public/tables/players.sql", sql: "create table players ();" }, + ], + manifest: { redactSecrets: true, scope: "database", profile: "supabase" }, + }), + planDeclarativeSchema: () => { + const extensionPath = join(workdir, "supabase", "database", "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 layer = Layer.mergeAll( out.layer, telemetry.layer, cache.layer, seam, edge, + engine, dbConn, resolver, mockLegacyCliConfig({ workdir, projectId: opts.projectId ?? Option.some("test") }), mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), mockStdin(opts.stdinIsTty ?? false), - Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? true), - Layer.succeed(CliArgs, { args: opts.args ?? ["db", "schema", "declarative", "sync"] }), + experimentalFlag, + cliArgs, Layer.succeed(LegacyYesFlag, opts.yes ?? false), - Layer.succeed( - LegacyNetworkIdFlag, - opts.networkId === undefined ? Option.none() : Option.some(opts.networkId), - ), + networkIdFlag, Layer.succeed(LegacyDnsResolverFlag, "native"), - Layer.succeed(LegacyDebugFlag, false), + debugFlag, // Sync diffs against the local DB, which refuses TLS → no SSL env injected. - Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }), + sslProbe, // The local-reset bucket-seed core statically requires the (lazy) Management-API // factory; never invoked on the local recovery reset (projectRef === ""). Layer.succeed(LegacyPlatformApiFactory, { @@ -253,13 +338,10 @@ function setup(workdir: string, opts: SetupOpts = {}) { // resolves a duplicate service tag to whichever layer is listed LAST, so this // mock overrides Bun's real `ChildProcessSpawner` instead of the reverse. child.layer, - mockRuntimeInfo({ platform: "linux" }), - mockProcessControl().layer, + runtimeInfo, + processControl.layer, alwaysReadyHttpClientLayer, - legacyDockerRunLayer.pipe( - Layer.provide(child.layer), - Layer.provide(mockProcessControl().layer), - ), + dockerRun, ); return { layer, @@ -277,6 +359,7 @@ const flags = ( over: Partial = {}, ): LegacyDbSchemaDeclarativeSyncFlags => ({ noCache: over.noCache ?? false, + strictCoverage: over.strictCoverage ?? false, schema: over.schema ?? [], file: over.file ?? Option.none(), name: over.name ?? Option.none(), @@ -892,6 +975,136 @@ describe("legacy db schema declarative sync integration", () => { }, ); + it.effect( + "recommends a staged next export before writing for extension-managed legacy gaps", + () => { + seedDeclarative(tmp.current); + const s = setup(tmp.current, { + experimental: true, + engineImplementation: "next", + diffSql: + "select cron.unschedule('refresh download metrics');\nDROP EXTENSION \"pgcrypto\";\n", + removals: { + extensions: ["pgcrypto", "uuid-ossp"], + extensionIntents: [ + { extension: "pg_cron", intentKind: "job", key: "refresh download metrics" }, + ], + }, + }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); + const chunks = s.out.rawChunks.map((chunk) => stripAnsi(chunk.text)); + const warningAt = chunks.findIndex((chunk) => + chunk.includes("legacy export did not represent"), + ); + const createdAt = chunks.findIndex((chunk) => chunk.includes("Created new migration at")); + expect(warningAt).toBeGreaterThan(-1); + expect(chunks[warningAt]).toContain("pg_cron job refresh download metrics"); + expect(chunks[warningAt]).toContain("--output supabase/database-next"); + expect(warningAt).toBeLessThan(createdAt); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect("adds detected legacy extension declarations and re-plans before writing", () => { + seedDeclarative(tmp.current); + const s = setup(tmp.current, { + engineImplementation: "next", + stdinIsTty: true, + diffSql: 'DROP EXTENSION "pg_net";\n', + replannedDiffSql: "", + removals: { extensions: ["pg_net"], extensionIntents: [] }, + promptSelectResponses: ["repair"], + }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); + expect(readFileSync(join(tmp.current, "supabase", "database", "extension.sql"), "utf8")).toBe( + 'CREATE EXTENSION IF NOT EXISTS "pg_net" WITH SCHEMA "extensions";\n', + ); + expect(existsSync(join(tmp.current, "supabase", "migrations"))).toBe(false); + expect(stripAnsi(s.out.rawChunks.map((chunk) => chunk.text).join(""))).toContain( + "No schema changes found", + ); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "continues with intentional legacy extension removals only after explicit choice", + () => { + seedDeclarative(tmp.current); + const s = setup(tmp.current, { + engineImplementation: "next", + stdinIsTty: true, + diffSql: 'DROP EXTENSION "pgcrypto";\n', + removals: { extensions: ["pgcrypto"], extensionIntents: [] }, + promptSelectResponses: ["continue"], + }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); + expect(readdirSync(join(tmp.current, "supabase", "migrations"))).toHaveLength(1); + expect(existsSync(join(tmp.current, "supabase", "database", "extension.sql"))).toBe(false); + }).pipe(Effect.provide(s.layer)); + }, + ); + + 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: [] }, + promptSelectResponses: ["cancel"], + }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); + expect(existsSync(join(tmp.current, "supabase", "migrations"))).toBe(false); + expect(existsSync(join(tmp.current, "supabase", "database", "extension.sql"))).toBe(false); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("fails safely instead of repairing when sync is non-interactive", () => { + seedDeclarative(tmp.current); + const s = setup(tmp.current, { + engineImplementation: "next", + diffSql: 'DROP EXTENSION "pgcrypto";\n', + removals: { extensions: ["pgcrypto"], extensionIntents: [] }, + }); + return Effect.gen(function* () { + const exit = yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })).pipe( + Effect.exit, + ); + expect(failError(exit)).toMatchObject({ + _tag: "LegacyDeclarativeCompatibilityError", + message: expect.stringContaining( + 'CREATE EXTENSION IF NOT EXISTS "pgcrypto" WITH SCHEMA "extensions";', + ), + }); + expect(existsSync(join(tmp.current, "supabase", "migrations"))).toBe(false); + expect(existsSync(join(tmp.current, "supabase", "database", "extension.sql"))).toBe(false); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("suppresses the compatibility warning when a next export manifest is present", () => { + seedDeclarative(tmp.current); + writeFileSync( + join(tmp.current, "supabase", "database", ".pgdelta-export.json"), + JSON.stringify({ formatVersion: 1, redactSecrets: true, scope: "database" }), + ); + 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).toContain("Found drop statements"); + }).pipe(Effect.provide(s.layer)); + }); + it.effect( "--apply: applies the migration natively (BEGIN … statements … COMMIT + history)", () => { @@ -1027,4 +1240,36 @@ describe("legacy db schema declarative sync integration", () => { expect(createArgs?.[networkIndex + 1]).toBe("my_net"); }).pipe(Effect.provide(s.layer)); }); + + it.effect("next engine preserves ordered migration segments as separate files", () => { + seedDeclarative(tmp.current); + const s = setup(tmp.current, { + experimental: true, + engineImplementation: "next", + renderedFiles: [ + { + sequence: 1, + name: "transactional", + suffix: "_1", + sql: "ALTER TABLE a ADD COLUMN b int;", + transactionMode: "transactional", + }, + { + sequence: 2, + name: "non_transactional", + suffix: "_2", + sql: "ALTER TYPE mood ADD VALUE 'fine';", + transactionMode: "none", + }, + ], + }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); + const migrations = readdirSync(join(tmp.current, "supabase", "migrations")).sort(); + 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/schema/declarative/sync/sync.layers.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.layers.ts index a54b47476d..7042acf094 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.layers.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.layers.ts @@ -2,6 +2,7 @@ import { Layer } from "effect"; import { commandRuntimeLayer } from "../../../../../../shared/runtime/command-runtime.layer.ts"; import { stdinLayer } from "../../../../../../shared/runtime/stdin.layer.ts"; +import { legacyHttpClientLayer } from "../../../../../auth/legacy-http-debug.layer.ts"; import { legacyCliConfigLayer } from "../../../../../config/legacy-cli-config.layer.ts"; import { legacyDbConfigLayer } from "../../../../../shared/legacy-db-config.layer.ts"; import { legacyDbConnectionLayer } from "../../../../../shared/legacy-db-connection.layer.ts"; @@ -13,6 +14,9 @@ import { legacyLinkedDbResolverRuntimeLayer } from "../../../../../shared/legacy import { legacyPgDeltaSslProbeLayer } from "../../../../../shared/legacy-pgdelta-ssl-probe.layer.ts"; import { legacyTelemetryStateLayer } from "../../../../../telemetry/legacy-telemetry-state.layer.ts"; import { legacyDeclarativeSeamLayer } from "../../../shared/legacy-pgdelta.seam.layer.ts"; +import { legacyPgDeltaEngineLayer } from "../../../shared/legacy-pgdelta-engine.layer.ts"; +import { legacyPgDeltaNextAdapterLayer } from "../../../shared/legacy-pgdelta-next-adapter.layer.ts"; +import { legacyPgDeltaNextShadowLayer } from "../../../shared/legacy-pgdelta-next-shadow.layer.ts"; /** * Runtime layer for `supabase db schema declarative sync`. Sync diffs against the @@ -44,7 +48,25 @@ const edgeRuntime = legacyEdgeRuntimeScriptLayer.pipe( Layer.provide(cliConfig), ); +const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); const seam = legacyDeclarativeSeamLayer.pipe(Layer.provide(cliConfig)); +const nextShadow = legacyPgDeltaNextShadowLayer.pipe( + Layer.provide(legacyDockerRunLayer), + Layer.provide(legacyDbConnectionLayer), + Layer.provide(httpClient), +); +const pgDeltaEngine = legacyPgDeltaEngineLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyPgDeltaNextAdapterLayer), + Layer.provide(nextShadow), + Layer.provide(edgeRuntime), + Layer.provide(legacyPgDeltaSslProbeLayer), + Layer.provide(seam), + Layer.provide(legacyDockerRunLayer), + Layer.provide(legacyDbConnectionLayer), + Layer.provide(httpClient), + Layer.provide(legacyDebugLoggerLayer), +); export const legacyDbSchemaDeclarativeSyncRuntimeLayer = Layer.mergeAll( dbConfig, @@ -52,6 +74,8 @@ export const legacyDbSchemaDeclarativeSyncRuntimeLayer = Layer.mergeAll( edgeRuntime, legacyPgDeltaSslProbeLayer, seam, + pgDeltaEngine, + httpClient, legacyDbConnectionLayer, cliConfig, legacyIdentityStitchLayer, 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 new file mode 100644 index 0000000000..3d5cb85a5f --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.layer.ts @@ -0,0 +1,93 @@ +import { Effect, FileSystem, Layer, Path } from "effect"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; +import type * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import type { GlobalFlag } from "effect/unstable/cli"; + +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; +import { legacyLoadProjectEnv } from "../../../shared/legacy-db-config.toml-read.ts"; +import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; +import { LegacyDockerRun } from "../../../shared/legacy-docker-run.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 "./legacy-pgdelta-engine.legacy.layer.ts"; +import { legacyPgDeltaNextEngineLayer } from "./legacy-pgdelta-engine.next.layer.ts"; +import { LegacyPgDeltaEngine } from "./legacy-pgdelta-engine.service.ts"; +import { LegacyPgDeltaNextAdapter } from "./legacy-pgdelta-next-adapter.service.ts"; +import { LegacyPgDeltaNextShadow } from "./legacy-pgdelta-next-shadow.service.ts"; +import { LegacyDeclarativeSeam } from "./legacy-pgdelta.seam.service.ts"; +import { legacyResolvePgDeltaImplementation } from "../../../shared/legacy-pgdelta-next-flag.ts"; + +const FLAG = "SUPABASE_USE_PG_DELTA_NEXT"; + +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; + }, +) { + return Layer.unwrap( + Effect.gen(function* () { + const implementation = yield* resolveAndLog(raw); + return implementation === "next" ? layers.next : layers.legacy; + }), + ); +} + +/** 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 cliConfig = yield* LegacyCliConfig; + const projectEnv = yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir); + // godotenv.Load never replaces a shell value, including an empty or invalid + // one, so presence in process.env must suppress the project-file fallback. + const raw = process.env[FLAG] ?? projectEnv[FLAG]; + const implementation = yield* resolveAndLog(raw); + return selectProductionLayer(implementation); + }), +); + +function selectProductionLayer( + implementation: "next" | "legacy", +): Layer.Layer< + LegacyPgDeltaEngine, + never, + | LegacyPgDeltaNextAdapter + | LegacyPgDeltaNextShadow + | LegacyDebugLogger + | LegacyDeclarativeSeam + | LegacyEdgeRuntimeScript + | LegacyPgDeltaSslProbe + | LegacyDbConnection + | LegacyDockerRun + | FileSystem.FileSystem + | Output + | Path.Path + | RuntimeInfo + | CliArgs + | HttpClient.HttpClient + | ChildProcessSpawner.ChildProcessSpawner + | GlobalFlag.Setting.Identifier<"debug"> + | GlobalFlag.Setting.Identifier<"experimental"> + | GlobalFlag.Setting.Identifier<"network-id"> +> { + return implementation === "next" ? legacyPgDeltaNextEngineLayer : legacyPgDeltaLegacyEngineLayer; +} 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 new file mode 100644 index 0000000000..202984d1e3 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.layer.unit.test.ts @@ -0,0 +1,278 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { Effect, Exit, Layer, Option } from "effect"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import * as BunServices from "@effect/platform-bun/BunServices"; +import { it } from "@effect/vitest"; +import { afterEach, describe, expect } from "vitest"; + +import { + mockLegacyCliConfig, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.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 { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; +import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; +import { LegacyDockerRun } from "../../../shared/legacy-docker-run.service.ts"; +import { LegacyEdgeRuntimeScript } from "../../../shared/legacy-edge-runtime-script.service.ts"; +import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; +import { LegacyDeclarativeSeam } from "./legacy-pgdelta.seam.service.ts"; +import { LegacyPgDeltaNextAdapter } from "./legacy-pgdelta-next-adapter.service.ts"; +import { LegacyPgDeltaNextShadow } from "./legacy-pgdelta-next-shadow.service.ts"; +import { + legacyPgDeltaEngineLayer, + legacyPgDeltaEngineSelectorLayer, +} from "./legacy-pgdelta-engine.layer.ts"; +import { LegacyPgDeltaEngine } from "./legacy-pgdelta-engine.service.ts"; + +const FLAG = "SUPABASE_USE_PG_DELTA_NEXT"; + +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`), + }), + ); +} + +const unusedLegacyRuntime = Layer.mergeAll( + BunServices.layer, + FetchHttpClient.layer, + Layer.succeed(LegacyEdgeRuntimeScript, { + run: () => Effect.die("edge runtime not needed"), + }), + Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.die("SSL probe not needed"), + requireSslForHost: () => Effect.die("SSL probe not needed"), + }), + Layer.succeed(LegacyDeclarativeSeam, { + exportCatalog: () => Effect.die("catalog not needed"), + ensureLocalDatabaseStarted: () => Effect.die("local start not needed"), + ensureLocalPostgresImageCurrent: () => Effect.die("image check not needed"), + }), + Layer.succeed(LegacyPgDeltaNextAdapter, { + diff: () => Effect.die("adapter not needed"), + exportDeclarativeSchema: () => Effect.die("adapter not needed"), + planDeclarativeSchema: () => Effect.die("adapter not needed"), + captureSnapshot: () => Effect.die("adapter not needed"), + }), + Layer.succeed(LegacyPgDeltaNextShadow, { + provisionMigrations: () => Effect.die("next migrations shadow not needed"), + provisionPlan: () => Effect.die("next plan shadows not needed"), + }), + Layer.succeed(LegacyDbConnection, { + connect: () => Effect.die("database connection not needed"), + }), + Layer.succeed(LegacyDockerRun, { + run: () => Effect.die("docker run not needed"), + runCapture: () => Effect.die("docker capture not needed"), + runStream: () => Effect.die("docker stream not needed"), + }), + Layer.succeed(CliArgs, { args: [] }), + Layer.succeed(LegacyDebugFlag, false), + Layer.succeed(LegacyExperimentalFlag, false), + Layer.succeed(LegacyNetworkIdFlag, Option.none()), + mockRuntimeInfo(), + mockOutput().layer, +); + +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("selects legacy only for an explicit false value", () => { + const messages: Array = []; + return Effect.gen(function* () { + const engine = yield* LegacyPgDeltaEngine; + expect(engine.implementation).toBe("legacy"); + expect(messages).toEqual(["Using pg-delta legacy implementation."]); + }).pipe( + Effect.provide( + legacyPgDeltaEngineSelectorLayer("false", { + 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)), + ), + ), + ); + }); +}); + +describe("legacyPgDeltaEngineLayer", () => { + const tmp = useLegacyTempWorkdir("pgdelta-engine-selector-"); + + afterEach(() => { + delete process.env[FLAG]; + }); + + const provideProductionSelector = (messages: Array) => + legacyPgDeltaEngineLayer.pipe( + Layer.provide(unusedLegacyRuntime), + Layer.provide(debugLayer(messages)), + Layer.provide(mockLegacyCliConfig({ workdir: tmp.current })), + ); + + const writeProjectFlag = (value: string) => { + mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + writeFileSync(join(tmp.current, "supabase", ".env"), `${FLAG}=${value}\n`); + }; + + it.effect("selects legacy from the project environment when the shell is unset", () => { + const messages: Array = []; + writeProjectFlag("false"); + + return Effect.gen(function* () { + const engine = yield* LegacyPgDeltaEngine; + expect(engine.implementation).toBe("legacy"); + expect(messages).toEqual(["Using pg-delta legacy implementation."]); + }).pipe(Effect.provide(provideProductionSelector(messages))); + }); + + it.effect("prefers a true shell value over a false project value", () => { + const messages: Array = []; + process.env[FLAG] = "true"; + writeProjectFlag("false"); + + return Effect.gen(function* () { + expect((yield* LegacyPgDeltaEngine).implementation).toBe("next"); + }).pipe(Effect.provide(provideProductionSelector(messages))); + }); + + it.effect("prefers a false shell value over a true project value", () => { + const messages: Array = []; + process.env[FLAG] = "false"; + writeProjectFlag("true"); + + return Effect.gen(function* () { + expect((yield* LegacyPgDeltaEngine).implementation).toBe("legacy"); + }).pipe(Effect.provide(provideProductionSelector(messages))); + }); + + it.effect("defaults to next when neither environment defines the flag", () => { + const messages: Array = []; + return Effect.gen(function* () { + expect((yield* LegacyPgDeltaEngine).implementation).toBe("next"); + expect(messages).toEqual(["Using pg-delta next implementation."]); + }).pipe(Effect.provide(provideProductionSelector(messages))); + }); + + it.effect("reads the environment once for the command-scoped service", () => { + const messages: Array = []; + process.env[FLAG] = "false"; + + return Effect.gen(function* () { + const first = yield* LegacyPgDeltaEngine; + process.env[FLAG] = "true"; + const second = yield* LegacyPgDeltaEngine; + + expect(first).toBe(second); + expect(second.implementation).toBe("legacy"); + expect(messages).toEqual(["Using pg-delta legacy implementation."]); + }).pipe(Effect.provide(provideProductionSelector(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 new file mode 100644 index 0000000000..b1afca4f74 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.legacy.layer.ts @@ -0,0 +1,235 @@ +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 { 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, + type LegacyPgDeltaTransactionMode, +} from "./legacy-pgdelta-engine.service.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 }); + +function normalizeDiff( + result: { + readonly sql: string; + readonly stderr: string; + readonly files: ReadonlyArray<{ + readonly order: number; + readonly name: string; + readonly transactionMode: LegacyPgDeltaTransactionMode; + 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)); + + 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* () { + 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* () { + 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* () { + 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* () { + 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 new file mode 100644 index 0000000000..eac85970cb --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.integration.test.ts @@ -0,0 +1,204 @@ +import * as BunServices from "@effect/platform-bun/BunServices"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option } from "effect"; + +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; +import { LegacyDeclarativeShadowDbError } from "./legacy-pgdelta.errors.ts"; +import { legacyPgDeltaNextEngineLayer } from "./legacy-pgdelta-engine.next.layer.ts"; +import { LegacyPgDeltaEngine, LegacyPgDeltaEngineError } from "./legacy-pgdelta-engine.service.ts"; +import { LegacyPgDeltaNextAdapter } from "./legacy-pgdelta-next-adapter.service.ts"; +import { LegacyPgDeltaNextShadow } from "./legacy-pgdelta-next-shadow.service.ts"; +import type { LegacyDbTomlValues } from "../../../shared/legacy-db-config.toml-read.ts"; + +const common = { + context: { + projectId: "test", + cwd: "/tmp/test", + npmVersion: undefined, + denoVersion: 2, + projectEnv: {}, + }, + schema: ["public"], + formatOptions: "", + debug: false, + strictCoverage: false, +} as const; + +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(), + }, + 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(), +}; + +function setup() { + const state = { migrations: 0, plan: 0 }; + const shadow = Layer.succeed(LegacyPgDeltaNextShadow, { + provisionMigrations: () => + Effect.sync(() => { + state.migrations += 1; + }).pipe( + Effect.andThen( + Effect.fail(new LegacyDeclarativeShadowDbError({ message: "stop after routing" })), + ), + ), + provisionPlan: () => + Effect.sync(() => { + state.plan += 1; + }).pipe( + Effect.andThen( + Effect.fail(new LegacyDeclarativeShadowDbError({ message: "stop after routing" })), + ), + ), + }); + const unusedAdapter = Layer.succeed(LegacyPgDeltaNextAdapter, { + diff: () => Effect.die("adapter not used"), + exportDeclarativeSchema: () => Effect.die("adapter not used"), + planDeclarativeSchema: () => Effect.die("adapter not used"), + captureSnapshot: () => Effect.die("adapter not used"), + }); + const debug = Layer.succeed(LegacyDebugLogger, { + debug: () => Effect.void, + http: () => Effect.void, + }); + const dependencies = Layer.mergeAll( + BunServices.layer, + shadow, + unusedAdapter, + debug, + mockOutput().layer, + ); + return { + state, + layer: legacyPgDeltaNextEngineLayer.pipe(Layer.provide(dependencies)), + }; +} + +describe("pg-delta next shadow selection", () => { + it.effect("does not provision a second shadow for prepared database diffs", () => { + const { state, layer } = setup(); + return Effect.gen(function* () { + const engine = yield* LegacyPgDeltaEngine; + yield* engine + .diffDatabase({ + ...common, + source: { + kind: "database", + ref: "postgresql://postgres@localhost/source", + connectOptions: { isLocal: true, dnsResolver: "native" }, + }, + target: { + kind: "database", + ref: "postgresql://postgres@localhost/postgres", + connectOptions: { isLocal: true, dnsResolver: "native" }, + }, + }) + .pipe(Effect.exit); + + expect(state).toEqual({ migrations: 0, plan: 0 }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("uses only the migrated shadow for explicit migrations diffs", () => { + const { state, layer } = setup(); + return Effect.gen(function* () { + const engine = yield* LegacyPgDeltaEngine; + yield* engine + .diffExplicit({ + ...common, + toml, + source: { kind: "migrations", projectRef: "linked-project" }, + desired: { + kind: "database", + ref: "postgresql://postgres@localhost/postgres", + connectOptions: { isLocal: true, dnsResolver: "native" }, + }, + }) + .pipe(Effect.exit); + + expect(state).toEqual({ migrations: 1, plan: 0 }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("uses both isolated shadows for declarative plans", () => { + const { state, layer } = setup(); + return Effect.gen(function* () { + const engine = yield* LegacyPgDeltaEngine; + yield* engine + .planDeclarativeSchema({ + ...common, + 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: false, + vaultNames: [], + rolesSql: "", + }, + }) + .pipe(Effect.exit); + + expect(state).toEqual({ migrations: 0, plan: 1 }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("returns malformed explicit URLs as typed failures rather than defects", () => { + const { state, layer } = setup(); + return Effect.gen(function* () { + const engine = yield* LegacyPgDeltaEngine; + const error = yield* engine + .diffExplicit({ + ...common, + source: { + kind: "database", + ref: "postgresql://postgres:source-secret@[/postgres", + connectOptions: { isLocal: false, dnsResolver: "native" }, + }, + desired: { + kind: "database", + ref: "postgresql://postgres:desired-secret@[/postgres", + connectOptions: { isLocal: false, dnsResolver: "native" }, + }, + }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(LegacyPgDeltaEngineError); + expect(String(error.cause)).not.toContain("source-secret"); + expect(String(error.cause)).not.toContain("desired-secret"); + expect(state).toEqual({ migrations: 0, plan: 0 }); + }).pipe(Effect.provide(layer)); + }); +}); 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 new file mode 100644 index 0000000000..46755d6f92 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.ts @@ -0,0 +1,372 @@ +import { Clock, Effect, FileSystem, Layer, Path } from "effect"; + +import { Output } from "../../../../shared/output/output.service.ts"; +import { parseLegacyConnectionString } from "../../../shared/legacy-db-config.parse.ts"; +import { LegacyDbConnectError } from "../../../shared/legacy-db-connection.errors.ts"; +import { legacyAcquirePgPool } from "../../../shared/legacy-db-connection.sql-pg.layer.ts"; +import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; +import { + LegacyPgDeltaEngine, + LegacyPgDeltaEngineError, + type LegacyPgDeltaDatabaseEndpoint, + type LegacyPgDeltaDiffResult, + type LegacyPgDeltaEndpoint, +} from "./legacy-pgdelta-engine.service.ts"; +import { + LegacyPgDeltaNextAdapter, + type LegacyPgDeltaNextOperation, +} from "./legacy-pgdelta-next-adapter.service.ts"; +import { + legacyFormatPgDeltaNextDebugId, + legacySavePgDeltaNextDebugArtifacts, + type LegacyPgDeltaNextDebugArtifacts, +} from "./legacy-pgdelta-next-artifacts.ts"; +import { LegacyPgDeltaNextShadow } from "./legacy-pgdelta-next-shadow.service.ts"; +import { + legacyPgDeltaNextDiagnosticReport, + legacyReportPgDeltaNextDiagnostics, +} from "./legacy-pgdelta-next-diagnostics.ts"; + +/** Shared by both declarative planner entrypoints over the full isolated baseline. */ +export const legacyPgDeltaNextIsolatedShadowPlanOptions = { + isolatedShadow: true, + seedAssumedSchemas: false, +} as const; + +function legacyPgDeltaNextConnectSuggestion(cause: unknown): string | undefined { + if (cause instanceof LegacyDbConnectError) return cause.suggestion; + if (typeof cause !== "object" || cause === null) return undefined; + const nested = Reflect.get(cause, "cause"); + return nested === cause ? undefined : legacyPgDeltaNextConnectSuggestion(nested); +} + +export const legacyPgDeltaNextEngineError = (cause: unknown) => { + if (cause instanceof LegacyPgDeltaEngineError) return cause; + const suggestion = legacyPgDeltaNextConnectSuggestion(cause); + return new LegacyPgDeltaEngineError({ + message: + typeof cause === "object" && + cause !== null && + typeof Reflect.get(cause, "message") === "string" + ? String(Reflect.get(cause, "message")) + : String(cause), + cause, + ...(suggestion !== undefined ? { suggestion } : {}), + }); +}; + +function normalizeNextDiff( + result: { + readonly changes: boolean; + readonly sql: string; + readonly files: ReadonlyArray<{ + readonly sequence: number; + readonly suffix: string | null; + readonly sql: string; + readonly transactionMode: "transactional" | "none"; + readonly actionCount: number; + }>; + readonly removals?: LegacyPgDeltaDiffResult["removals"]; + readonly debug?: { + readonly sourceSnapshot?: string; + readonly desiredSnapshot?: string; + readonly plan?: string; + }; + }, + debugDirectory?: string, +): LegacyPgDeltaDiffResult { + return { + changes: result.changes, + sql: result.sql, + files: result.files.map((file) => ({ + sequence: file.sequence, + name: `segment_${file.sequence}`, + suffix: file.suffix, + sql: file.sql, + transactionMode: file.transactionMode, + actionCount: file.actionCount, + })), + ...(result.removals !== undefined ? { removals: result.removals } : {}), + ...(result.debug !== undefined + ? { + debug: { + ...result.debug, + ...(debugDirectory !== undefined ? { directory: debugDirectory } : {}), + }, + } + : {}), + }; +} + +export function legacyParsePgDeltaNextEndpoint(endpoint: LegacyPgDeltaDatabaseEndpoint) { + return Effect.gen(function* () { + if (endpoint.connection !== undefined) return endpoint.connection; + const parsed = parseLegacyConnectionString(endpoint.ref); + if (parsed !== undefined) return parsed; + return yield* Effect.fail( + new LegacyPgDeltaEngineError({ + message: "failed to parse Postgres connection string for pg-delta", + cause: endpoint.ref.replace(/:[^:@/]+@/, ":***@"), + }), + ); + }); +} + +/** In-process pg-delta next implementation. Every pool and shadow is scope-owned. */ +export const legacyPgDeltaNextEngineLayer = Layer.effect( + LegacyPgDeltaEngine, + Effect.gen(function* () { + const adapter = yield* LegacyPgDeltaNextAdapter; + const shadowService = yield* LegacyPgDeltaNextShadow; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const debugLogger = yield* LegacyDebugLogger; + const output = yield* Output; + let feedbackInvitationShown = false; + + const saveDebugArtifacts = ( + workdir: string, + operation: LegacyPgDeltaNextOperation, + artifacts: LegacyPgDeltaNextDebugArtifacts, + ) => + Effect.gen(function* () { + const id = legacyFormatPgDeltaNextDebugId(yield* Clock.currentTimeMillis, operation); + const debugDir = yield* legacySavePgDeltaNextDebugArtifacts( + fs, + path, + workdir, + id, + operation, + artifacts, + ); + yield* debugLogger.debug(`Saved pg-delta next debug artifacts to ${debugDir}.`); + return debugDir; + }).pipe( + Effect.catch((cause) => + debugLogger + .debug( + `Failed to save pg-delta next debug artifacts: ${ + typeof cause === "object" && + cause !== null && + typeof Reflect.get(cause, "message") === "string" + ? String(Reflect.get(cause, "message")) + : String(cause) + }`, + ) + .pipe(Effect.as(undefined)), + ), + ); + + const acquireDatabase = (endpoint: LegacyPgDeltaDatabaseEndpoint) => + legacyParsePgDeltaNextEndpoint(endpoint).pipe( + Effect.flatMap((connection) => legacyAcquirePgPool(connection, endpoint.connectOptions)), + ); + + const reportDiagnostics = ( + operation: LegacyPgDeltaNextOperation, + diagnostics: Parameters[1], + strictCoverage: boolean, + verboseDiagnostics: boolean, + ) => { + const report = legacyPgDeltaNextDiagnosticReport(diagnostics, strictCoverage); + const showFeedback = !feedbackInvitationShown && report.unmodeledKinds.length > 0; + if (showFeedback) feedbackInvitationShown = true; + return legacyReportPgDeltaNextDiagnostics( + operation, + diagnostics, + strictCoverage, + showFeedback, + verboseDiagnostics, + ).pipe( + Effect.provideService(Output, output), + Effect.provideService(LegacyDebugLogger, debugLogger), + ); + }; + + return LegacyPgDeltaEngine.of({ + implementation: "next", + diffExplicit: (input) => + Effect.scoped( + Effect.gen(function* () { + let shadow: { readonly migrationsUrl: string } | undefined; + const migrationsEndpoint = + input.source.kind === "migrations" + ? input.source + : input.desired.kind === "migrations" + ? input.desired + : undefined; + if (migrationsEndpoint !== undefined) { + if (input.toml === undefined) { + return yield* Effect.fail( + new LegacyPgDeltaEngineError({ + message: "pg-delta migrations endpoint requires loaded database config", + cause: "missing database config", + }), + ); + } + shadow = yield* shadowService.provisionMigrations({ + context: input.context, + toml: input.toml, + ...(migrationsEndpoint.projectRef !== undefined + ? { projectRef: migrationsEndpoint.projectRef } + : {}), + }); + } + const endpointPool = (endpoint: LegacyPgDeltaEndpoint) => + Effect.gen(function* () { + if (endpoint.kind === "database") return yield* acquireDatabase(endpoint); + if (shadow === undefined) { + return yield* Effect.die("missing pg-delta migrations shadow"); + } + const connection = parseLegacyConnectionString(shadow.migrationsUrl); + if (connection === undefined) { + return yield* Effect.fail( + new LegacyPgDeltaEngineError({ + message: "failed to parse pg-delta migrations shadow URL", + cause: shadow.migrationsUrl.replace(/:[^:@/]+@/, ":***@"), + }), + ); + } + return yield* legacyAcquirePgPool(connection, { + isLocal: true, + dnsResolver: "native", + }); + }); + const [sourcePool, desiredPool] = yield* Effect.all( + [endpointPool(input.source), endpointPool(input.desired)], + { concurrency: 2 }, + ); + const result = yield* adapter.diff({ + sourcePool, + desiredPool, + allowDrops: true, + debug: input.debug, + schema: input.schema, + formatOptions: input.formatOptions, + }); + const debugDirectory = + result.debug !== undefined + ? yield* saveDebugArtifacts(input.context.cwd, "diff", { + ...result.debug, + diagnostics: result.diagnostics, + }) + : undefined; + yield* reportDiagnostics("diff", result.diagnostics, input.strictCoverage, input.debug); + return normalizeNextDiff(result, debugDirectory); + }), + ).pipe(Effect.mapError(legacyPgDeltaNextEngineError)), + diffDatabase: (input) => + Effect.scoped( + Effect.gen(function* () { + const migrationsPool = yield* acquireDatabase(input.source); + const desiredPool = yield* acquireDatabase(input.target); + const result = yield* adapter.diff({ + sourcePool: migrationsPool, + desiredPool, + allowDrops: true, + debug: input.debug, + schema: input.schema, + formatOptions: input.formatOptions, + }); + const debugDirectory = + result.debug !== undefined + ? yield* saveDebugArtifacts(input.context.cwd, "diff", { + ...result.debug, + diagnostics: result.diagnostics, + }) + : undefined; + yield* reportDiagnostics("diff", result.diagnostics, input.strictCoverage, input.debug); + return normalizeNextDiff(result, debugDirectory); + }), + ).pipe(Effect.mapError(legacyPgDeltaNextEngineError)), + exportDeclarativeSchema: (input) => + Effect.scoped( + Effect.gen(function* () { + const pool = yield* acquireDatabase(input.target); + const result = yield* adapter.exportDeclarativeSchema({ + pool, + layout: "grouped", + schema: input.schema, + formatOptions: input.formatOptions, + }); + if (input.debug) { + const capture = yield* adapter + .captureSnapshot({ pool, redactSecrets: true }) + .pipe(Effect.orElseSucceed(() => undefined)); + yield* saveDebugArtifacts(input.context.cwd, "declarativeExport", { + ...(capture !== undefined ? { desiredSnapshot: capture.snapshot } : {}), + diagnostics: + capture === undefined + ? result.diagnostics + : [...result.diagnostics, ...capture.diagnostics], + }); + } + yield* reportDiagnostics( + "declarativeExport", + result.diagnostics, + input.strictCoverage, + input.debug, + ); + return { files: result.files, manifest: result.manifest }; + }), + ).pipe(Effect.mapError(legacyPgDeltaNextEngineError)), + planDeclarativeSchema: (input) => + Effect.scoped( + Effect.gen(function* () { + const shadow = yield* shadowService.provisionPlan({ + context: input.context, + toml: input.toml, + ...(input.projectRef !== undefined ? { projectRef: input.projectRef } : {}), + }); + const migrations = parseLegacyConnectionString(shadow.migrationsUrl); + const declarative = parseLegacyConnectionString(shadow.declarativeUrl); + if (migrations === undefined || declarative === undefined) { + return yield* Effect.fail( + new LegacyPgDeltaEngineError({ + message: "failed to parse pg-delta next shadow database URL", + cause: "invalid password-free shadow output", + }), + ); + } + const [migrationsPool, declarativePool] = yield* Effect.all( + [ + legacyAcquirePgPool(migrations, { isLocal: true, dnsResolver: "native" }), + legacyAcquirePgPool(declarative, { isLocal: true, dnsResolver: "native" }), + ], + { concurrency: 2 }, + ); + const result = yield* adapter.planDeclarativeSchema({ + targetPool: migrationsPool, + shadowPool: declarativePool, + files: input.files, + allowDrops: true, + debug: input.debug, + reorder: true, + ...legacyPgDeltaNextIsolatedShadowPlanOptions, + schema: input.schema, + formatOptions: input.formatOptions, + ...(input.manifest !== undefined ? { manifest: input.manifest } : {}), + }); + const debugDirectory = + result.debug !== undefined + ? yield* saveDebugArtifacts(input.context.cwd, "declarativePlan", { + ...result.debug, + diagnostics: result.diagnostics, + }) + : undefined; + yield* reportDiagnostics( + "declarativePlan", + result.diagnostics, + input.strictCoverage, + input.debug, + ); + return { + ...normalizeNextDiff(result, debugDirectory), + sourceRef: "pg-delta-next:migrations", + targetRef: "pg-delta-next:declarative", + }; + }), + ).pipe(Effect.mapError(legacyPgDeltaNextEngineError)), + }); + }), +); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.unit.test.ts new file mode 100644 index 0000000000..20fc56e16b --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.unit.test.ts @@ -0,0 +1,52 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; + +import type { LegacyPgDeltaDatabaseEndpoint } from "./legacy-pgdelta-engine.service.ts"; +import { + legacyParsePgDeltaNextEndpoint, + legacyPgDeltaNextIsolatedShadowPlanOptions, +} from "./legacy-pgdelta-engine.next.layer.ts"; +import { LegacyPgDeltaEngineError } from "./legacy-pgdelta-engine.service.ts"; + +describe("legacyPgDeltaNextIsolatedShadowPlanOptions", () => { + it("uses the isolated full-baseline mode shared by both declarative planner entrypoints", () => { + expect(legacyPgDeltaNextIsolatedShadowPlanOptions).toEqual({ + isolatedShadow: true, + seedAssumedSchemas: false, + }); + }); +}); + +describe("legacyParsePgDeltaNextEndpoint", () => { + it("fails malformed explicit URLs through the typed error channel and redacts passwords", () => { + const endpoint = { + kind: "database", + ref: "postgresql://postgres:supersecret@[/postgres", + connectOptions: { isLocal: false, dnsResolver: "native" }, + } satisfies LegacyPgDeltaDatabaseEndpoint; + + const error = Effect.runSync(legacyParsePgDeltaNextEndpoint(endpoint).pipe(Effect.flip)); + + expect(error).toBeInstanceOf(LegacyPgDeltaEngineError); + expect(error.message).toBe("failed to parse Postgres connection string for pg-delta"); + expect(error.cause).toBe("postgresql://postgres:***@[/postgres"); + }); + + it("uses a supplied parsed connection without reparsing the display ref", () => { + const connection = { + host: "localhost", + port: 5432, + user: "postgres", + password: "secret", + database: "postgres", + }; + const endpoint = { + kind: "database", + ref: "malformed-display-ref", + connection, + connectOptions: { isLocal: true, dnsResolver: "native" }, + } satisfies LegacyPgDeltaDatabaseEndpoint; + + expect(Effect.runSync(legacyParsePgDeltaNextEndpoint(endpoint))).toBe(connection); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.unit.test.ts new file mode 100644 index 0000000000..d1db522f46 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.unit.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { LegacyDbConnectError } from "../../../shared/legacy-db-connection.errors.ts"; +import { legacyPgDeltaNextEngineError } from "./legacy-pgdelta-engine.next.layer.ts"; +import { LegacyPgDeltaEngineError } from "./legacy-pgdelta-engine.service.ts"; +import { LegacyPgDeltaNextError } from "./legacy-pgdelta-next-adapter.service.ts"; + +describe("pg-delta next engine errors", () => { + it("preserves database connection suggestions when wrapping failures", () => { + const cause = new LegacyDbConnectError({ + message: "failed to connect to postgres", + suggestion: "Retry with --dns-resolver https.", + }); + + expect(legacyPgDeltaNextEngineError(cause)).toEqual( + new LegacyPgDeltaEngineError({ + message: "failed to connect to postgres", + suggestion: "Retry with --dns-resolver https.", + cause, + }), + ); + }); + + it("finds connection suggestions nested in adapter failures", () => { + const cause = new LegacyDbConnectError({ + message: "failed to connect to postgres", + suggestion: "Retry with --dns-resolver https.", + }); + const adapterError = new LegacyPgDeltaNextError({ + operation: "diff", + message: "Database diff failed", + cause, + }); + + expect(legacyPgDeltaNextEngineError(adapterError).suggestion).toBe( + "Retry with --dns-resolver https.", + ); + }); + + it("does not wrap an existing engine error again", () => { + const error = new LegacyPgDeltaEngineError({ message: "blocked", cause: "diagnostic" }); + expect(legacyPgDeltaNextEngineError(error)).toBe(error); + }); +}); 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 new file mode 100644 index 0000000000..e1f8f28292 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.service.ts @@ -0,0 +1,168 @@ +import { Context, Data, type Effect } from "effect"; + +import type { + LegacyDbConnectOptions, + 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 { LegacyDbTomlValues } from "../../../shared/legacy-db-config.toml-read.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + +export interface LegacyPgDeltaDatabaseEndpoint { + readonly kind: "database"; + /** URL/reference used by the legacy edge-runtime implementation. */ + readonly ref: string; + /** Full parsed connection, preferred by the next implementation. */ + readonly connection?: LegacyPgConnInput; + readonly connectOptions: LegacyDbConnectOptions; +} + +interface LegacyPgDeltaMigrationsEndpoint { + readonly kind: "migrations"; + readonly projectRef?: string; +} + +export type LegacyPgDeltaEndpoint = LegacyPgDeltaDatabaseEndpoint | LegacyPgDeltaMigrationsEndpoint; + +export interface LegacyPgDeltaSqlFile { + readonly name: string; + readonly sql: string; +} + +export interface LegacyPgDeltaExportManifest { + readonly redactSecrets: boolean; + readonly scope: "database" | "cluster"; + readonly profile?: string; + readonly baselineDigest?: string; + readonly defaultOwner?: string | null; + readonly files?: ReadonlyArray; +} + +export type LegacyPgDeltaTransactionMode = "transactional" | "none"; + +export interface LegacyPgDeltaRenderedFile { + readonly sequence: number; + /** Legacy semantic unit name. */ + readonly name: string; + /** Next renderer's exact filename suffix (`null`, `_1`, `_2`, ...). */ + readonly suffix?: string | null; + readonly sql: string; + readonly transactionMode: LegacyPgDeltaTransactionMode; + readonly actionCount?: number; +} + +interface LegacyPgDeltaExtensionIntentRemoval { + readonly extension: string; + readonly intentKind: string; + readonly key: string; +} + +/** Root object removals retained from a semantic pg-delta plan. */ +export interface LegacyPgDeltaRemovalSummary { + readonly extensions: ReadonlyArray; + readonly extensionIntents: ReadonlyArray; +} + +interface LegacyPgDeltaDebugArtifacts { + readonly sourceSnapshot?: string; + readonly desiredSnapshot?: string; + readonly plan?: string; + readonly stderr?: string; + /** Persisted debug directory, when the selected implementation writes one. */ + readonly directory?: string; +} + +export interface LegacyPgDeltaDiffResult { + readonly changes: boolean; + readonly sql: string; + readonly files: ReadonlyArray; + readonly removals?: LegacyPgDeltaRemovalSummary; + readonly debug?: LegacyPgDeltaDebugArtifacts; +} + +interface LegacyPgDeltaCommonInput { + readonly context: LegacyPgDeltaContext; + readonly schema: ReadonlyArray; + readonly formatOptions: string; + readonly projectRef?: string; + readonly debug: boolean; + /** Refuse coverage-gap diagnostics instead of continuing with those objects unmanaged. */ + readonly strictCoverage: boolean; +} + +export interface LegacyPgDeltaExplicitDiffInput extends LegacyPgDeltaCommonInput { + readonly source: LegacyPgDeltaEndpoint; + readonly desired: LegacyPgDeltaEndpoint; + /** Already-loaded config used when a migrations endpoint needs a native shadow. */ + readonly toml?: LegacyDbTomlValues; +} + +export interface LegacyPgDeltaDatabaseDiffInput extends LegacyPgDeltaCommonInput { + /** Workflow-owned, migrated shadow database. */ + readonly source: LegacyPgDeltaDatabaseEndpoint; + readonly target: LegacyPgDeltaDatabaseEndpoint; +} + +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 { + readonly files: ReadonlyArray; + readonly manifest?: LegacyPgDeltaExportManifest; +} + +export interface LegacyPgDeltaDeclarativePlanInput extends LegacyPgDeltaCommonInput { + readonly files: ReadonlyArray; + readonly manifest?: LegacyPgDeltaExportManifest; + readonly noCache: boolean; + /** Already-loaded config used by native shadow/catalog provisioning. */ + readonly toml: LegacyDbTomlValues; + readonly setupInputs: LegacySetupInputs; +} + +interface LegacyPgDeltaDeclarativePlanResult extends LegacyPgDeltaDiffResult { + /** Debug labels retained for the legacy apply/reset bundle. */ + readonly sourceRef: string; + readonly targetRef: string; +} + +export class LegacyPgDeltaEngineError extends Data.TaggedError("LegacyPgDeltaEngineError")<{ + readonly message: string; + readonly cause: unknown; + readonly suggestion?: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} + +export interface LegacyPgDeltaEngineShape { + readonly implementation: LegacyPgDeltaImplementation; + readonly diffExplicit: ( + input: LegacyPgDeltaExplicitDiffInput, + ) => Effect.Effect; + readonly diffDatabase: ( + input: LegacyPgDeltaDatabaseDiffInput, + ) => Effect.Effect; + readonly exportDeclarativeSchema: ( + input: LegacyPgDeltaDeclarativeExportInput, + ) => Effect.Effect; + readonly planDeclarativeSchema: ( + input: LegacyPgDeltaDeclarativePlanInput, + ) => Effect.Effect; +} + +export class LegacyPgDeltaEngine extends Context.Service< + LegacyPgDeltaEngine, + LegacyPgDeltaEngineShape +>()("supabase/legacy/PgDeltaEngine") {} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-files.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-files.ts new file mode 100644 index 0000000000..368a8cf20e --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-files.ts @@ -0,0 +1,135 @@ +import { Data, Effect, type FileSystem, type Path } from "effect"; + +import type { + LegacyPgDeltaExportManifest, + LegacyPgDeltaSqlFile, +} from "./legacy-pgdelta-engine.service.ts"; + +const EXPORT_MANIFEST_FILE = ".pgdelta-export.json"; + +class LegacyPgDeltaFilesError extends Data.TaggedError("LegacyPgDeltaFilesError")<{ + readonly message: string; +}> {} + +const filesError = (message: string) => new LegacyPgDeltaFilesError({ message }); + +function readManifestValue(doc: object, key: string): unknown { + return Reflect.get(doc, key); +} + +/** Reads a next-engine export manifest from an explicit declarative directory. */ +export const LegacyReadPgDeltaExportManifest = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + directory: string, +) { + const manifestPath = path.join(directory, EXPORT_MANIFEST_FILE); + const exists = yield* fs + .exists(manifestPath) + .pipe( + Effect.mapError((error) => filesError(`cannot inspect export manifest: ${error.message}`)), + ); + if (!exists) return undefined; + + const raw = yield* fs + .readFileString(manifestPath) + .pipe( + Effect.mapError((error) => + filesError(`cannot read export manifest ${manifestPath}: ${error.message}`), + ), + ); + const decoded = yield* Effect.try({ + try: (): unknown => JSON.parse(raw), + catch: (cause) => + filesError( + `malformed export manifest ${manifestPath}: ${cause instanceof Error ? cause.message : String(cause)}`, + ), + }); + if (typeof decoded !== "object" || decoded === null || Array.isArray(decoded)) { + return yield* Effect.fail(filesError(`malformed export manifest ${manifestPath}`)); + } + + const formatVersion = readManifestValue(decoded, "formatVersion"); + const redactSecrets = readManifestValue(decoded, "redactSecrets"); + const scope = readManifestValue(decoded, "scope"); + if ( + (formatVersion !== undefined && formatVersion !== 1) || + typeof redactSecrets !== "boolean" || + (scope !== "database" && scope !== "cluster") + ) { + return yield* Effect.fail( + filesError(`export manifest ${manifestPath} is missing required policy metadata`), + ); + } + + const profile = readManifestValue(decoded, "profile"); + const baselineDigest = readManifestValue(decoded, "baselineDigest"); + const defaultOwner = readManifestValue(decoded, "defaultOwner"); + const files = readManifestValue(decoded, "files"); + return { + redactSecrets, + scope, + ...(typeof profile === "string" ? { profile } : {}), + ...(typeof baselineDigest === "string" ? { baselineDigest } : {}), + ...(typeof defaultOwner === "string" || defaultOwner === null ? { defaultOwner } : {}), + ...(Array.isArray(files) && files.every((file) => typeof file === "string") ? { files } : {}), + } satisfies LegacyPgDeltaExportManifest; +}); + +/** Recursively loads path-safe `.sql` files in stable POSIX-relative order. */ +export const LegacyLoadPgDeltaSqlFiles = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + directory: string, +) { + const pending = [directory]; + const paths: Array<{ readonly full: string; readonly name: string }> = []; + + while (pending.length > 0) { + const current = pending.pop(); + if (current === undefined) break; + const entries = yield* fs + .readDirectory(current) + .pipe( + Effect.mapError((error) => + filesError(`failed to read declarative schema directory: ${error.message}`), + ), + ); + for (const entry of entries) { + const full = path.join(current, entry); + const stat = yield* fs + .stat(full) + .pipe( + Effect.mapError((error) => + filesError(`failed to inspect declarative schema file: ${error.message}`), + ), + ); + if (stat.type === "Directory") { + pending.push(full); + continue; + } + if (path.extname(entry).toLowerCase() !== ".sql") continue; + + const name = path.relative(directory, full).split("\\").join("/"); + const normalized = path.normalize(name); + if (normalized.startsWith("..") || path.isAbsolute(normalized)) { + return yield* Effect.fail(filesError(`unsafe declarative schema path: ${name}`)); + } + paths.push({ full, name }); + } + } + + paths.sort((left, right) => left.name.localeCompare(right.name)); + const files: Array = []; + for (const file of paths) { + const sql = yield* fs + .readFileString(file.full) + .pipe( + Effect.mapError((error) => + filesError(`failed to read declarative schema file: ${error.message}`), + ), + ); + files.push({ name: file.name, sql }); + } + return files; +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-migrations.write.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-migrations.write.ts index d26a81f19d..e0f5d0aac2 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-migrations.write.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-migrations.write.ts @@ -10,6 +10,7 @@ import { legacyFormatMigrationTimestamp, legacyGetMigrationPath, } from "../../../shared/legacy-migration-file.ts"; +import type { LegacyPgDeltaTransactionMode } from "./legacy-pgdelta-engine.service.ts"; /** A migration file written by a diff/pull, paired with its history version. */ export interface LegacyWrittenMigration { @@ -67,16 +68,34 @@ export const legacyWritePgDeltaMigrations = ( readonly workdir: string; readonly baseMillis: number; readonly name: string; - readonly files: ReadonlyArray<{ readonly name: string; readonly sql: string }>; + readonly files: ReadonlyArray<{ + readonly name: string; + readonly suffix?: string | null; + readonly sql: string; + readonly transactionMode: LegacyPgDeltaTransactionMode; + }>; }, ): Effect.Effect, LegacyPgDeltaMigrationWriteError> => Effect.gen(function* () { const { workdir, name, files } = opts; + for (const file of files) { + if (file.transactionMode !== "transactional" && file.transactionMode !== "none") { + return yield* Effect.fail( + new LegacyPgDeltaMigrationWriteError({ + message: `unknown pg-delta transaction mode ${JSON.stringify(file.transactionMode)}`, + }), + ); + } + } const single = files.length === 1; const buildSet = (baseMillis: number): Array => files.map((file, i) => { const version = legacyFormatMigrationTimestamp(baseMillis + i * 1000); - const unitName = single ? name : `${name}_${file.name}`; + const unitName = single + ? name + : file.suffix !== undefined && file.suffix !== null + ? `${name}${file.suffix}` + : `${name}_${file.name}`; return { path: legacyGetMigrationPath(pathSvc, workdir, version, unitName), version }; }); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.layer.ts new file mode 100644 index 0000000000..ff1bd0f861 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.layer.ts @@ -0,0 +1,689 @@ +import { Effect, Layer } from "effect"; +import type { Pool } from "pg"; +import { serializeSnapshot, encodeId } from "@supabase/pg-delta/core"; +import { + buildSchemaExport, + planSchemaFiles, + renderPlanFiles, + ShadowLoadError, +} from "@supabase/pg-delta/frontends"; +import { + type IntegrationProfile, + resolveProfile, + supabaseProfile, +} from "@supabase/pg-delta/integrations"; +import { plan, serializePlan } from "@supabase/pg-delta/plan"; +import type { Plan as PgDeltaPlan } from "@supabase/pg-delta/plan"; +import type { Policy } from "@supabase/pg-delta/policy"; +import { formatSqlStatements, type SqlFormatOptions } from "@supabase/pg-delta/sql-format"; + +import { + LegacyPgDeltaNextAdapter, + LegacyPgDeltaNextError, + type LegacyPgDeltaNextAdapterShape, + type LegacyPgDeltaNextDeclarativeExportInput, + type LegacyPgDeltaNextDeclarativeManifestInput, + type LegacyPgDeltaNextDeclarativePlanInput, + type LegacyPgDeltaNextDiagnostic, + type LegacyPgDeltaNextDiagnosticOrigin, + type LegacyPgDeltaNextDiffInput, + type LegacyPgDeltaNextExportManifest, + type LegacyPgDeltaNextRenderedFile, + type LegacyPgDeltaNextSnapshotCaptureInput, + type LegacyPgDeltaNextSqlFile, + type LegacyPgDeltaNextOperation, +} from "./legacy-pgdelta-next-adapter.service.ts"; +import type { LegacyPgDeltaRemovalSummary } from "./legacy-pgdelta-engine.service.ts"; + +interface LegacyPgDeltaNextLibraryDiagnostic { + readonly code: string; + readonly severity: "error" | "warning" | "info"; + readonly subject?: Subject; + readonly message: string; + readonly context?: Readonly>; +} + +interface LegacyPgDeltaNextLibraryExtractResult { + readonly factBase: FactBase; + readonly pgVersion: string; + readonly diagnostics: readonly LegacyPgDeltaNextLibraryDiagnostic[]; +} + +interface LegacyPgDeltaNextResolvedProfile { + readonly id: string; + readonly planOptions: PlanOptions; + readonly extract: ( + pool: Pool, + options?: { readonly redactSecrets?: boolean; readonly statementTimeoutMs?: number }, + ) => Promise>; +} + +interface LegacyPgDeltaNextLibraryRenderedFile { + readonly suffix: string | null; + readonly contents: string; + readonly transactional: boolean; + readonly actionCount: number; +} + +interface LegacyPgDeltaNextLibraryRenderedResult { + readonly changes: boolean; + readonly files: readonly LegacyPgDeltaNextLibraryRenderedFile[]; +} + +interface LegacyPgDeltaNextLibrarySchemaExport { + readonly files: readonly LegacyPgDeltaNextSqlFile[]; + readonly diagnostics: readonly LegacyPgDeltaNextLibraryDiagnostic[]; + readonly manifest: LegacyPgDeltaNextExportManifest; +} + +type LegacyPgDeltaNextLibraryExportOptions = ReturnType; + +interface LegacyPgDeltaNextLibrarySchemaPlan { + readonly plan: Plan; + readonly loadDiagnostics: readonly LegacyPgDeltaNextLibraryDiagnostic[]; + readonly targetDiagnostics: readonly LegacyPgDeltaNextLibraryDiagnostic[]; + readonly skipped: readonly { readonly file: string; readonly stmt: string }[]; +} + +export interface LegacyPgDeltaNextLibraries { + readonly resolveProfile: ( + pool: Pool, + options: { + readonly restrictToApplier?: boolean; + readonly redactSecrets?: boolean; + readonly skipBaseline?: boolean; + }, + schema?: readonly string[], + ) => Promise>; + readonly plan: ( + source: FactBase, + desired: FactBase, + options: PlanOptions & { readonly redactSecrets: boolean }, + ) => Plan; + readonly renderPlanFiles: ( + plan: Plan, + options: { readonly allowDrops: boolean }, + ) => LegacyPgDeltaNextLibraryRenderedResult; + readonly buildSchemaExport: ( + pool: Pool, + input: LegacyPgDeltaNextLibraryExportOptions, + ) => Promise>; + readonly planSchemaFiles: ( + targetPool: Pool, + shadowPool: Pool, + files: readonly LegacyPgDeltaNextSqlFile[], + input: LegacyPgDeltaNextDeclarativePlanInput, + ) => Promise>; + readonly serializeSnapshot: ( + factBase: FactBase, + metadata: { + readonly pgVersion: string; + readonly redactSecrets: boolean; + readonly profile: string; + }, + ) => string; + readonly serializePlan: (plan: Plan) => string; + readonly summarizeRemovals: (plan: Plan) => LegacyPgDeltaRemovalSummary; + readonly encodeSubject: (subject: Subject) => string; +} + +export function legacySummarizePgDeltaNextRemovals( + generatedPlan: Pick, +): LegacyPgDeltaRemovalSummary { + const extensions = new Set(); + const extensionIntents = new Map< + string, + LegacyPgDeltaRemovalSummary["extensionIntents"][number] + >(); + for (const delta of generatedPlan.deltas) { + if (delta.verb !== "remove" || delta.fact.parent !== undefined) continue; + const id = delta.fact.id; + if (id.kind === "extension") { + extensions.add(id.name); + continue; + } + if (id.kind !== "extensionIntent") continue; + const removal = { extension: id.ext, intentKind: id.intentKind, key: id.key }; + extensionIntents.set(`${id.ext}\u0000${id.intentKind}\u0000${id.key}`, removal); + } + return { + extensions: [...extensions].sort(), + extensionIntents: [...extensionIntents.values()].sort( + (left, right) => + left.extension.localeCompare(right.extension) || + left.intentKind.localeCompare(right.intentKind) || + left.key.localeCompare(right.key), + ), + }; +} + +function legacyPgDeltaNextMessage(operation: LegacyPgDeltaNextOperation, cause: unknown): string { + const detail = cause instanceof Error ? cause.message : String(cause); + const diagnostics = + cause instanceof ShadowLoadError ? cause.details.map((diagnostic) => diagnostic.message) : []; + const label = + operation === "declarativeExport" + ? "Declarative schema export" + : operation === "declarativePlan" + ? "Declarative schema planning" + : operation === "snapshotCapture" + ? "Snapshot capture" + : "Database diff"; + const renderedDiagnostics = diagnostics.map((diagnostic) => ` - ${diagnostic}`).join("\n"); + return `${label} failed: ${detail}${renderedDiagnostics === "" ? "" : `\n${renderedDiagnostics}`}`; +} + +function legacyTryPgDeltaNext( + operation: LegacyPgDeltaNextOperation, + run: () => Promise, +) { + return Effect.tryPromise({ + try: run, + catch: (cause) => + new LegacyPgDeltaNextError({ + operation, + message: legacyPgDeltaNextMessage(operation, cause), + cause, + }), + }); +} + +function legacyNormalizePgDeltaNextDiagnostics( + diagnostics: readonly LegacyPgDeltaNextLibraryDiagnostic[], + origin: LegacyPgDeltaNextDiagnosticOrigin, + encodeSubject: (subject: Subject) => string, +): LegacyPgDeltaNextDiagnostic[] { + return diagnostics.map((diagnostic) => ({ + origin, + code: diagnostic.code, + severity: diagnostic.severity, + ...(diagnostic.subject !== undefined ? { subject: encodeSubject(diagnostic.subject) } : {}), + message: diagnostic.message, + ...(diagnostic.context !== undefined ? { context: diagnostic.context } : {}), + })); +} + +function legacyIsPgDeltaNextParameterAclDiagnostic( + diagnostic: LegacyPgDeltaNextLibraryDiagnostic, +): boolean { + return diagnostic.code === "unmodeled_kind" && diagnostic.context?.["kind"] === "parameter ACL"; +} + +/** + * The parameter-ACL catalog is cluster-wide, so a co-located declarative shadow + * observes Supabase platform grants too. Keep strict coverage for every ACL + * other than the exact platform bootstrap grant while removing the aggregate + * diagnostic when that bootstrap grant is the only observed parameter ACL. + */ +export function legacyFilterPgDeltaNextPlatformParameterAclDiagnostics( + diagnostics: readonly LegacyPgDeltaNextLibraryDiagnostic[], + userOwnedParameterAcls: readonly string[], +): LegacyPgDeltaNextLibraryDiagnostic[] { + const names = [...new Set(userOwnedParameterAcls)].sort(); + const filtered: LegacyPgDeltaNextLibraryDiagnostic[] = []; + for (const diagnostic of diagnostics) { + if (!legacyIsPgDeltaNextParameterAclDiagnostic(diagnostic)) { + filtered.push(diagnostic); + continue; + } + if (names.length === 0) continue; + const samples = names.slice(0, 5); + const more = names.length > samples.length ? ", …" : ""; + filtered.push({ + ...diagnostic, + message: + `${names.length} unmodeled "parameter ACL" object${names.length === 1 ? "" : "s"} ` + + `not managed by this engine (e.g. ${samples.join(", ")}${more}) — ` + + "v1 detects but does not model this kind", + context: { kind: "parameter ACL", count: names.length, samples }, + }); + } + return filtered; +} + +interface LegacyPgDeltaNextParameterAclGrant { + readonly name: string; + readonly grantee: string; + readonly privilege: string; +} + +// Supabase's platform bootstrap grants these so privileged platform roles can +// manage the setting and the Realtime owner can replay routines whose proconfig +// contains `SET log_min_messages ...`. Parameter ACLs have cluster scope, so +// the grants are also visible from sibling shadow DBs. +const legacyPgDeltaNextPlatformParameterAcls = new Set([ + "log_min_messages\u0000supabase_admin\u0000ALTER SYSTEM", + "log_min_messages\u0000supabase_admin\u0000SET", + "log_min_messages\u0000supabase_realtime_admin\u0000SET", +]); + +function legacyPgDeltaNextParameterAclKey(grant: LegacyPgDeltaNextParameterAclGrant): string { + return `${grant.name}\u0000${grant.grantee}\u0000${grant.privilege}`; +} + +export function legacyPgDeltaNextUserOwnedParameterAcls( + grants: readonly LegacyPgDeltaNextParameterAclGrant[], +): string[] { + return [ + ...new Set( + grants + .filter( + (grant) => + !legacyPgDeltaNextPlatformParameterAcls.has(legacyPgDeltaNextParameterAclKey(grant)), + ) + .map((grant) => grant.name), + ), + ].sort(); +} + +async function legacyFilterPgDeltaNextPlatformDiagnostics( + pool: Pool, + diagnostics: readonly LegacyPgDeltaNextLibraryDiagnostic[], +): Promise[]> { + if (!diagnostics.some(legacyIsPgDeltaNextParameterAclDiagnostic)) return [...diagnostics]; + const result = await pool.query( + `SELECT DISTINCT pa.parname AS name, + COALESCE(grantee.rolname, 'PUBLIC') AS grantee, + acl.privilege_type AS privilege + FROM pg_parameter_acl pa + CROSS JOIN LATERAL aclexplode(pa.paracl) acl + LEFT JOIN pg_roles grantee ON grantee.oid = acl.grantee + ORDER BY pa.parname, grantee, privilege`, + ); + return legacyFilterPgDeltaNextPlatformParameterAclDiagnostics( + diagnostics, + legacyPgDeltaNextUserOwnedParameterAcls(result.rows), + ); +} + +function legacyNormalizePgDeltaNextRenderedFiles( + files: readonly LegacyPgDeltaNextLibraryRenderedFile[], +): LegacyPgDeltaNextRenderedFile[] { + return files.map((file, index) => ({ + sequence: index + 1, + suffix: file.suffix, + sql: file.contents, + transactionMode: file.transactional ? "transactional" : "none", + actionCount: file.actionCount, + })); +} + +export function legacyPgDeltaNextProfile( + schema: readonly string[] | undefined, +): IntegrationProfile { + if (schema === undefined || schema.length === 0 || supabaseProfile.policy === undefined) { + return supabaseProfile; + } + const selected = [...schema]; + const policy: Policy = { + id: `supabase-cli-schemas:${selected.join(",")}`, + filter: [ + { + match: { all: [{ schema: "*" }, { not: { schema: selected } }] }, + action: "exclude", + }, + { + match: { all: [{ kind: "schema" }, { not: { name: selected } }] }, + action: "exclude", + }, + { + match: { + all: [{ target: { schema: "*" } }, { not: { target: { schema: selected } } }], + }, + action: "exclude", + }, + ], + extends: [supabaseProfile.policy], + }; + return { ...supabaseProfile, policy }; +} + +const legacyPgDeltaNextHumanFormatOptions: SqlFormatOptions = { + keywordCase: "lower", + maxWidth: 180, +}; + +function legacyPgDeltaNextFormatOptions(raw: string | undefined): SqlFormatOptions | undefined { + if (raw === undefined || raw.trim().length === 0) return legacyPgDeltaNextHumanFormatOptions; + const parsed: unknown = JSON.parse(raw); + if (parsed === null) return undefined; + if (typeof parsed !== "object" || Array.isArray(parsed)) { + return legacyPgDeltaNextHumanFormatOptions; + } + const value = (key: string): unknown => Reflect.get(parsed, key); + const keywordCase = value("keywordCase"); + const commaStyle = value("commaStyle"); + const indent = value("indent"); + const maxWidth = value("maxWidth"); + const alignColumns = value("alignColumns"); + const alignKeyValues = value("alignKeyValues"); + const preserveRoutineBodies = value("preserveRoutineBodies"); + const preserveViewBodies = value("preserveViewBodies"); + const preserveRuleBodies = value("preserveRuleBodies"); + return { + ...legacyPgDeltaNextHumanFormatOptions, + ...(keywordCase === "upper" || keywordCase === "lower" || keywordCase === "preserve" + ? { keywordCase } + : {}), + ...(commaStyle === "trailing" || commaStyle === "leading" ? { commaStyle } : {}), + ...(typeof indent === "number" ? { indent } : {}), + ...(typeof maxWidth === "number" ? { maxWidth } : {}), + ...(typeof alignColumns === "boolean" ? { alignColumns } : {}), + ...(typeof alignKeyValues === "boolean" ? { alignKeyValues } : {}), + ...(typeof preserveRoutineBodies === "boolean" ? { preserveRoutineBodies } : {}), + ...(typeof preserveViewBodies === "boolean" ? { preserveViewBodies } : {}), + ...(typeof preserveRuleBodies === "boolean" ? { preserveRuleBodies } : {}), + }; +} + +function legacyTerminatePgDeltaNextStatement(sql: string): string { + const trimmed = sql.trimEnd(); + return trimmed.endsWith(";") ? trimmed : `${trimmed};`; +} + +function legacyFormatPgDeltaNextRenderedFiles( + files: readonly LegacyPgDeltaNextLibraryRenderedFile[], + format: SqlFormatOptions | undefined, +): readonly LegacyPgDeltaNextLibraryRenderedFile[] { + if (format === undefined) return files; + return files.map((file) => ({ + ...file, + contents: `${formatSqlStatements([file.contents], format) + .map(legacyTerminatePgDeltaNextStatement) + .join("\n\n")}\n`, + })); +} + +function legacyPgDeltaNextExportOptions(input: LegacyPgDeltaNextDeclarativeExportInput) { + const format = legacyPgDeltaNextFormatOptions(input.formatOptions); + return { + profile: legacyPgDeltaNextProfile(input.schema), + ...(input.scope !== undefined ? { scope: input.scope } : {}), + ...(input.redactSecrets !== undefined ? { redactSecrets: input.redactSecrets } : {}), + ...(input.restrictToApplier !== undefined + ? { resolveOptions: { restrictToApplier: input.restrictToApplier } } + : {}), + ...(input.layout !== undefined ? { layout: input.layout } : {}), + ...(input.grouping !== undefined + ? { + grouping: { + ...(input.grouping.mode !== undefined ? { mode: input.grouping.mode } : {}), + ...(input.grouping.groupPatterns !== undefined + ? { groupPatterns: [...input.grouping.groupPatterns] } + : {}), + ...(input.grouping.flatSchemas !== undefined + ? { flatSchemas: [...input.grouping.flatSchemas] } + : {}), + ...(input.grouping.autoGroupPartitions !== undefined + ? { autoGroupPartitions: input.grouping.autoGroupPartitions } + : {}), + }, + } + : {}), + ...(input.defaultOwner !== undefined ? { defaultOwner: input.defaultOwner } : {}), + ...(format !== undefined ? { format } : {}), + ...(input.onWarning !== undefined ? { onWarning: input.onWarning } : {}), + }; +} + +function legacyPgDeltaNextManifest(manifest: LegacyPgDeltaNextDeclarativeManifestInput) { + return { + ...(manifest.redactSecrets !== undefined ? { redactSecrets: manifest.redactSecrets } : {}), + ...(manifest.profile !== undefined ? { profile: manifest.profile } : {}), + ...(manifest.scope !== undefined ? { scope: manifest.scope } : {}), + ...(manifest.baselineDigest !== undefined ? { baselineDigest: manifest.baselineDigest } : {}), + ...(manifest.defaultOwner !== undefined ? { defaultOwner: manifest.defaultOwner } : {}), + ...(manifest.files !== undefined ? { files: [...manifest.files] } : {}), + }; +} + +function legacyPgDeltaNextPlanOptions(input: LegacyPgDeltaNextDeclarativePlanInput) { + return { + profile: legacyPgDeltaNextProfile(input.schema), + ...(input.scope !== undefined ? { scope: input.scope } : {}), + ...(input.manifest !== undefined + ? { manifest: legacyPgDeltaNextManifest(input.manifest) } + : {}), + ...(input.redactSecrets !== undefined ? { redactSecrets: input.redactSecrets } : {}), + ...(input.skipClusterDdl !== undefined ? { skipClusterDdl: input.skipClusterDdl } : {}), + ...(input.isolatedShadow !== undefined ? { isolatedShadow: input.isolatedShadow } : {}), + ...(input.seedAssumedSchemas !== undefined + ? { seedAssumedSchemas: input.seedAssumedSchemas } + : {}), + ...(input.restrictToApplier !== undefined + ? { resolveOptions: { restrictToApplier: input.restrictToApplier } } + : {}), + ...(input.strictFunctionBodies !== undefined + ? { strictFunctionBodies: input.strictFunctionBodies } + : {}), + reorder: input.reorder ?? true, + ...(input.onWarning !== undefined ? { onWarning: input.onWarning } : {}), + }; +} + +function legacyMakePgDeltaNextAdapter( + libraries: LegacyPgDeltaNextLibraries, +): LegacyPgDeltaNextAdapterShape { + return { + diff: (input: LegacyPgDeltaNextDiffInput) => + legacyTryPgDeltaNext("diff", async () => { + const format = legacyPgDeltaNextFormatOptions(input.formatOptions); + const redactSecrets = input.redactSecrets ?? true; + const profile = await libraries.resolveProfile( + input.sourcePool, + { + redactSecrets, + ...(input.restrictToApplier !== undefined + ? { restrictToApplier: input.restrictToApplier } + : {}), + }, + input.schema, + ); + const [source, desired] = await Promise.all([ + profile.extract(input.sourcePool, { redactSecrets }), + profile.extract(input.desiredPool, { redactSecrets }), + ]); + const generatedPlan = libraries.plan(source.factBase, desired.factBase, { + ...profile.planOptions, + redactSecrets, + }); + const rendered = libraries.renderPlanFiles(generatedPlan, { + allowDrops: input.allowDrops, + }); + const renderedFiles = legacyFormatPgDeltaNextRenderedFiles(rendered.files, format); + const diagnostics = [ + ...legacyNormalizePgDeltaNextDiagnostics( + source.diagnostics, + "source", + libraries.encodeSubject, + ), + ...legacyNormalizePgDeltaNextDiagnostics( + desired.diagnostics, + "desired", + libraries.encodeSubject, + ), + ]; + return { + changes: rendered.changes, + sql: renderedFiles.map((file) => file.contents).join("\n\n"), + files: legacyNormalizePgDeltaNextRenderedFiles(renderedFiles), + diagnostics, + ...(input.debug + ? { + debug: { + sourceSnapshot: libraries.serializeSnapshot(source.factBase, { + pgVersion: source.pgVersion, + redactSecrets, + profile: profile.id, + }), + desiredSnapshot: libraries.serializeSnapshot(desired.factBase, { + pgVersion: desired.pgVersion, + redactSecrets, + profile: profile.id, + }), + plan: libraries.serializePlan(generatedPlan), + }, + } + : {}), + }; + }), + exportDeclarativeSchema: (input: LegacyPgDeltaNextDeclarativeExportInput) => + legacyTryPgDeltaNext("declarativeExport", async () => { + const result = await libraries.buildSchemaExport( + input.pool, + legacyPgDeltaNextExportOptions(input), + ); + return { + files: result.files.map((file) => ({ name: file.name, sql: file.sql })), + manifest: { + ...result.manifest, + files: result.files.map((file) => file.name).sort(), + }, + diagnostics: legacyNormalizePgDeltaNextDiagnostics( + result.diagnostics, + "export", + libraries.encodeSubject, + ), + }; + }), + planDeclarativeSchema: (input: LegacyPgDeltaNextDeclarativePlanInput) => + legacyTryPgDeltaNext("declarativePlan", async () => { + const format = legacyPgDeltaNextFormatOptions(input.formatOptions); + const planningInput = { ...input, reorder: input.reorder ?? true }; + const result = await libraries.planSchemaFiles( + input.targetPool, + input.shadowPool, + input.files, + planningInput, + ); + const rendered = libraries.renderPlanFiles(result.plan, { + allowDrops: input.allowDrops, + }); + const renderedFiles = legacyFormatPgDeltaNextRenderedFiles(rendered.files, format); + return { + changes: rendered.changes, + sql: renderedFiles.map((file) => file.contents).join("\n\n"), + files: legacyNormalizePgDeltaNextRenderedFiles(renderedFiles), + diagnostics: [ + ...legacyNormalizePgDeltaNextDiagnostics( + result.loadDiagnostics, + "declarativeLoad", + libraries.encodeSubject, + ), + ...legacyNormalizePgDeltaNextDiagnostics( + result.targetDiagnostics, + "declarativeTarget", + libraries.encodeSubject, + ), + ], + skipped: result.skipped.map((skipped) => ({ + file: skipped.file, + statement: skipped.stmt, + })), + removals: libraries.summarizeRemovals(result.plan), + ...(input.debug ? { debug: { plan: libraries.serializePlan(result.plan) } } : {}), + }; + }), + captureSnapshot: (input: LegacyPgDeltaNextSnapshotCaptureInput) => + legacyTryPgDeltaNext("snapshotCapture", async () => { + const redactSecrets = input.redactSecrets ?? true; + const profile = await libraries.resolveProfile(input.pool, { + redactSecrets, + skipBaseline: true, + }); + const result = await profile.extract(input.pool, { + redactSecrets, + ...(input.statementTimeoutMs !== undefined + ? { statementTimeoutMs: input.statementTimeoutMs } + : {}), + }); + return { + generation: "v2", + snapshot: libraries.serializeSnapshot(result.factBase, { + pgVersion: result.pgVersion, + redactSecrets, + profile: profile.id, + }), + pgVersion: result.pgVersion, + diagnostics: legacyNormalizePgDeltaNextDiagnostics( + result.diagnostics, + "snapshot", + libraries.encodeSubject, + ), + }; + }), + }; +} + +const legacyPgDeltaNextRealLibraries = { + resolveProfile: async ( + pool: Pool, + options: Parameters[2], + schema?: readonly string[], + ) => { + const resolved = await resolveProfile(pool, legacyPgDeltaNextProfile(schema), options); + return { + ...resolved, + extract: async ( + extractPool: Pool, + extractOptions?: Parameters[1], + ) => { + const result = await resolved.extract(extractPool, extractOptions); + return { + ...result, + diagnostics: await legacyFilterPgDeltaNextPlatformDiagnostics( + extractPool, + result.diagnostics, + ), + }; + }, + }; + }, + plan, + renderPlanFiles, + buildSchemaExport: async (pool: Pool, input: LegacyPgDeltaNextLibraryExportOptions) => { + const result = await buildSchemaExport(pool, input); + return { + ...result, + diagnostics: await legacyFilterPgDeltaNextPlatformDiagnostics(pool, result.diagnostics), + }; + }, + planSchemaFiles: async ( + targetPool: Pool, + shadowPool: Pool, + files: readonly LegacyPgDeltaNextSqlFile[], + input: LegacyPgDeltaNextDeclarativePlanInput, + ) => { + const result = await planSchemaFiles( + targetPool, + shadowPool, + files.map((file) => ({ name: file.name, sql: file.sql })), + legacyPgDeltaNextPlanOptions(input), + ); + const [loadDiagnostics, targetDiagnostics] = await Promise.all([ + legacyFilterPgDeltaNextPlatformDiagnostics(shadowPool, result.loadDiagnostics), + legacyFilterPgDeltaNextPlatformDiagnostics(targetPool, result.targetDiagnostics), + ]); + return { ...result, loadDiagnostics, targetDiagnostics }; + }, + serializeSnapshot, + serializePlan, + summarizeRemovals: legacySummarizePgDeltaNextRemovals, + encodeSubject: encodeId, +}; + +export function legacyPgDeltaNextAdapterLayerFromLibraries< + FactBase, + PlanOptions extends object, + Plan, + Subject, +>(libraries: LegacyPgDeltaNextLibraries) { + return Layer.succeed( + LegacyPgDeltaNextAdapter, + LegacyPgDeltaNextAdapter.of(legacyMakePgDeltaNextAdapter(libraries)), + ); +} + +export const legacyPgDeltaNextAdapterLayer = legacyPgDeltaNextAdapterLayerFromLibraries( + legacyPgDeltaNextRealLibraries, +); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.service.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.service.ts new file mode 100644 index 0000000000..aba46a3f66 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.service.ts @@ -0,0 +1,197 @@ +import type { Pool } from "pg"; +import { Context, Data, type Effect } from "effect"; + +import type { + LegacyPgDeltaRemovalSummary, + LegacyPgDeltaTransactionMode, +} from "./legacy-pgdelta-engine.service.ts"; + +export type LegacyPgDeltaNextOperation = + | "diff" + | "declarativeExport" + | "declarativePlan" + | "snapshotCapture"; + +export type LegacyPgDeltaNextDiagnosticOrigin = + | "source" + | "desired" + | "export" + | "declarativeLoad" + | "declarativeTarget" + | "snapshot"; + +export interface LegacyPgDeltaNextDiagnostic { + readonly origin: LegacyPgDeltaNextDiagnosticOrigin; + readonly code: string; + readonly severity: "error" | "warning" | "info"; + readonly subject?: string; + readonly message: string; + readonly context?: Readonly>; +} + +export interface LegacyPgDeltaNextRenderedFile { + readonly sequence: number; + readonly suffix: string | null; + readonly sql: string; + readonly transactionMode: LegacyPgDeltaTransactionMode; + readonly actionCount: number; +} + +export interface LegacyPgDeltaNextSqlFile { + readonly name: string; + readonly sql: string; +} + +interface LegacyPgDeltaNextDebugArtifacts { + readonly sourceSnapshot?: string; + readonly desiredSnapshot?: string; + readonly plan?: string; +} + +export interface LegacyPgDeltaNextDiffInput { + /** The live database the rendered migration will be applied to. */ + readonly sourcePool: Pool; + /** The live database whose state is desired. */ + readonly desiredPool: Pool; + readonly allowDrops: boolean; + readonly debug: boolean; + readonly redactSecrets?: boolean; + readonly restrictToApplier?: boolean; + readonly schema?: readonly string[]; + readonly formatOptions?: string; +} + +interface LegacyPgDeltaNextDiffResult { + readonly changes: boolean; + readonly sql: string; + readonly files: readonly LegacyPgDeltaNextRenderedFile[]; + readonly diagnostics: readonly LegacyPgDeltaNextDiagnostic[]; + readonly debug?: LegacyPgDeltaNextDebugArtifacts; +} + +type LegacyPgDeltaNextManagementScope = "database" | "cluster"; +type LegacyPgDeltaNextExportLayout = "by-object" | "ordered" | "grouped"; + +interface LegacyPgDeltaNextExportGroupingPattern { + readonly pattern: string; + readonly name: string; +} + +interface LegacyPgDeltaNextExportGrouping { + readonly mode?: "single-file" | "subdirectory"; + readonly groupPatterns?: readonly LegacyPgDeltaNextExportGroupingPattern[]; + readonly flatSchemas?: readonly string[]; + readonly autoGroupPartitions?: boolean; +} + +export interface LegacyPgDeltaNextDeclarativeExportInput { + readonly pool: Pool; + readonly scope?: LegacyPgDeltaNextManagementScope; + readonly redactSecrets?: boolean; + readonly restrictToApplier?: boolean; + readonly layout?: LegacyPgDeltaNextExportLayout; + readonly grouping?: LegacyPgDeltaNextExportGrouping; + readonly defaultOwner?: string | null; + readonly onWarning?: (message: string) => void; + readonly schema?: readonly string[]; + readonly formatOptions?: string; +} + +export interface LegacyPgDeltaNextExportManifest { + readonly redactSecrets: boolean; + readonly scope: LegacyPgDeltaNextManagementScope; + readonly profile?: string; + readonly baselineDigest?: string; + readonly defaultOwner?: string | null; + readonly files?: readonly string[]; +} + +interface LegacyPgDeltaNextDeclarativeExportResult { + readonly files: readonly LegacyPgDeltaNextSqlFile[]; + readonly manifest: LegacyPgDeltaNextExportManifest; + readonly diagnostics: readonly LegacyPgDeltaNextDiagnostic[]; +} + +export interface LegacyPgDeltaNextDeclarativeManifestInput { + readonly redactSecrets?: boolean; + readonly profile?: string; + readonly scope?: LegacyPgDeltaNextManagementScope; + readonly baselineDigest?: string; + readonly defaultOwner?: string | null; + readonly files?: readonly string[]; +} + +export interface LegacyPgDeltaNextDeclarativePlanInput { + readonly targetPool: Pool; + readonly shadowPool: Pool; + readonly files: readonly LegacyPgDeltaNextSqlFile[]; + readonly allowDrops: boolean; + readonly debug: boolean; + readonly scope?: LegacyPgDeltaNextManagementScope; + readonly manifest?: LegacyPgDeltaNextDeclarativeManifestInput; + readonly redactSecrets?: boolean; + readonly skipClusterDdl?: boolean; + readonly isolatedShadow?: boolean; + readonly seedAssumedSchemas?: boolean; + readonly restrictToApplier?: boolean; + readonly strictFunctionBodies?: boolean; + readonly formatOptions?: string; + /** Defaults to true, preserving pg-topo statement-level reorder support. */ + readonly reorder?: boolean; + readonly onWarning?: (message: string) => void; + readonly schema?: readonly string[]; +} + +interface LegacyPgDeltaNextSkippedStatement { + readonly file: string; + readonly statement: string; +} + +interface LegacyPgDeltaNextDeclarativePlanResult { + readonly changes: boolean; + readonly sql: string; + readonly files: readonly LegacyPgDeltaNextRenderedFile[]; + readonly diagnostics: readonly LegacyPgDeltaNextDiagnostic[]; + readonly skipped: readonly LegacyPgDeltaNextSkippedStatement[]; + readonly removals: LegacyPgDeltaRemovalSummary; + readonly debug?: LegacyPgDeltaNextDebugArtifacts; +} + +export interface LegacyPgDeltaNextSnapshotCaptureInput { + readonly pool: Pool; + readonly redactSecrets?: boolean; + readonly statementTimeoutMs?: number; +} + +interface LegacyPgDeltaNextSnapshotCaptureResult { + readonly generation: "v2"; + readonly snapshot: string; + readonly pgVersion: string; + readonly diagnostics: readonly LegacyPgDeltaNextDiagnostic[]; +} + +export class LegacyPgDeltaNextError extends Data.TaggedError("LegacyPgDeltaNextError")<{ + readonly operation: LegacyPgDeltaNextOperation; + readonly message: string; + readonly cause: unknown; +}> {} + +export interface LegacyPgDeltaNextAdapterShape { + readonly diff: ( + input: LegacyPgDeltaNextDiffInput, + ) => Effect.Effect; + readonly exportDeclarativeSchema: ( + input: LegacyPgDeltaNextDeclarativeExportInput, + ) => Effect.Effect; + readonly planDeclarativeSchema: ( + input: LegacyPgDeltaNextDeclarativePlanInput, + ) => Effect.Effect; + readonly captureSnapshot: ( + input: LegacyPgDeltaNextSnapshotCaptureInput, + ) => Effect.Effect; +} + +export class LegacyPgDeltaNextAdapter extends Context.Service< + LegacyPgDeltaNextAdapter, + LegacyPgDeltaNextAdapterShape +>()("supabase/legacy/PgDeltaNextAdapter") {} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.unit.test.ts new file mode 100644 index 0000000000..98fdaedd9e --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.unit.test.ts @@ -0,0 +1,684 @@ +import { it } from "@effect/vitest"; +import { ShadowLoadError } from "@supabase/pg-delta/frontends"; +import { Effect } from "effect"; +import { Pool } from "pg"; +import { describe, expect } from "vitest"; + +import { + legacyPgDeltaNextAdapterLayer, + legacyPgDeltaNextAdapterLayerFromLibraries, + legacyFilterPgDeltaNextPlatformParameterAclDiagnostics, + legacyPgDeltaNextProfile, + legacyPgDeltaNextUserOwnedParameterAcls, + legacySummarizePgDeltaNextRemovals, + type LegacyPgDeltaNextLibraries, +} from "./legacy-pgdelta-next-adapter.layer.ts"; +import { + LegacyPgDeltaNextAdapter, + LegacyPgDeltaNextError, +} from "./legacy-pgdelta-next-adapter.service.ts"; + +interface FakeFactBase { + readonly id: string; +} + +interface FakePlanOptions { + readonly managedView: string; +} + +interface FakePlan { + readonly source: string; + readonly desired: string; +} + +interface FakeSubject { + readonly id: string; +} + +function fakeDiagnostic(code: string, subject: string) { + return { + code, + severity: "warning" as const, + subject: { id: subject }, + message: `${code} message`, + context: { detail: code }, + }; +} + +function setupLibraries(sourcePool: Pool, desiredPool: Pool) { + const state = { + resolveCalls: [] as Array<{ + pool: Pool; + options: { + restrictToApplier?: boolean; + redactSecrets?: boolean; + skipBaseline?: boolean; + }; + schema?: readonly string[]; + }>, + extractCalls: [] as Array<{ pool: Pool; options: object | undefined }>, + planCalls: [] as Array<{ + source: FakeFactBase; + desired: FakeFactBase; + options: FakePlanOptions & { redactSecrets: boolean }; + }>, + renderOptions: [] as Array<{ allowDrops: boolean }>, + exportInputs: [] as object[], + declarativeInputs: [] as object[], + snapshotMetadata: [] as object[], + serializedPlans: [] as FakePlan[], + renderChanges: true, + }; + + const extract = async ( + pool: Pool, + options?: { redactSecrets?: boolean; statementTimeoutMs?: number }, + ) => { + state.extractCalls.push({ pool, options }); + const source = pool === sourcePool; + if (!source && pool !== desiredPool) { + throw new Error("unexpected pool passed to fake extractor"); + } + return { + factBase: { id: source ? "source-facts" : "desired-facts" }, + pgVersion: source ? "15.9" : "17.6", + diagnostics: [ + fakeDiagnostic(source ? "source-warning" : "desired-warning", source ? "s" : "d"), + ], + }; + }; + + const libraries: LegacyPgDeltaNextLibraries< + FakeFactBase, + FakePlanOptions, + FakePlan, + FakeSubject + > = { + resolveProfile: async (pool, options, schema) => { + state.resolveCalls.push({ pool, options, ...(schema !== undefined ? { schema } : {}) }); + return { + id: "supabase", + planOptions: { managedView: "shared-profile-options" }, + extract, + }; + }, + plan: (source, desired, options) => { + state.planCalls.push({ source, desired, options }); + return { source: source.id, desired: desired.id }; + }, + renderPlanFiles: (_generatedPlan, options) => { + state.renderOptions.push(options); + if (!state.renderChanges) return { changes: false, files: [] }; + return { + changes: true, + files: [ + { + suffix: "_1", + contents: "CREATE TABLE public.widgets (id integer, display_name text);\n", + transactional: true, + actionCount: 2, + }, + { + suffix: "_2", + contents: + "-- pg-delta: transaction=false\nSET check_function_bodies = off;\n\nGRANT SELECT ON TABLE public.widgets TO anon;\n\nRESET ALL;\n", + transactional: false, + actionCount: 1, + }, + ], + }; + }, + buildSchemaExport: async (_pool, input) => { + state.exportInputs.push(input); + return { + files: [{ name: "schemas/public/tables/items.sql", sql: "create table items();" }], + diagnostics: [fakeDiagnostic("export-warning", "export")], + manifest: { + redactSecrets: true, + scope: "database", + profile: "supabase", + defaultOwner: "postgres", + }, + }; + }, + planSchemaFiles: async (_targetPool, _shadowPool, _files, input) => { + state.declarativeInputs.push(input); + return { + plan: { source: "target-facts", desired: "loaded-files" }, + loadDiagnostics: [fakeDiagnostic("load-warning", "load")], + targetDiagnostics: [fakeDiagnostic("target-warning", "target")], + skipped: [{ file: "roles.sql", stmt: "create role ignored" }], + }; + }, + serializeSnapshot: (factBase, metadata) => { + state.snapshotMetadata.push(metadata); + return JSON.stringify({ factBase: factBase.id, metadata }); + }, + serializePlan: (generatedPlan) => { + state.serializedPlans.push(generatedPlan); + return JSON.stringify(generatedPlan); + }, + summarizeRemovals: () => ({ + extensions: ["pgcrypto"], + extensionIntents: [ + { extension: "pg_cron", intentKind: "job", key: "refresh download metrics" }, + ], + }), + encodeSubject: (subject) => `subject:${subject.id}`, + }; + + return { + state, + layer: legacyPgDeltaNextAdapterLayerFromLibraries(libraries), + }; +} + +describe("LegacyPgDeltaNextAdapter", () => { + it("summarizes only root extension and extension-intent removals", () => { + expect( + legacySummarizePgDeltaNextRemovals({ + deltas: [ + { verb: "remove", fact: { id: { kind: "extension", name: "uuid-ossp" }, payload: {} } }, + { verb: "remove", fact: { id: { kind: "extension", name: "pgcrypto" }, payload: {} } }, + { + verb: "remove", + fact: { + id: { kind: "extension", name: "nested-extension" }, + parent: { kind: "schema", name: "extensions" }, + payload: {}, + }, + }, + { + verb: "remove", + fact: { + id: { + kind: "extensionIntent", + ext: "pg_cron", + intentKind: "job", + key: "refresh download metrics", + }, + payload: {}, + }, + }, + { + verb: "remove", + fact: { + id: { kind: "comment", target: { kind: "extension", name: "pgcrypto" } }, + payload: {}, + }, + }, + { + verb: "unlink", + edge: { + from: { kind: "extension", name: "pgcrypto" }, + to: { kind: "schema", name: "extensions" }, + kind: "depends", + }, + }, + ], + }), + ).toEqual({ + extensions: ["pgcrypto", "uuid-ossp"], + extensionIntents: [ + { extension: "pg_cron", intentKind: "job", key: "refresh download metrics" }, + ], + }); + }); + + it("filters platform parameter ACL coverage without hiding user-owned ACLs", () => { + const diagnostics = [ + { + origin: "declarativeLoad" as const, + code: "unmodeled_kind", + severity: "warning" as const, + message: "2 unmodeled parameter ACLs", + context: { + kind: "parameter ACL", + count: 2, + samples: ["log_min_messages", "work_mem"], + }, + }, + { + origin: "declarativeLoad" as const, + code: "unsupported_extension", + severity: "warning" as const, + message: "extension is externally managed", + }, + ]; + + expect(legacyFilterPgDeltaNextPlatformParameterAclDiagnostics(diagnostics, [])).toEqual([ + diagnostics[1], + ]); + expect( + legacyFilterPgDeltaNextPlatformParameterAclDiagnostics(diagnostics, ["work_mem"]), + ).toEqual([ + { + ...diagnostics[0], + message: + '1 unmodeled "parameter ACL" object not managed by this engine (e.g. work_mem) — v1 detects but does not model this kind', + context: { kind: "parameter ACL", count: 1, samples: ["work_mem"] }, + }, + diagnostics[1], + ]); + }); + + it("recognizes only the exact Supabase platform parameter grant tuples", () => { + expect( + legacyPgDeltaNextUserOwnedParameterAcls([ + { name: "log_min_messages", grantee: "supabase_admin", privilege: "SET" }, + { name: "log_min_messages", grantee: "app_user", privilege: "SET" }, + { name: "work_mem", grantee: "supabase_realtime_admin", privilege: "SET" }, + { name: "work_mem", grantee: "app_user", privilege: "SET" }, + ]), + ).toEqual(["log_min_messages", "work_mem"]); + expect( + legacyPgDeltaNextUserOwnedParameterAcls([ + { name: "log_min_messages", grantee: "supabase_admin", privilege: "ALTER SYSTEM" }, + { name: "log_min_messages", grantee: "supabase_admin", privilege: "SET" }, + { name: "log_min_messages", grantee: "supabase_realtime_admin", privilege: "SET" }, + ]), + ).toEqual([]); + expect( + legacyPgDeltaNextUserOwnedParameterAcls([ + { name: "log_min_messages", grantee: "supabase_realtime_admin", privilege: "ALTER SYSTEM" }, + ]), + ).toEqual(["log_min_messages"]); + }); + + it("composes schema exclusions ahead of the Supabase managed-view policy", () => { + const profile = legacyPgDeltaNextProfile(["public", "tenant"]); + expect(profile.id).toBe("supabase"); + expect(profile.policy?.filter).toEqual([ + { + match: { all: [{ schema: "*" }, { not: { schema: ["public", "tenant"] } }] }, + action: "exclude", + }, + { + match: { + all: [{ kind: "schema" }, { not: { name: ["public", "tenant"] } }], + }, + action: "exclude", + }, + { + match: { + all: [{ target: { schema: "*" } }, { not: { target: { schema: ["public", "tenant"] } } }], + }, + action: "exclude", + }, + ]); + expect(profile.policy?.extends).toHaveLength(1); + }); + + it.effect("constructs the real adapter from supported public pg-delta subpaths", () => + Effect.gen(function* () { + const adapter = yield* LegacyPgDeltaNextAdapter; + expect(adapter.diff).toBeTypeOf("function"); + expect(adapter.exportDeclarativeSchema).toBeTypeOf("function"); + expect(adapter.planDeclarativeSchema).toBeTypeOf("function"); + expect(adapter.captureSnapshot).toBeTypeOf("function"); + }).pipe(Effect.provide(legacyPgDeltaNextAdapterLayer)), + ); + + it.effect( + "resolves one shared profile for a pool-to-pool diff and emits structured debug data", + () => { + const sourcePool = new Pool(); + const desiredPool = new Pool(); + const { layer, state } = setupLibraries(sourcePool, desiredPool); + + return Effect.gen(function* () { + const adapter = yield* LegacyPgDeltaNextAdapter; + const result = yield* adapter.diff({ + sourcePool, + desiredPool, + allowDrops: true, + debug: true, + redactSecrets: false, + restrictToApplier: true, + schema: ["public"], + formatOptions: '{"keywordCase":"upper","indent":4}', + }); + + expect(state.resolveCalls).toEqual([ + { + pool: sourcePool, + options: { redactSecrets: false, restrictToApplier: true }, + schema: ["public"], + }, + ]); + expect(state.extractCalls).toEqual([ + { pool: sourcePool, options: { redactSecrets: false } }, + { pool: desiredPool, options: { redactSecrets: false } }, + ]); + expect(state.planCalls).toEqual([ + { + source: { id: "source-facts" }, + desired: { id: "desired-facts" }, + options: { redactSecrets: false, managedView: "shared-profile-options" }, + }, + ]); + expect(state.renderOptions).toEqual([{ allowDrops: true }]); + expect(result.files).toEqual([ + { + sequence: 1, + suffix: "_1", + sql: "CREATE TABLE public.widgets (\n id integer,\n display_name text\n);\n", + transactionMode: "transactional", + actionCount: 2, + }, + { + sequence: 2, + suffix: "_2", + sql: "-- pg-delta: transaction=false\nSET check_function_bodies = off;\n\nGRANT SELECT ON TABLE public.widgets TO anon;\n\nRESET ALL;\n", + transactionMode: "none", + actionCount: 1, + }, + ]); + expect(result.sql).toBe( + "CREATE TABLE public.widgets (\n id integer,\n display_name text\n);\n\n\n-- pg-delta: transaction=false\nSET check_function_bodies = off;\n\nGRANT SELECT ON TABLE public.widgets TO anon;\n\nRESET ALL;\n", + ); + expect(result.diagnostics).toEqual([ + { + origin: "source", + code: "source-warning", + severity: "warning", + subject: "subject:s", + message: "source-warning message", + context: { detail: "source-warning" }, + }, + { + origin: "desired", + code: "desired-warning", + severity: "warning", + subject: "subject:d", + message: "desired-warning message", + context: { detail: "desired-warning" }, + }, + ]); + expect(result.debug).toEqual({ + sourceSnapshot: expect.stringContaining("source-facts"), + desiredSnapshot: expect.stringContaining("desired-facts"), + plan: JSON.stringify({ source: "source-facts", desired: "desired-facts" }), + }); + expect(state.snapshotMetadata).toEqual([ + { pgVersion: "15.9", redactSecrets: false, profile: "supabase" }, + { pgVersion: "17.6", redactSecrets: false, profile: "supabase" }, + ]); + expect(sourcePool.ending).toBe(false); + expect(sourcePool.ended).toBe(false); + expect(desiredPool.ending).toBe(false); + expect(desiredPool.ended).toBe(false); + yield* Effect.promise(() => Promise.all([sourcePool.end(), desiredPool.end()])); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect("preserves a no-change result without creating debug artifacts", () => { + const sourcePool = new Pool(); + const desiredPool = new Pool(); + const { layer, state } = setupLibraries(sourcePool, desiredPool); + state.renderChanges = false; + + return Effect.gen(function* () { + const adapter = yield* LegacyPgDeltaNextAdapter; + const result = yield* adapter.diff({ + sourcePool, + desiredPool, + allowDrops: false, + debug: false, + }); + expect(result.changes).toBe(false); + expect(result.sql).toBe(""); + expect(result.files).toEqual([]); + expect(result.debug).toBeUndefined(); + expect(state.snapshotMetadata).toEqual([]); + expect(state.renderOptions).toEqual([{ allowDrops: false }]); + yield* Effect.promise(() => Promise.all([sourcePool.end(), desiredPool.end()])); + }).pipe(Effect.provide(layer)); + }); + + it.effect("formats rendered migration files with the human-readable defaults", () => { + const sourcePool = new Pool(); + const desiredPool = new Pool(); + const { layer } = setupLibraries(sourcePool, desiredPool); + + return Effect.gen(function* () { + const adapter = yield* LegacyPgDeltaNextAdapter; + const result = yield* adapter.diff({ + sourcePool, + desiredPool, + allowDrops: false, + debug: false, + }); + expect(result.files[0]?.sql).toBe( + "create table public.widgets (\n id integer,\n display_name text\n);\n", + ); + expect(result.files[1]?.sql).toBe( + "-- pg-delta: transaction=false\nset check_function_bodies = off;\n\ngrant select on table public.widgets to anon;\n\nreset all;\n", + ); + yield* Effect.promise(() => Promise.all([sourcePool.end(), desiredPool.end()])); + }).pipe(Effect.provide(layer)); + }); + + it.effect( + "normalizes declarative export and planning results with reorder enabled by default", + () => { + const targetPool = new Pool(); + const shadowPool = new Pool(); + const { layer, state } = setupLibraries(targetPool, shadowPool); + + return Effect.gen(function* () { + const adapter = yield* LegacyPgDeltaNextAdapter; + const exported = yield* adapter.exportDeclarativeSchema({ + pool: targetPool, + layout: "grouped", + restrictToApplier: true, + formatOptions: + '{"keywordCase":"lower","commaStyle":"leading","indent":4,"maxWidth":100,"alignColumns":true,"alignKeyValues":false,"preserveRoutineBodies":true,"preserveViewBodies":false,"preserveRuleBodies":true,"ignored":"value"}', + }); + expect(exported.files).toEqual([ + { name: "schemas/public/tables/items.sql", sql: "create table items();" }, + ]); + expect(exported.manifest).toEqual({ + redactSecrets: true, + scope: "database", + profile: "supabase", + defaultOwner: "postgres", + files: ["schemas/public/tables/items.sql"], + }); + expect(exported.diagnostics[0]).toMatchObject({ + origin: "export", + subject: "subject:export", + }); + expect(state.exportInputs).toHaveLength(1); + expect(state.exportInputs[0]).toMatchObject({ + layout: "grouped", + resolveOptions: { restrictToApplier: true }, + format: { + keywordCase: "lower", + commaStyle: "leading", + indent: 4, + maxWidth: 100, + alignColumns: true, + alignKeyValues: false, + preserveRoutineBodies: true, + preserveViewBodies: false, + preserveRuleBodies: true, + }, + }); + expect(state.exportInputs[0]).not.toHaveProperty("formatOptions"); + + yield* adapter.exportDeclarativeSchema({ + pool: targetPool, + layout: "grouped", + }); + expect(state.exportInputs[1]).toMatchObject({ + format: { keywordCase: "lower", maxWidth: 180 }, + }); + + const planned = yield* adapter.planDeclarativeSchema({ + targetPool, + shadowPool, + files: exported.files, + allowDrops: true, + debug: true, + isolatedShadow: true, + seedAssumedSchemas: true, + formatOptions: "null", + }); + expect(state.declarativeInputs).toHaveLength(1); + expect(state.declarativeInputs[0]).toMatchObject({ + reorder: true, + seedAssumedSchemas: true, + }); + expect(planned.diagnostics.map((diagnostic) => diagnostic.origin)).toEqual([ + "declarativeLoad", + "declarativeTarget", + ]); + expect(planned.skipped).toEqual([{ file: "roles.sql", statement: "create role ignored" }]); + expect(planned.removals).toEqual({ + extensions: ["pgcrypto"], + extensionIntents: [ + { extension: "pg_cron", intentKind: "job", key: "refresh download metrics" }, + ], + }); + expect(planned.debug).toEqual({ + plan: JSON.stringify({ source: "target-facts", desired: "loaded-files" }), + }); + expect(planned.files.map((file) => file.sql)).toEqual([ + "CREATE TABLE public.widgets (id integer, display_name text);\n", + "-- pg-delta: transaction=false\nSET check_function_bodies = off;\n\nGRANT SELECT ON TABLE public.widgets TO anon;\n\nRESET ALL;\n", + ]); + expect(state.renderOptions).toEqual([{ allowDrops: true }]); + yield* Effect.promise(() => Promise.all([targetPool.end(), shadowPool.end()])); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect("captures a v2 snapshot with a single baseline-free profile resolution", () => { + const pool = new Pool(); + const unusedDesiredPool = new Pool(); + const { layer, state } = setupLibraries(pool, unusedDesiredPool); + + return Effect.gen(function* () { + const adapter = yield* LegacyPgDeltaNextAdapter; + const result = yield* adapter.captureSnapshot({ + pool, + statementTimeoutMs: 4_000, + }); + expect(result.generation).toBe("v2"); + expect(result.pgVersion).toBe("15.9"); + expect(result.snapshot).toContain("source-facts"); + expect(state.resolveCalls).toEqual([ + { + pool, + options: { redactSecrets: true, skipBaseline: true }, + }, + ]); + expect(state.extractCalls).toEqual([ + { + pool, + options: { redactSecrets: true, statementTimeoutMs: 4_000 }, + }, + ]); + yield* Effect.promise(() => Promise.all([pool.end(), unusedDesiredPool.end()])); + }).pipe(Effect.provide(layer)); + }); + + it.effect("maps library rejections to an actionable typed error", () => { + const sourcePool = new Pool(); + const desiredPool = new Pool(); + const cause = new Error("connection refused for desired database"); + const failingLayer = legacyPgDeltaNextAdapterLayerFromLibraries({ + resolveProfile: async () => { + throw cause; + }, + plan: () => ({ source: "unused", desired: "unused" }), + renderPlanFiles: () => ({ changes: false, files: [] }), + buildSchemaExport: async () => ({ + files: [], + diagnostics: [], + manifest: { redactSecrets: true, scope: "database" }, + }), + planSchemaFiles: async () => ({ + plan: { source: "unused", desired: "unused" }, + loadDiagnostics: [], + targetDiagnostics: [], + skipped: [], + }), + serializeSnapshot: () => "unused", + serializePlan: () => "unused", + summarizeRemovals: () => ({ extensions: [], extensionIntents: [] }), + encodeSubject: (subject: string) => subject, + }); + + return Effect.gen(function* () { + const adapter = yield* LegacyPgDeltaNextAdapter; + const error = yield* adapter + .diff({ sourcePool, desiredPool, allowDrops: false, debug: false }) + .pipe(Effect.flip); + expect(error).toBeInstanceOf(LegacyPgDeltaNextError); + expect(error.operation).toBe("diff"); + expect(error.message).toBe("Database diff failed: connection refused for desired database"); + expect(error.cause).toBe(cause); + yield* Effect.promise(() => Promise.all([sourcePool.end(), desiredPool.end()])); + }).pipe(Effect.provide(failingLayer)); + }); + + it.effect("preserves shadow-load diagnostics in the actionable error", () => { + const targetPool = new Pool(); + const shadowPool = new Pool(); + const cause = new ShadowLoadError("2 files cannot apply", [ + { + code: "stuck_statement", + severity: "error", + message: 'extensions/pg_cron.sql: extension "pg_cron" already exists', + }, + { + code: "stuck_statement", + severity: "error", + message: 'extensions/pg_net.sql: extension "pg_net" already exists', + }, + ]); + const failingLayer = legacyPgDeltaNextAdapterLayerFromLibraries({ + resolveProfile: async () => { + throw new Error("unused"); + }, + plan: () => ({ source: "unused", desired: "unused" }), + renderPlanFiles: () => ({ changes: false, files: [] }), + buildSchemaExport: async () => ({ + files: [], + diagnostics: [], + manifest: { redactSecrets: true, scope: "database" }, + }), + planSchemaFiles: async () => { + throw cause; + }, + serializeSnapshot: () => "unused", + serializePlan: () => "unused", + summarizeRemovals: () => ({ extensions: [], extensionIntents: [] }), + encodeSubject: (subject: string) => subject, + }); + + return Effect.gen(function* () { + const adapter = yield* LegacyPgDeltaNextAdapter; + const error = yield* adapter + .planDeclarativeSchema({ + targetPool, + shadowPool, + files: [], + allowDrops: false, + debug: false, + isolatedShadow: true, + seedAssumedSchemas: false, + }) + .pipe(Effect.flip); + expect(error).toBeInstanceOf(LegacyPgDeltaNextError); + expect(error.message).toBe( + 'Declarative schema planning failed: 2 files cannot apply\n - extensions/pg_cron.sql: extension "pg_cron" already exists\n - extensions/pg_net.sql: extension "pg_net" already exists', + ); + expect(error.cause).toBe(cause); + yield* Effect.promise(() => Promise.all([targetPool.end(), shadowPool.end()])); + }).pipe(Effect.provide(failingLayer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-artifacts.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-artifacts.ts new file mode 100644 index 0000000000..ebff9b0a4a --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-artifacts.ts @@ -0,0 +1,81 @@ +import { Effect, type FileSystem, type Path } from "effect"; + +import { legacyPgDeltaTempPath } from "../../../shared/legacy-pgdelta.cache.ts"; +import type { + LegacyPgDeltaNextDiagnostic, + LegacyPgDeltaNextOperation, +} from "./legacy-pgdelta-next-adapter.service.ts"; + +export interface LegacyPgDeltaNextDebugArtifacts { + readonly sourceSnapshot?: string; + readonly desiredSnapshot?: string; + readonly plan?: string; + readonly diagnostics?: ReadonlyArray; +} + +/** Explicit cache/artifact generation for the bundled pg-delta implementation. */ +export function legacyPgDeltaNextTempPath(path: Path.Path, workdir: string): string { + return path.join(legacyPgDeltaTempPath(path, workdir), "v2"); +} + +/** Millisecond-resolution id so multiple operations in one command do not collide. */ +export function legacyFormatPgDeltaNextDebugId( + millis: number, + operation: LegacyPgDeltaNextOperation, +): string { + const digits = new Date(millis).toISOString().replace(/\D/gu, "").slice(0, 17); + return `${digits.slice(0, 8)}-${digits.slice(8, 14)}-${digits.slice(14)}-${operation}`; +} + +interface LegacyPgDeltaNextArtifactMetadata { + readonly version: 1; + readonly generation: "v2"; + readonly implementation: "next"; + readonly operation: LegacyPgDeltaNextOperation; + readonly cacheReusable: false; + readonly files: ReadonlyArray; +} + +/** + * Writes bundled-engine debug data below the v2 generation. These files are + * diagnostics only: they are never considered catalog-cache inputs. + */ +export const legacySavePgDeltaNextDebugArtifacts = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + id: string, + operation: LegacyPgDeltaNextOperation, + artifacts: LegacyPgDeltaNextDebugArtifacts, +) { + const debugDir = path.join(legacyPgDeltaNextTempPath(path, workdir), "debug", id); + yield* fs.makeDirectory(debugDir, { recursive: true }); + + const files: Array = []; + const write = Effect.fnUntraced(function* (name: string, contents: string | undefined) { + if (contents === undefined || contents.length === 0) return; + yield* fs.writeFileString(path.join(debugDir, name), contents); + files.push(name); + }); + + yield* write("source-snapshot.json", artifacts.sourceSnapshot); + yield* write("desired-snapshot.json", artifacts.desiredSnapshot); + yield* write("plan.json", artifacts.plan); + if (artifacts.diagnostics !== undefined) { + yield* write("diagnostics.json", `${JSON.stringify(artifacts.diagnostics, null, 2)}\n`); + } + + const metadata: LegacyPgDeltaNextArtifactMetadata = { + version: 1, + generation: "v2", + implementation: "next", + operation, + cacheReusable: false, + files: [...files].sort(), + }; + yield* fs.writeFileString( + path.join(debugDir, "metadata.json"), + `${JSON.stringify(metadata, null, 2)}\n`, + ); + return debugDir; +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-artifacts.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-artifacts.unit.test.ts new file mode 100644 index 0000000000..567d2ad2f6 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-artifacts.unit.test.ts @@ -0,0 +1,74 @@ +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, Path } from "effect"; + +import { + legacyFormatPgDeltaNextDebugId, + legacyPgDeltaNextTempPath, + legacySavePgDeltaNextDebugArtifacts, +} from "./legacy-pgdelta-next-artifacts.ts"; +import { legacyPgDeltaTempPath } from "../../../shared/legacy-pgdelta.cache.ts"; + +describe("pg-delta next artifact generation", () => { + it.effect("isolates v2 artifacts from legacy catalog paths", () => + Effect.gen(function* () { + const path = yield* Path.Path; + expect(legacyPgDeltaTempPath(path, "/project")).toBe( + join("/project", "supabase", ".temp", "pgdelta"), + ); + expect(legacyPgDeltaNextTempPath(path, "/project")).toBe( + join("/project", "supabase", ".temp", "pgdelta", "v2"), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it("uses millisecond-resolution, operation-qualified debug ids", () => { + expect(legacyFormatPgDeltaNextDebugId(Date.UTC(2024, 0, 2, 3, 4, 5, 678), "diff")).toBe( + "20240102-030405-678-diff", + ); + }); + + it.effect("writes structured non-cache artifacts and metadata under v2", () => { + const root = mkdtempSync(join(tmpdir(), "pgdelta-next-artifacts-")); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const debugDir = yield* legacySavePgDeltaNextDebugArtifacts( + fs, + path, + root, + "20240102-030405-678-diff", + "diff", + { + sourceSnapshot: '{"source":true}\n', + desiredSnapshot: '{"desired":true}\n', + plan: '{"plan":true}\n', + diagnostics: [ + { origin: "source", code: "PG001", severity: "warning", message: "warning" }, + ], + }, + ); + + expect(debugDir).toBe( + join(root, "supabase", ".temp", "pgdelta", "v2", "debug", "20240102-030405-678-diff"), + ); + expect(JSON.parse(readFileSync(join(debugDir, "metadata.json"), "utf8"))).toEqual({ + version: 1, + generation: "v2", + implementation: "next", + operation: "diff", + cacheReusable: false, + files: ["desired-snapshot.json", "diagnostics.json", "plan.json", "source-snapshot.json"], + }); + expect(JSON.parse(readFileSync(join(debugDir, "diagnostics.json"), "utf8"))).toEqual([ + { origin: "source", code: "PG001", severity: "warning", message: "warning" }, + ]); + }).pipe( + Effect.provide(BunServices.layer), + Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-diagnostics.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-diagnostics.ts new file mode 100644 index 0000000000..df89b0d79b --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-diagnostics.ts @@ -0,0 +1,164 @@ +import { Effect } from "effect"; + +import { Output } from "../../../../shared/output/output.service.ts"; +import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; +import { LegacyPgDeltaEngineError } from "./legacy-pgdelta-engine.service.ts"; +import type { + LegacyPgDeltaNextDiagnostic, + LegacyPgDeltaNextOperation, +} from "./legacy-pgdelta-next-adapter.service.ts"; + +const coverageDiagnosticCodes = new Set(["unmodeled_kind", "unresolved_security_label"]); + +const operationConsequence: Record = { + diff: "Changes to these objects are omitted from the generated database diff.", + declarativeExport: "These objects are omitted from the exported declarative schema.", + declarativePlan: "Changes to these objects are omitted from the declarative migration plan.", + snapshotCapture: "These objects are omitted from the captured database snapshot.", +}; + +const operationAction: Record = { + diff: "emit the database diff", + declarativeExport: "export the declarative schema", + declarativePlan: "emit the declarative migration plan", + snapshotCapture: "capture the database snapshot", +}; + +export interface LegacyPgDeltaNextDiagnosticReport { + readonly diagnostics: ReadonlyArray; + readonly blocking: ReadonlyArray; + readonly coverage: ReadonlyArray; + readonly unmodeledKinds: ReadonlyArray; +} + +function diagnosticKind(diagnostic: LegacyPgDeltaNextDiagnostic): string | undefined { + if (diagnostic.code !== "unmodeled_kind") return undefined; + const kind = diagnostic.context?.kind; + if (typeof kind !== "string") return undefined; + const normalized = kind.trim().replaceAll(/\s+/gu, " "); + return normalized.length === 0 ? undefined : normalized; +} + +export function legacyPgDeltaNextDiagnosticReport( + diagnostics: readonly LegacyPgDeltaNextDiagnostic[], + strictCoverage: boolean, +): LegacyPgDeltaNextDiagnosticReport { + const coverage = diagnostics.filter((diagnostic) => coverageDiagnosticCodes.has(diagnostic.code)); + const blocking = diagnostics.filter( + (diagnostic) => + diagnostic.severity === "error" || + (strictCoverage && coverageDiagnosticCodes.has(diagnostic.code)), + ); + const unmodeledKinds = [ + ...new Set(diagnostics.map(diagnosticKind).filter((kind) => kind !== undefined)), + ].sort((left, right) => left.localeCompare(right)); + + return { diagnostics: [...diagnostics], blocking, coverage, unmodeledKinds }; +} + +export function legacyPgDeltaNextDiagnosticMessage( + diagnostic: LegacyPgDeltaNextDiagnostic, +): string { + const subject = + diagnostic.subject === undefined || diagnostic.subject === "unknown" + ? "" + : ` subject=${diagnostic.subject}`; + return `pg-delta next diagnostic: origin=${diagnostic.origin} code=${diagnostic.code}${subject} message=${diagnostic.message}`; +} + +function legacyPgDeltaNextUnmodeledKindsMessage( + operation: LegacyPgDeltaNextOperation, + kinds: readonly string[], + strictCoverage: boolean, +): string { + const policy = strictCoverage + ? "Strict coverage is enabled, so the operation will stop." + : operationConsequence[operation]; + const summary = + kinds.length === 0 + ? "pg-delta found schema objects it does not manage." + : `pg-delta does not manage these PostgreSQL object kinds: ${kinds.join(", ")}.`; + return `${summary} ${policy}`; +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +export function legacyPgDeltaNextFeedbackInvitation(kinds: readonly string[]): string | undefined { + if (kinds.length === 0) return undefined; + const problem = `pg-delta does not manage these PostgreSQL object kinds: ${kinds.join(", ")}`; + const solution = "Add pg-delta support for these PostgreSQL object kinds."; + return [ + "Request pg-delta support:", + ` supabase issue feature --problem ${shellQuote(problem)} --proposed-solution ${shellQuote(solution)}`, + ].join("\n"); +} + +function legacyPgDeltaNextBlockingDiagnosticMessage( + operation: LegacyPgDeltaNextOperation, + blockedByCoverage: boolean, +): string { + const reason = blockedByCoverage + ? "strict coverage rejected unmanaged schema objects" + : "pg-delta reported an error"; + return `pg-delta next refused to ${operationAction[operation]}: ${reason}`; +} + +/** Render actionable diagnostics, route internal detail to debug, and enforce coverage policy. */ +export const legacyReportPgDeltaNextDiagnostics = Effect.fnUntraced(function* ( + operation: LegacyPgDeltaNextOperation, + diagnostics: readonly LegacyPgDeltaNextDiagnostic[], + strictCoverage: boolean, + showFeedback = true, + verboseDiagnostics = false, +) { + const output = yield* Output; + const debug = yield* LegacyDebugLogger; + const report = legacyPgDeltaNextDiagnosticReport(diagnostics, strictCoverage); + + for (const diagnostic of report.diagnostics) { + const message = legacyPgDeltaNextDiagnosticMessage(diagnostic); + const renderDetail = + verboseDiagnostics || + diagnostic.severity === "error" || + (strictCoverage && coverageDiagnosticCodes.has(diagnostic.code)); + if (!renderDetail) { + yield* debug.debug(message); + continue; + } + if (diagnostic.severity === "error") { + yield* output.error(message); + } else if (diagnostic.severity === "warning") { + yield* output.warn(message); + } else { + yield* output.info(message); + } + } + + const unmodeledCount = report.diagnostics.filter( + (diagnostic) => diagnostic.code === "unmodeled_kind", + ).length; + if (unmodeledCount > 0) { + yield* output.warn( + legacyPgDeltaNextUnmodeledKindsMessage(operation, report.unmodeledKinds, strictCoverage), + ); + } + + const feedback = showFeedback + ? legacyPgDeltaNextFeedbackInvitation(report.unmodeledKinds) + : undefined; + if (feedback !== undefined) yield* output.info(feedback); + + if (report.blocking.length > 0) { + return yield* Effect.fail( + new LegacyPgDeltaEngineError({ + message: legacyPgDeltaNextBlockingDiagnosticMessage( + operation, + strictCoverage && report.coverage.length > 0, + ), + cause: report.blocking, + }), + ); + } +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-diagnostics.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-diagnostics.unit.test.ts new file mode 100644 index 0000000000..849b1a9452 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-diagnostics.unit.test.ts @@ -0,0 +1,253 @@ +import { Effect, Exit, Layer } from "effect"; +import { it } from "@effect/vitest"; +import { describe, expect } from "vitest"; + +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; +import type { LegacyPgDeltaNextDiagnostic } from "./legacy-pgdelta-next-adapter.service.ts"; +import { + legacyPgDeltaNextDiagnosticMessage, + legacyPgDeltaNextDiagnosticReport, + legacyPgDeltaNextFeedbackInvitation, + legacyReportPgDeltaNextDiagnostics, +} from "./legacy-pgdelta-next-diagnostics.ts"; + +const unmodeled = ( + kind: unknown, + overrides: Partial = {}, +): LegacyPgDeltaNextDiagnostic => ({ + origin: "desired", + code: "unmodeled_kind", + severity: "warning", + subject: "object:public.unsupported", + message: "object kind is not modeled", + context: { kind }, + ...overrides, +}); + +const debugLayer = (messages: string[]) => + Layer.succeed(LegacyDebugLogger, { + debug: (message) => Effect.sync(() => messages.push(message)), + http: () => Effect.void, + }); + +describe("pg-delta next diagnostic coverage policy", () => { + it("summarizes unmodeled kinds and routes nonfatal diagnostic detail to debug", () => { + const out = mockOutput(); + const debugMessages: string[] = []; + return Effect.gen(function* () { + yield* legacyReportPgDeltaNextDiagnostics( + "diff", + [ + unmodeled("text search configuration"), + unmodeled("statistics object"), + { + origin: "source", + code: "dangling_edge", + severity: "warning", + subject: "role:postgres", + message: "edge references a fact not in the base", + }, + { + origin: "declarativeLoad", + code: "invalid_routine_body", + severity: "warning", + message: "routine body failed validation", + }, + { + origin: "snapshot", + code: "unresolved_security_label", + severity: "warning", + message: "provider was not resolved", + }, + ], + false, + ); + + expect(out.messages.filter(({ type }) => type === "warn")).toHaveLength(1); + expect(out.messages).toContainEqual({ + type: "warn", + message: + "pg-delta does not manage these PostgreSQL object kinds: statistics object, text search configuration. Changes to these objects are omitted from the generated database diff.", + }); + expect(out.messages.some(({ message }) => message.includes("dangling_edge"))).toBe(false); + expect(out.messages.some(({ message }) => message.includes("invalid_routine_body"))).toBe( + false, + ); + expect( + out.messages.some(({ message }) => message.includes("unresolved_security_label")), + ).toBe(false); + expect(debugMessages).toHaveLength(5); + expect(debugMessages).toContain( + "pg-delta next diagnostic: origin=source code=dangling_edge subject=role:postgres message=edge references a fact not in the base", + ); + const invitations = out.messages.filter(({ message }) => + message.startsWith("Request pg-delta support:"), + ); + expect(invitations).toHaveLength(1); + expect(invitations[0]?.message).toContain("statistics object, text search configuration"); + }).pipe(Effect.provide(out.layer), Effect.provide(debugLayer(debugMessages))); + }); + + it("renders coverage diagnostics and then fails in strict mode", () => { + const out = mockOutput(); + const debugMessages: string[] = []; + return Effect.gen(function* () { + const exit = yield* legacyReportPgDeltaNextDiagnostics( + "declarativePlan", + [unmodeled("text search configuration")], + true, + ).pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(out.messages).toContainEqual({ + type: "warn", + message: + "pg-delta next diagnostic: origin=desired code=unmodeled_kind subject=object:public.unsupported message=object kind is not modeled", + }); + expect(out.messages).toContainEqual({ + type: "warn", + message: + "pg-delta does not manage these PostgreSQL object kinds: text search configuration. Strict coverage is enabled, so the operation will stop.", + }); + expect(debugMessages).toEqual([]); + expect(out.messages.some(({ message }) => message.includes("supabase issue feature"))).toBe( + true, + ); + }).pipe(Effect.provide(out.layer), Effect.provide(debugLayer(debugMessages))); + }); + + it("can suppress a repeated feedback invitation without suppressing warnings", () => { + const out = mockOutput(); + const debugMessages: string[] = []; + return Effect.gen(function* () { + yield* legacyReportPgDeltaNextDiagnostics( + "declarativePlan", + [unmodeled("text search configuration")], + false, + false, + ); + + expect(out.messages.some(({ message }) => message.includes("supabase issue feature"))).toBe( + false, + ); + expect(out.messages.some(({ type }) => type === "warn")).toBe(true); + }).pipe(Effect.provide(out.layer), Effect.provide(debugLayer(debugMessages))); + }); + + it("always renders and fails error diagnostics", () => { + const out = mockOutput(); + const debugMessages: string[] = []; + return Effect.gen(function* () { + const exit = yield* legacyReportPgDeltaNextDiagnostics( + "declarativeExport", + [ + { + origin: "export", + code: "extraction_failed", + severity: "error", + message: "catalog query failed", + }, + ], + false, + ).pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(out.messages).toContainEqual({ + type: "error", + message: + "pg-delta next diagnostic: origin=export code=extraction_failed message=catalog query failed", + }); + }).pipe(Effect.provide(out.layer), Effect.provide(debugLayer(debugMessages))); + }); + + it("renders every diagnostic with full detail when pg-delta debug is enabled", () => { + const out = mockOutput(); + const debugMessages: string[] = []; + return Effect.gen(function* () { + yield* legacyReportPgDeltaNextDiagnostics( + "diff", + [ + { + origin: "source", + code: "dangling_edge", + severity: "warning", + subject: "role:postgres", + message: "edge references a fact not in the base", + }, + { + origin: "declarativeLoad", + code: "invalid_routine_body", + severity: "info", + message: "routine body failed validation", + }, + ], + false, + true, + true, + ); + + expect(out.messages).toContainEqual({ + type: "warn", + message: + "pg-delta next diagnostic: origin=source code=dangling_edge subject=role:postgres message=edge references a fact not in the base", + }); + expect(out.messages).toContainEqual({ + type: "info", + message: + "pg-delta next diagnostic: origin=declarativeLoad code=invalid_routine_body message=routine body failed validation", + }); + expect(debugMessages).toEqual([]); + }).pipe(Effect.provide(out.layer), Effect.provide(debugLayer(debugMessages))); + }); + + it("classifies both coverage codes and aggregates arbitrary kinds safely", () => { + const report = legacyPgDeltaNextDiagnosticReport( + [ + unmodeled("z future kind"), + unmodeled("a future kind"), + unmodeled("a future kind"), + unmodeled("line\nbreak"), + unmodeled(undefined), + unmodeled(" "), + { + origin: "snapshot", + code: "unresolved_security_label", + severity: "info", + message: "provider was not resolved", + context: { kind: 42 }, + }, + ], + true, + ); + + expect(report.coverage).toHaveLength(7); + expect(report.blocking).toHaveLength(7); + expect(report.unmodeledKinds).toEqual(["a future kind", "line break", "z future kind"]); + }); + + it("omits an unknown subject and keeps feedback free of diagnostic details", () => { + expect( + legacyPgDeltaNextDiagnosticMessage({ + origin: "source", + code: "unmodeled_kind", + severity: "warning", + subject: "unknown", + message: "private diagnostic message", + context: { kind: "operator class" }, + }), + ).not.toContain("subject="); + + const invitation = legacyPgDeltaNextFeedbackInvitation(["operator class"]); + expect(invitation).toContain("operator class"); + expect(invitation).not.toContain("private diagnostic message"); + expect(invitation).not.toContain("subject"); + expect(invitation).not.toContain("public."); + }); + + it("shell-quotes future kind names without making feedback kind-specific", () => { + expect(legacyPgDeltaNextFeedbackInvitation(["user's future kind"])).toContain( + `user'"'"'s future kind`, + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.layer.ts new file mode 100644 index 0000000000..68868d1b97 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.layer.ts @@ -0,0 +1,250 @@ +import { Effect, FileSystem, Layer, Option, Path } from "effect"; +import * as Net from "node:net"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { + LegacyDebugFlag, + LegacyExperimentalFlag, + 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 { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; +import { LegacyDockerRun } from "../../../shared/legacy-docker-run.service.ts"; +import { legacyToPostgresURL } from "../../../shared/legacy-postgres-url.ts"; +import { + legacyBuildLocalDbContainerInputs, + type LegacyLocalDbContainerInputs, +} from "../../../shared/db-bootstrap/local-container-inputs.ts"; +import { legacyWaitForHealthyServices } from "../../../shared/db-bootstrap/health-check.ts"; +import { + legacyConnectShadowDatabase, + legacyCreateShadowDatabase, + legacyMigrateNextShadowDatabase, + legacyRemoveShadowDatabase, + legacySetupShadowDatabase, + type LegacyShadowDatabaseHandle, +} from "../../../shared/db-bootstrap/shadow-database.ts"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type { ChildProcessSpawner as ChildProcessSpawnerType } from "effect/unstable/process/ChildProcessSpawner"; +import * as HttpClient from "effect/unstable/http/HttpClient"; + +import { + LegacyPgDeltaNextShadow, + type LegacyPgDeltaNextMigrationsShadow, + type LegacyPgDeltaNextPlanShadows, + type LegacyPgDeltaNextShadowInput, +} from "./legacy-pgdelta-next-shadow.service.ts"; +import { legacyShadowRunInputFromLocalContainerInputs } from "./legacy-shadow-source.ts"; +import { LegacyDeclarativeShadowDbError } from "./legacy-pgdelta.errors.ts"; + +const allocateFreeHostPort = Effect.callback>((resume) => { + const server = Net.createServer(); + server.once("error", () => resume(Effect.succeed(Option.none()))); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address !== null ? address.port : 0; + server.close(() => resume(Effect.succeed(port > 0 ? Option.some(port) : Option.none()))); + }); +}); + +const nextShadowError = (cause: unknown) => + cause instanceof LegacyDeclarativeShadowDbError + ? cause + : new LegacyDeclarativeShadowDbError({ + message: + typeof cause === "object" && + cause !== null && + typeof Reflect.get(cause, "message") === "string" + ? String(Reflect.get(cause, "message")) + : String(cause), + ...(typeof cause === "object" && + cause !== null && + Reflect.get(cause, "reason") === "docker_daemon" + ? { docker: "daemon" as const } + : {}), + }); + +interface NativeShadowInput { + readonly spawner: ChildProcessSpawnerType["Service"]; + readonly localInputs: LegacyLocalDbContainerInputs; + readonly base: ReturnType; +} + +interface NativeShadowBase { + readonly localInputs: LegacyLocalDbContainerInputs; + readonly image: string; +} + +const setupRunInput = (input: NativeShadowInput, handle: LegacyShadowDatabaseHandle) => ({ + fs: input.base.fs, + path: input.base.path, + workdir: input.base.workdir, + projectId: input.base.projectId, + container: handle.containerId, + networkId: input.base.networkId, + connConfig: { + host: input.base.hostname, + port: input.base.shadowPort, + user: "postgres", + password: input.base.password, + database: "postgres", + }, + setup: input.base.setup, +}); + +/** + * Scoped, native TypeScript shadow orchestration for pg-delta next. The command + * workflows and this specialized two-shadow planner share the same bootstrap + * primitives; no Go command or shadow handoff protocol is involved. + */ +export const legacyPgDeltaNextShadowLayer = Layer.effect( + LegacyPgDeltaNextShadow, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtimeInfo = yield* RuntimeInfo; + const networkIdFlag = yield* LegacyNetworkIdFlag; + const debugFlag = yield* LegacyDebugFlag; + const experimentalFlag = yield* LegacyExperimentalFlag; + const cliArgs = yield* CliArgs; + const output = yield* Output; + const docker = yield* LegacyDockerRun; + const dbConnection = yield* LegacyDbConnection; + const httpClient = yield* HttpClient.HttpClient; + + const runtime = Layer.mergeAll( + Layer.succeed(FileSystem.FileSystem, fs), + Layer.succeed(Path.Path, path), + Layer.succeed(LegacyDebugFlag, debugFlag), + Layer.succeed(LegacyExperimentalFlag, experimentalFlag), + Layer.succeed(LegacyNetworkIdFlag, networkIdFlag), + Layer.succeed(CliArgs, cliArgs), + Layer.succeed(Output, output), + Layer.succeed(RuntimeInfo, runtimeInfo), + Layer.succeed(LegacyDockerRun, docker), + Layer.succeed(LegacyDbConnection, dbConnection), + Layer.succeed(HttpClient.HttpClient, httpClient), + ); + + const nextPort = (excluded?: number) => + Effect.gen(function* () { + for (let attempt = 0; attempt < 10; attempt++) { + const candidate = yield* allocateFreeHostPort; + if (Option.isSome(candidate) && candidate.value !== excluded) return candidate.value; + } + return yield* Effect.fail( + new LegacyDeclarativeShadowDbError({ + message: + excluded === undefined + ? "failed to allocate a host port for pg-delta shadow database" + : `failed to allocate a host port distinct from ${excluded}`, + }), + ); + }); + + const buildNativeBase = (request: LegacyPgDeltaNextShadowInput) => + Effect.gen(function* () { + const debug = yield* legacyResolveDebugWithProjectEnv(request.toml.projectEnv); + const localInputs = yield* legacyBuildLocalDbContainerInputs( + spawner, + request.context.cwd, + networkIdFlag, + runtimeInfo.platform, + debug, + request.projectRef, + request.toml.remoteOverrideKeys, + ); + const image = yield* localInputs.resolvePostgresImage; + return { localInputs, image } satisfies NativeShadowBase; + }).pipe(Effect.provide(runtime)); + + const buildNativeInput = ( + request: LegacyPgDeltaNextShadowInput, + built: NativeShadowBase, + port: number, + ): NativeShadowInput => ({ + spawner, + localInputs: built.localInputs, + base: legacyShadowRunInputFromLocalContainerInputs( + built.localInputs, + built.image, + { ...request.toml, shadowPort: port }, + fs, + path, + ), + }); + + const acquireShadow = (input: NativeShadowInput) => + Effect.acquireRelease(legacyCreateShadowDatabase(input.spawner, input.base), (handle) => + legacyRemoveShadowDatabase(input.spawner, handle.containerId).pipe( + Effect.provideService(Output, output), + ), + ); + + const provisionMigrations = (input: NativeShadowInput) => + Effect.gen(function* () { + const handle = yield* acquireShadow(input); + yield* legacyWaitForHealthyServices(input.spawner, [handle.containerId], { + timeoutSeconds: input.base.healthTimeoutSeconds, + }); + const setup = setupRunInput(input, handle); + yield* legacyMigrateNextShadowDatabase(input.spawner, setup); + return { + migrationsUrl: legacyToPostgresURL(setup.connConfig), + } satisfies LegacyPgDeltaNextMigrationsShadow; + }).pipe(Effect.provide(runtime), Effect.mapError(nextShadowError)); + + const provisionDeclarative = (input: NativeShadowInput) => + Effect.gen(function* () { + if (input.localInputs.setup.majorVersion !== 17) { + return yield* Effect.fail( + new LegacyDeclarativeShadowDbError({ + message: `pg-delta declarative shadow baseline requires Postgres 17 (got major ${input.localInputs.setup.majorVersion}, image ${JSON.stringify(input.base.image)})`, + }), + ); + } + const handle = yield* acquireShadow(input); + yield* legacyWaitForHealthyServices(input.spawner, [handle.containerId], { + timeoutSeconds: input.base.healthTimeoutSeconds, + }); + const setup = setupRunInput(input, handle); + yield* legacySetupShadowDatabase(input.spawner, setup, { + activateUserExtensions: false, + }); + yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* legacyConnectShadowDatabase(setup.connConfig); + yield* session.exec("DROP EXTENSION IF EXISTS pgcrypto"); + yield* session.exec('DROP EXTENSION IF EXISTS "uuid-ossp"'); + }), + ); + return legacyToPostgresURL(setup.connConfig); + }).pipe(Effect.provide(runtime), Effect.mapError(nextShadowError)); + + return LegacyPgDeltaNextShadow.of({ + provisionMigrations: (opts) => + Effect.gen(function* () { + const port = yield* nextPort(); + const built = yield* buildNativeBase(opts); + const input = buildNativeInput(opts, built, port); + return yield* provisionMigrations(input); + }).pipe(Effect.mapError(nextShadowError)), + provisionPlan: (opts) => + Effect.gen(function* () { + const migrationsPort = yield* nextPort(); + const declarativePort = yield* nextPort(migrationsPort); + const built = yield* buildNativeBase(opts); + const migrationsInput = buildNativeInput(opts, built, migrationsPort); + const declarativeInput = buildNativeInput(opts, built, declarativePort); + const migrations = yield* provisionMigrations(migrationsInput); + const declarativeUrl = yield* provisionDeclarative(declarativeInput); + return { + ...migrations, + declarativeUrl, + } satisfies LegacyPgDeltaNextPlanShadows; + }).pipe(Effect.mapError(nextShadowError)), + }); + }), +); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.service.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.service.ts new file mode 100644 index 0000000000..c71e08311d --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.service.ts @@ -0,0 +1,49 @@ +import { Context, type Effect, type Scope } from "effect"; + +import type { LegacyDeclarativeShadowDbError } from "./legacy-pgdelta.errors.ts"; +import type { LegacyDbTomlValues } from "../../../shared/legacy-db-config.toml-read.ts"; +import type { LegacyPgDeltaContext } from "../../../shared/legacy-pgdelta.ts"; + +/** The live migrated database needed by pg-delta next database diffs. */ +export interface LegacyPgDeltaNextMigrationsShadow { + /** Platform baseline with the project's local migrations applied. */ + readonly migrationsUrl: string; +} + +/** The two live databases needed to plan declarative SQL with pg-delta next. */ +export interface LegacyPgDeltaNextPlanShadows extends LegacyPgDeltaNextMigrationsShadow { + /** Independent platform baseline owned by `planSchemaFiles` while loading desired SQL. */ + readonly declarativeUrl: string; +} + +export interface LegacyPgDeltaNextShadowInput { + readonly context: LegacyPgDeltaContext; + readonly toml: LegacyDbTomlValues; + readonly projectRef?: string; +} + +interface LegacyPgDeltaNextShadowShape { + /** + * Provisions only the migrated next-engine shadow needed by database diffs. + * The container is removed when the current Effect scope closes. + */ + readonly provisionMigrations: ( + opts: LegacyPgDeltaNextShadowInput, + ) => Effect.Effect< + LegacyPgDeltaNextMigrationsShadow, + LegacyDeclarativeShadowDbError, + Scope.Scope + >; + /** + * Provisions the independent migrated and declarative shadows needed by a + * declarative plan. Both are removed when the current Effect scope closes. + */ + readonly provisionPlan: ( + opts: LegacyPgDeltaNextShadowInput, + ) => Effect.Effect; +} + +export class LegacyPgDeltaNextShadow extends Context.Service< + LegacyPgDeltaNextShadow, + LegacyPgDeltaNextShadowShape +>()("supabase/legacy/PgDeltaNextShadow") {} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next.live.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next.live.test.ts new file mode 100644 index 0000000000..7de8a4176c --- /dev/null +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next.live.test.ts @@ -0,0 +1,798 @@ +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + writeFileSync, +} from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, expect, test } from "vitest"; + +import { describeDockerLive, runSupabaseLive } from "../../../../../tests/helpers/live.ts"; + +const COMMAND_TIMEOUT_MS = 280_000; +const SCENARIO_TIMEOUT_MS = 900_000; +const NEXT_ENV = { + PGDELTA_DEBUG: "1", + SUPABASE_USE_PG_DELTA_NEXT: "true", +}; + +const initialDesiredSchema = `create type public.account_state as enum ('pending', 'active'); + +create table public.disposable_note ( + id bigint generated by default as identity primary key, + body text not null +); + +create view public.auth_user_emails as +select id, email +from auth.users; +`; + +const editedDesiredSchema = `create type public.account_state as enum ('pending', 'review', 'active'); + +create view public.auth_user_emails as +select id, email +from auth.users; + +create table public.review_queue ( + id bigint primary key, + state public.account_state not null default 'review' +); +`; + +function commandFailure(result: { stdout: string; stderr: string }): string { + return `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`; +} + +function migrationFiles(projectDir: string): ReadonlyArray { + const migrationsDir = path.join(projectDir, "supabase", "migrations"); + return existsSync(migrationsDir) + ? readdirSync(migrationsDir) + .filter((file) => file.endsWith(".sql")) + .sort() + : []; +} + +function debugBundleDirectories(projectDir: string): ReadonlyArray { + const debugDir = path.join(projectDir, "supabase", ".temp", "pgdelta", "v2", "debug"); + if (!existsSync(debugDir)) return []; + return readdirSync(debugDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(debugDir, entry.name)) + .sort(); +} + +function requireDebugBundle(projectDir: string, operation: "declarativePlan" | "diff"): string { + const bundle = debugBundleDirectories(projectDir) + .filter((dir) => path.basename(dir).endsWith(`-${operation}`)) + .at(-1); + expect(bundle, `missing ${operation} debug bundle`).toBeDefined(); + if (bundle === undefined) throw new Error(`missing ${operation} debug bundle`); + return bundle; +} + +function assertJsonFile(file: string): unknown { + expect(existsSync(file), `missing ${file}`).toBe(true); + return JSON.parse(readFileSync(file, "utf8")); +} + +function localDatabaseUrl(config: string): string { + const dbSection = config.match(/\[db\][\s\S]*?\nport\s*=\s*(\d+)/u); + expect(dbSection?.[1], "db.port missing from generated config.toml").toBeDefined(); + return `postgresql://postgres:postgres@127.0.0.1:${dbSection?.[1]}/postgres?sslmode=disable`; +} + +function projectContainerIds(config: string): ReadonlyArray { + const projectId = config.match(/^project_id\s*=\s*"([^"]+)"/mu)?.[1]; + expect(projectId, "project_id missing from generated config.toml").toBeDefined(); + if (projectId === undefined) throw new Error("project_id missing from generated config.toml"); + const output = execFileSync( + "docker", + ["ps", "-aq", "--filter", `label=com.supabase.cli.project=${projectId}`], + { encoding: "utf8" }, + ); + return output.split(/\r?\n/u).filter(Boolean).sort(); +} + +function findSqlContaining(root: string, needle: string): string { + const match = readdirSync(root, { recursive: true }) + .filter((entry): entry is string => typeof entry === "string" && entry.endsWith(".sql")) + .map((entry) => path.join(root, entry)) + .find((file) => readFileSync(file, "utf8").includes(needle)); + expect(match, `no SQL file under ${root} contains ${needle}`).toBeDefined(); + if (match === undefined) throw new Error(`no SQL file under ${root} contains ${needle}`); + return match; +} + +function findExtensionDeclaration(root: string, extension: string): string { + const escaped = extension.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const declaration = new RegExp( + `\\bCREATE\\s+EXTENSION(?:\\s+IF\\s+NOT\\s+EXISTS)?\\s+(?:"${escaped}"|${escaped})(?=\\s|;)`, + "iu", + ); + const match = readdirSync(root, { recursive: true }) + .filter((entry): entry is string => typeof entry === "string" && entry.endsWith(".sql")) + .map((entry) => path.join(root, entry)) + .find((file) => declaration.test(readFileSync(file, "utf8"))); + expect(match, `no SQL file under ${root} declares extension ${extension}`).toBeDefined(); + if (match === undefined) throw new Error(`no SQL file under ${root} declares ${extension}`); + return match; +} + +describeDockerLive("pg-delta next local convergence (live)", () => { + let projectDir = ""; + let desiredSchemaPath = ""; + let databaseUrl = ""; + + beforeAll(async () => { + projectDir = await mkdtemp(path.join(tmpdir(), "sb-pgdelta-next-live-")); + + const init = await runSupabaseLive(["init"], { + cwd: projectDir, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }); + expect(init.exitCode, commandFailure(init)).toBe(0); + + const configPath = path.join(projectDir, "supabase", "config.toml"); + const config = readFileSync(configPath, "utf8"); + expect(config).toContain("schema_paths = []"); + expect(config).toContain("[experimental.pgdelta]\nenabled = true"); + writeFileSync( + configPath, + config + .replace("schema_paths = []", 'schema_paths = ["./schemas/*.sql"]') + .replace( + '# declarative_schema_path = "./database"', + 'declarative_schema_path = "./schemas"', + ), + ); + databaseUrl = localDatabaseUrl(config); + + const schemasDir = path.join(projectDir, "supabase", "schemas"); + mkdirSync(schemasDir, { recursive: true }); + desiredSchemaPath = path.join(schemasDir, "public.sql"); + writeFileSync(desiredSchemaPath, initialDesiredSchema); + + const start = await runSupabaseLive( + [ + "start", + "--exclude", + "studio", + "--exclude", + "logflare", + "--exclude", + "vector", + "--exclude", + "gotrue", + "--exclude", + "realtime", + "--exclude", + "storage-api", + ], + { cwd: projectDir, exitTimeoutMs: COMMAND_TIMEOUT_MS }, + ); + expect(start.exitCode, commandFailure(start)).toBe(0); + }, COMMAND_TIMEOUT_MS); + + afterAll(async () => { + if (projectDir.length === 0) return; + await runSupabaseLive(["stop", "--no-backup"], { + cwd: projectDir, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }).catch(() => undefined); + await rm(projectDir, { recursive: true, force: true }).catch(() => undefined); + }, COMMAND_TIMEOUT_MS); + + test( + "converges declarative state across empty, destructive, enum, URL, and migrations refs", + { timeout: SCENARIO_TIMEOUT_MS }, + async () => { + expect(migrationFiles(projectDir)).toEqual([]); + + const initialDiff = await runSupabaseLive( + ["db", "diff", "--local", "--use-pg-delta", "-f", "initial_declarative"], + { cwd: projectDir, env: NEXT_ENV, exitTimeoutMs: COMMAND_TIMEOUT_MS }, + ); + expect(initialDiff.exitCode, commandFailure(initialDiff)).toBe(0); + + const initialMigrations = migrationFiles(projectDir); + expect(initialMigrations.length).toBeGreaterThan(0); + const initialSql = initialMigrations + .map((file) => readFileSync(path.join(projectDir, "supabase", "migrations", file), "utf8")) + .join("\n"); + expect(initialSql).toContain("account_state"); + expect(initialSql).toContain("disposable_note"); + expect(initialSql).toContain("auth_user_emails"); + expect(initialSql).toContain("auth.users"); + expect(initialSql).not.toMatch( + /CREATE\s+(?:SCHEMA|TABLE)\s+(?:IF\s+NOT\s+EXISTS\s+)?["']?(?:auth|storage|realtime)["']?/iu, + ); + + const declarativeBundle = requireDebugBundle(projectDir, "declarativePlan"); + expect(assertJsonFile(path.join(declarativeBundle, "metadata.json"))).toMatchObject({ + version: 1, + generation: "v2", + implementation: "next", + operation: "declarativePlan", + cacheReusable: false, + files: ["diagnostics.json", "plan.json"], + }); + assertJsonFile(path.join(declarativeBundle, "plan.json")); + expect(Array.isArray(assertJsonFile(path.join(declarativeBundle, "diagnostics.json")))).toBe( + true, + ); + + const firstReset = await runSupabaseLive(["db", "reset", "--local", "--no-seed"], { + cwd: projectDir, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }); + expect(firstReset.exitCode, commandFailure(firstReset)).toBe(0); + + const emptyAfterInitial = await runSupabaseLive(["db", "diff", "--local", "--use-pg-delta"], { + cwd: projectDir, + env: { SUPABASE_USE_PG_DELTA_NEXT: "true" }, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }); + expect(emptyAfterInitial.exitCode, commandFailure(emptyAfterInitial)).toBe(0); + expect(emptyAfterInitial.stderr).toContain("No schema changes found"); + + writeFileSync(desiredSchemaPath, editedDesiredSchema); + const beforeEdit = new Set(migrationFiles(projectDir)); + const editedDiff = await runSupabaseLive( + ["db", "diff", "--local", "--use-pg-delta", "-f", "enum_and_drop"], + { + cwd: projectDir, + env: { SUPABASE_USE_PG_DELTA_NEXT: "true" }, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + expect(editedDiff.exitCode, commandFailure(editedDiff)).toBe(0); + expect(editedDiff.stderr).toContain("Found drop statements in schema diff"); + + const editedMigrations = migrationFiles(projectDir).filter((file) => !beforeEdit.has(file)); + expect(editedMigrations.length).toBeGreaterThan(1); + const editedMigrationSql = editedMigrations.map((file) => + readFileSync(path.join(projectDir, "supabase", "migrations", file), "utf8"), + ); + const editedSql = editedMigrationSql.join("\n"); + expect(editedSql).toMatch(/ALTER\s+TYPE[\s\S]*account_state[\s\S]*ADD\s+VALUE/iu); + expect(editedSql).toMatch(/DROP\s+TABLE[\s\S]*disposable_note/iu); + expect(editedSql).toContain("review_queue"); + + const enumPush = await runSupabaseLive(["db", "push", "--local"], { + cwd: projectDir, + env: { SUPABASE_YES: "true" }, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }); + expect(enumPush.exitCode, commandFailure(enumPush)).toBe(0); + + const emptyAfterEdit = await runSupabaseLive(["db", "diff", "--local", "--use-pg-delta"], { + cwd: projectDir, + env: { SUPABASE_USE_PG_DELTA_NEXT: "true" }, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }); + expect(emptyAfterEdit.exitCode, commandFailure(emptyAfterEdit)).toBe(0); + expect(emptyAfterEdit.stderr).toContain("No schema changes found"); + + const explicit = await runSupabaseLive( + ["db", "diff", "--from", "migrations", "--to", databaseUrl], + { cwd: projectDir, env: NEXT_ENV, exitTimeoutMs: COMMAND_TIMEOUT_MS }, + ); + expect(explicit.exitCode, commandFailure(explicit)).toBe(0); + expect(explicit.stdout.trim()).toBe(""); + + const diffBundle = requireDebugBundle(projectDir, "diff"); + expect(assertJsonFile(path.join(diffBundle, "metadata.json"))).toMatchObject({ + version: 1, + generation: "v2", + implementation: "next", + operation: "diff", + cacheReusable: false, + files: ["desired-snapshot.json", "diagnostics.json", "plan.json", "source-snapshot.json"], + }); + const sourceSnapshot = readFileSync(path.join(diffBundle, "source-snapshot.json"), "utf8"); + const desiredSnapshot = readFileSync(path.join(diffBundle, "desired-snapshot.json"), "utf8"); + expect(sourceSnapshot).toContain("account_state"); + expect(desiredSnapshot).toContain("account_state"); + JSON.parse(sourceSnapshot); + JSON.parse(desiredSnapshot); + assertJsonFile(path.join(diffBundle, "plan.json")); + expect(Array.isArray(assertJsonFile(path.join(diffBundle, "diagnostics.json")))).toBe(true); + + const generated = await runSupabaseLive( + ["db", "schema", "declarative", "generate", "--local", "--overwrite"], + { + cwd: projectDir, + env: { SUPABASE_USE_PG_DELTA_NEXT: "true" }, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + expect(generated.exitCode, commandFailure(generated)).toBe(0); + + const exportedFiles = readdirSync(path.join(projectDir, "supabase", "schemas"), { + recursive: true, + }) + .filter((entry): entry is string => typeof entry === "string" && entry.endsWith(".sql")) + .map((entry) => path.join(projectDir, "supabase", "schemas", entry)) + .sort(); + expect(exportedFiles.length).toBeGreaterThan(0); + expect(existsSync(path.join(projectDir, "supabase", "schemas", ".pgdelta-export.json"))).toBe( + true, + ); + + const migrationsBeforeGeneratedSync = migrationFiles(projectDir); + const emptyGeneratedSync = await runSupabaseLive( + ["db", "schema", "declarative", "sync", "--no-apply"], + { + cwd: projectDir, + env: { SUPABASE_USE_PG_DELTA_NEXT: "true" }, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + expect(emptyGeneratedSync.exitCode, commandFailure(emptyGeneratedSync)).toBe(0); + expect(emptyGeneratedSync.stderr).toContain("No schema changes found"); + expect(migrationFiles(projectDir)).toEqual(migrationsBeforeGeneratedSync); + + const editedExport = exportedFiles[0]; + expect(editedExport).toBeDefined(); + if (editedExport === undefined) throw new Error("declarative export produced no SQL files"); + writeFileSync( + editedExport, + `${readFileSync(editedExport, "utf8")}\ncreate table public.phase6_synced (id bigint primary key);\n`, + ); + + const migrationsBeforeApplySync = new Set(migrationFiles(projectDir)); + const appliedSync = await runSupabaseLive( + ["db", "schema", "declarative", "sync", "--apply", "--name", "phase6_sync"], + { + cwd: projectDir, + env: { SUPABASE_USE_PG_DELTA_NEXT: "true" }, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + expect(appliedSync.exitCode, commandFailure(appliedSync)).toBe(0); + expect(appliedSync.stderr).toContain("Migration applied successfully"); + const appliedSyncMigrations = migrationFiles(projectDir).filter( + (file) => !migrationsBeforeApplySync.has(file), + ); + expect(appliedSyncMigrations.length).toBeGreaterThan(0); + expect( + appliedSyncMigrations + .map((file) => + readFileSync(path.join(projectDir, "supabase", "migrations", file), "utf8"), + ) + .join("\n"), + ).toContain("phase6_synced"); + + const emptyAppliedSync = await runSupabaseLive( + ["db", "schema", "declarative", "sync", "--no-apply"], + { + cwd: projectDir, + env: { SUPABASE_USE_PG_DELTA_NEXT: "true" }, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + expect(emptyAppliedSync.exitCode, commandFailure(emptyAppliedSync)).toBe(0); + expect(emptyAppliedSync.stderr).toContain("No schema changes found"); + + const dbOnlyChange = await runSupabaseLive( + ["db", "query", "--local", "create table public.phase6_pulled (id bigint primary key)"], + { cwd: projectDir, exitTimeoutMs: COMMAND_TIMEOUT_MS }, + ); + expect(dbOnlyChange.exitCode, commandFailure(dbOnlyChange)).toBe(0); + + const configPath = path.join(projectDir, "supabase", "config.toml"); + const pullConfig = readFileSync(configPath, "utf8") + .replace('schema_paths = ["./schemas/*.sql"]', "schema_paths = []") + .replace('declarative_schema_path = "./schemas"', 'declarative_schema_path = "./database"'); + writeFileSync(configPath, pullConfig); + renameSync( + path.join(projectDir, "supabase", "schemas"), + path.join(projectDir, "supabase", ".phase6-exported-schemas"), + ); + + const migrationsBeforePull = new Set(migrationFiles(projectDir)); + const pulled = await runSupabaseLive( + ["db", "pull", "phase6_pull", "--db-url", databaseUrl, "--diff-engine", "pg-delta"], + { + cwd: projectDir, + env: { SUPABASE_USE_PG_DELTA_NEXT: "true", SUPABASE_YES: "true" }, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + expect(pulled.exitCode, commandFailure(pulled)).toBe(0); + const pulledMigrations = migrationFiles(projectDir).filter( + (file) => !migrationsBeforePull.has(file), + ); + expect(pulledMigrations.length).toBeGreaterThan(0); + expect( + pulledMigrations + .map((file) => + readFileSync(path.join(projectDir, "supabase", "migrations", file), "utf8"), + ) + .join("\n"), + ).toContain("phase6_pulled"); + + const removePulledTable = await runSupabaseLive( + ["db", "query", "--local", "drop table public.phase6_pulled"], + { + cwd: projectDir, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + expect(removePulledTable.exitCode, commandFailure(removePulledTable)).toBe(0); + + const pulledVersion = pulledMigrations[0]?.split("_", 1)[0]; + expect(pulledVersion).toMatch(/^\d{14}$/u); + if (pulledVersion === undefined) throw new Error("db pull produced no migration version"); + const markPulledReverted = await runSupabaseLive( + ["migration", "repair", "--local", "--status", "reverted", pulledVersion], + { + cwd: projectDir, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + expect(markPulledReverted.exitCode, commandFailure(markPulledReverted)).toBe(0); + + const pullPush = await runSupabaseLive(["db", "push", "--local"], { + cwd: projectDir, + env: { SUPABASE_YES: "true" }, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }); + expect(pullPush.exitCode, commandFailure(pullPush)).toBe(0); + + const emptyPull = await runSupabaseLive( + ["db", "pull", "phase6_pull_empty", "--db-url", databaseUrl, "--diff-engine", "pg-delta"], + { + cwd: projectDir, + env: { SUPABASE_USE_PG_DELTA_NEXT: "true", SUPABASE_YES: "true" }, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + expect(emptyPull.exitCode, commandFailure(emptyPull)).toBe(1); + expect(emptyPull.stderr).toContain("No schema changes found"); + }, + ); + + test( + "keeps the legacy edge-runtime implementation available behind the opt-out", + { timeout: SCENARIO_TIMEOUT_MS }, + async (context) => { + const legacy = await runSupabaseLive( + ["db", "diff", "--from", "migrations", "--to", databaseUrl], + { + cwd: projectDir, + env: { SUPABASE_USE_PG_DELTA_NEXT: "false" }, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + const output = `${legacy.stdout}\n${legacy.stderr}`; + if ( + legacy.exitCode !== 0 && + /(?:No such image|manifest unknown|pull access denied|edge-runtime: (?:not found|command not found))/iu.test( + output, + ) + ) { + context.skip("legacy edge-runtime image is concretely unavailable on this Docker host"); + } + expect(legacy.exitCode, commandFailure(legacy)).toBe(0); + }, + ); +}); + +describeDockerLive("pg-delta next declarative extension baseline (live)", () => { + let projectDir = ""; + let config = ""; + + beforeAll(async () => { + projectDir = await mkdtemp(path.join(tmpdir(), "sb-pgdelta-next-extensions-live-")); + + const init = await runSupabaseLive(["init"], { + cwd: projectDir, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }); + expect(init.exitCode, commandFailure(init)).toBe(0); + + const configPath = path.join(projectDir, "supabase", "config.toml"); + const generatedConfig = readFileSync(configPath, "utf8"); + expect(generatedConfig).toContain("major_version = 17"); + expect(generatedConfig).not.toContain("[experimental.webhooks]"); + config = `${generatedConfig + .replace("schema_paths = []", 'schema_paths = ["./schemas/*.sql"]') + .replace( + '# declarative_schema_path = "./database"', + 'declarative_schema_path = "./schemas"', + )}\n[experimental.webhooks]\nenabled = true\n`; + writeFileSync(configPath, config); + + const start = await runSupabaseLive( + [ + "start", + "--exclude", + "studio", + "--exclude", + "logflare", + "--exclude", + "vector", + "--exclude", + "gotrue", + "--exclude", + "realtime", + "--exclude", + "storage-api", + ], + { cwd: projectDir, exitTimeoutMs: COMMAND_TIMEOUT_MS }, + ); + expect(start.exitCode, commandFailure(start)).toBe(0); + }, COMMAND_TIMEOUT_MS); + + afterAll(async () => { + if (projectDir.length === 0) return; + await runSupabaseLive(["stop", "--no-backup"], { + cwd: projectDir, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }).catch(() => undefined); + await rm(projectDir, { recursive: true, force: true }).catch(() => undefined); + }, COMMAND_TIMEOUT_MS); + + test( + "loads exported user-managed extensions and plans their removal by file deletion", + { timeout: SCENARIO_TIMEOUT_MS }, + async () => { + const generated = await runSupabaseLive( + ["db", "schema", "declarative", "generate", "--local", "--overwrite"], + { + cwd: projectDir, + env: NEXT_ENV, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + expect(generated.exitCode, commandFailure(generated)).toBe(0); + + const schemasDir = path.join(projectDir, "supabase", "schemas"); + findExtensionDeclaration(schemasDir, "pg_net"); + const pgcryptoFile = findExtensionDeclaration(schemasDir, "pgcrypto"); + findExtensionDeclaration(schemasDir, "uuid-ossp"); + // The directory itself is the complete desired-state contract. A missing + // manifest must not preserve an extension omitted from the SQL files. + await rm(path.join(schemasDir, ".pgdelta-export.json")); + + const containersBeforeEmpty = projectContainerIds(config); + const migrationsBeforeEmpty = migrationFiles(projectDir); + const empty = await runSupabaseLive(["db", "schema", "declarative", "sync", "--no-apply"], { + cwd: projectDir, + env: NEXT_ENV, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }); + expect(empty.exitCode, commandFailure(empty)).toBe(0); + expect(empty.stderr).toContain("No schema changes found"); + expect(migrationFiles(projectDir)).toEqual(migrationsBeforeEmpty); + expect(projectContainerIds(config)).toEqual(containersBeforeEmpty); + + const pgcryptoSql = readFileSync(pgcryptoFile, "utf8"); + const migrationsBeforeRemoval = new Set(migrationFiles(projectDir)); + await rm(pgcryptoFile); + try { + const containersBeforeRemoval = projectContainerIds(config); + const removal = await runSupabaseLive( + ["db", "schema", "declarative", "sync", "--no-apply", "--name", "drop_pgcrypto"], + { + cwd: projectDir, + env: NEXT_ENV, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + expect(removal.exitCode, commandFailure(removal)).toBe(0); + expect(projectContainerIds(config)).toEqual(containersBeforeRemoval); + + const removalMigrations = migrationFiles(projectDir).filter( + (file) => !migrationsBeforeRemoval.has(file), + ); + expect(removalMigrations.length).toBeGreaterThan(0); + const removalSql = removalMigrations + .map((file) => + readFileSync(path.join(projectDir, "supabase", "migrations", file), "utf8"), + ) + .join("\n"); + expect(removalSql).toMatch(/DROP\s+EXTENSION(?:\s+IF\s+EXISTS)?\s+"?pgcrypto"?/iu); + } finally { + writeFileSync(pgcryptoFile, pgcryptoSql); + await Promise.all( + migrationFiles(projectDir) + .filter((file) => !migrationsBeforeRemoval.has(file)) + .map((file) => rm(path.join(projectDir, "supabase", "migrations", file))), + ); + } + }, + ); +}); + +describeDockerLive("pg-delta next isolated cron shadows (live)", () => { + const jobName = "pgdelta_cli_inactive"; + const initialSchedule = "0 0 * * *"; + const changedSchedule = "15 3 * * *"; + let projectDir = ""; + let config = ""; + + beforeAll(async () => { + projectDir = await mkdtemp(path.join(tmpdir(), "sb-pgdelta-next-cron-live-")); + + const init = await runSupabaseLive(["init"], { + cwd: projectDir, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }); + expect(init.exitCode, commandFailure(init)).toBe(0); + + const configPath = path.join(projectDir, "supabase", "config.toml"); + config = readFileSync(configPath, "utf8") + .replace("schema_paths = []", 'schema_paths = ["./schemas/*.sql"]') + .replace('# declarative_schema_path = "./database"', 'declarative_schema_path = "./schemas"'); + writeFileSync(configPath, config); + + const migrationsDir = path.join(projectDir, "supabase", "migrations"); + mkdirSync(migrationsDir, { recursive: true }); + writeFileSync( + path.join(migrationsDir, "20260806000000_cron_inactive.sql"), + `create extension if not exists pg_cron; + +create table public.pgdelta_cron_execution_sentinel ( + executed_at timestamptz not null default now() +); + +select cron.schedule( + '${jobName}', + '${initialSchedule}', + 'insert into public.pgdelta_cron_execution_sentinel default values' +); + +select cron.alter_job( + (select jobid from cron.job where jobname = '${jobName}'), + active := false +); +`, + ); + + const start = await runSupabaseLive( + [ + "start", + "--exclude", + "studio", + "--exclude", + "logflare", + "--exclude", + "vector", + "--exclude", + "gotrue", + "--exclude", + "realtime", + "--exclude", + "storage-api", + ], + { cwd: projectDir, exitTimeoutMs: COMMAND_TIMEOUT_MS }, + ); + expect(start.exitCode, commandFailure(start)).toBe(0); + }, COMMAND_TIMEOUT_MS); + + afterAll(async () => { + if (projectDir.length === 0) return; + await runSupabaseLive(["stop", "--no-backup"], { + cwd: projectDir, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }).catch(() => undefined); + await rm(projectDir, { recursive: true, force: true }).catch(() => undefined); + }, COMMAND_TIMEOUT_MS); + + test( + "keeps an inactive named job converged and replaces only its changed schedule", + { timeout: SCENARIO_TIMEOUT_MS }, + async () => { + const generated = await runSupabaseLive( + ["db", "schema", "declarative", "generate", "--local", "--overwrite"], + { + cwd: projectDir, + env: NEXT_ENV, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + expect(generated.exitCode, commandFailure(generated)).toBe(0); + + const schemasDir = path.join(projectDir, "supabase", "schemas"); + const cronFile = findSqlContaining(schemasDir, `cron.schedule_in_database('${jobName}'`); + const containersBeforeEmpty = projectContainerIds(config); + const empty = await runSupabaseLive(["db", "schema", "declarative", "sync", "--no-apply"], { + cwd: projectDir, + env: NEXT_ENV, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }); + expect(empty.exitCode, commandFailure(empty)).toBe(0); + expect(empty.stderr).toContain("No schema changes found"); + expect(projectContainerIds(config)).toEqual(containersBeforeEmpty); + + const emptyBundle = requireDebugBundle(projectDir, "declarativePlan"); + expect(assertJsonFile(path.join(emptyBundle, "plan.json"))).toMatchObject({ + deltas: [], + actions: [], + source: { fingerprint: expect.any(String) }, + target: { fingerprint: expect.any(String) }, + }); + + const exportedCron = readFileSync(cronFile, "utf8"); + expect(exportedCron).toContain(`'${initialSchedule}'`); + writeFileSync(cronFile, exportedCron.replace(`'${initialSchedule}'`, `'${changedSchedule}'`)); + + const migrationsBeforeApply = new Set(migrationFiles(projectDir)); + const containersBeforeApply = projectContainerIds(config); + const applied = await runSupabaseLive( + ["db", "schema", "declarative", "sync", "--apply", "--name", "cron_schedule"], + { + cwd: projectDir, + env: NEXT_ENV, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + expect(applied.exitCode, commandFailure(applied)).toBe(0); + expect(applied.stderr).toContain("Migration applied successfully"); + expect(projectContainerIds(config)).toEqual(containersBeforeApply); + + const scheduleMigrations = migrationFiles(projectDir).filter( + (file) => !migrationsBeforeApply.has(file), + ); + expect(scheduleMigrations.length).toBeGreaterThan(0); + const scheduleSql = scheduleMigrations + .map((file) => readFileSync(path.join(projectDir, "supabase", "migrations", file), "utf8")) + .join("\n"); + expect(scheduleSql.match(/cron\.unschedule/gu)).toHaveLength(1); + expect(scheduleSql.match(/cron\.schedule_in_database/gu)).toHaveLength(1); + expect(scheduleSql).toContain(`'${changedSchedule}'`); + expect(scheduleSql).not.toMatch( + /\b(?:create|alter|drop)\s+(?:table|schema|function|view|extension|role)\b/iu, + ); + + const job = await runSupabaseLive( + [ + "db", + "query", + "--local", + "-o", + "json", + `select schedule, active from cron.job where jobname = '${jobName}'`, + ], + { cwd: projectDir, exitTimeoutMs: COMMAND_TIMEOUT_MS }, + ); + expect(job.exitCode, commandFailure(job)).toBe(0); + expect(JSON.parse(job.stdout)).toEqual([{ schedule: changedSchedule, active: false }]); + + const executions = await runSupabaseLive( + [ + "db", + "query", + "--local", + "-o", + "json", + "select count(*)::int as executions from public.pgdelta_cron_execution_sentinel", + ], + { cwd: projectDir, exitTimeoutMs: COMMAND_TIMEOUT_MS }, + ); + expect(executions.exitCode, commandFailure(executions)).toBe(0); + expect(JSON.parse(executions.stdout)).toEqual([{ executions: 0 }]); + + const containersBeforeFinal = projectContainerIds(config); + const finalSync = await runSupabaseLive( + ["db", "schema", "declarative", "sync", "--no-apply"], + { + cwd: projectDir, + env: NEXT_ENV, + exitTimeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + expect(finalSync.exitCode, commandFailure(finalSync)).toBe(0); + expect(finalSync.stderr).toContain("No schema changes found"); + expect(projectContainerIds(config)).toEqual(containersBeforeFinal); + }, + ); +}); 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 20a19da966..fdf51e0ef8 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 @@ -3,7 +3,7 @@ import * as ChildProcess from "effect/unstable/process/ChildProcess"; import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; import { LegacyNetworkIdFlag, LegacyProfileFlag } from "../../../../shared/legacy/global-flags.ts"; -import { resolveBinary } from "../../../../shared/legacy/go-proxy.layer.ts"; +import { type BinaryResolution, resolveBinary } from "../../../../shared/legacy/go-proxy.layer.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { spawnContainerCli } from "../../../shared/legacy-container-cli.ts"; import { legacyResolveDbImage } from "../../../shared/legacy-db-image.ts"; @@ -33,8 +33,7 @@ const legacyShadowDockerCause = ( * doc comment in `legacy-pgdelta.seam.service.ts` for why those two modes still * need this hidden Go command while `"migrations"` no longer does). */ -export const legacyDeclarativeSeamLayer = Layer.effect( - LegacyDeclarativeSeam, +const makeLegacyDeclarativeSeam = (resolved: BinaryResolution) => Effect.gen(function* () { const cliConfig = yield* LegacyCliConfig; const networkId = yield* LegacyNetworkIdFlag; @@ -49,8 +48,6 @@ export const legacyDeclarativeSeamLayer = Layer.effect( const spawner = yield* ChildProcessSpawner; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const resolved = resolveBinary(); - return LegacyDeclarativeSeam.of({ exportCatalog: ({ mode, noCache, projectRef }) => Effect.scoped( @@ -385,8 +382,15 @@ export const legacyDeclarativeSeamLayer = Layer.effect( }), ), }); - }), -); + }); + +export function makeLegacyDeclarativeSeamLayer(options: { readonly binary?: string } = {}) { + const resolved: BinaryResolution = + options.binary === undefined ? resolveBinary() : { found: options.binary }; + return Layer.effect(LegacyDeclarativeSeam, makeLegacyDeclarativeSeam(resolved)); +} + +export const legacyDeclarativeSeamLayer = makeLegacyDeclarativeSeamLayer(); // Intentionally NOT `LegacyGoChildExitError` (contrast the now-removed `db __db-bootstrap` // seam, fixed under CLI-1879): this seam's failure is a TS-authored domain summary over noisy diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.ts index 2503e5ccc2..e184be77be 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.ts @@ -1,8 +1,16 @@ import { Effect, type FileSystem, type Path } from "effect"; import { legacyBold } from "../../../shared/legacy-colors.ts"; -import { LegacyDeclarativeWriteError } from "./legacy-pgdelta.errors.ts"; import type { LegacyDeclarativeOutput } from "../../../shared/legacy-pgdelta.ts"; +import { LegacyDeclarativeWriteError } from "./legacy-pgdelta.errors.ts"; +import type { + LegacyPgDeltaDeclarativeExportResult, + LegacyPgDeltaExportManifest, +} from "./legacy-pgdelta-engine.service.ts"; + +const EXPORT_MANIFEST_FILE = ".pgdelta-export.json"; + +type LegacyDeclarativeWriteOutput = LegacyDeclarativeOutput | LegacyPgDeltaDeclarativeExportResult; /** * Go's `declarative.Generate` / `pull.go`'s written-to line, printed by all three @@ -31,7 +39,7 @@ export const legacyWriteDeclarativeSchemas = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, path: Path.Path, declarativeDir: string, - output: LegacyDeclarativeOutput, + output: LegacyDeclarativeWriteOutput, ) { yield* fs.remove(declarativeDir, { recursive: true }).pipe( Effect.catchTag("PlatformError", (error) => @@ -47,18 +55,37 @@ export const legacyWriteDeclarativeSchemas = Effect.fnUntraced(function* ( ); yield* fs.makeDirectory(declarativeDir, { recursive: true }); + const writtenFiles: Array = []; for (const file of output.files) { - const rel = path.normalize(file.path); + const name = "name" in file ? file.name : file.path; + const rel = path.normalize(name); if (rel.startsWith("..") || path.isAbsolute(rel)) { return yield* Effect.fail( new LegacyDeclarativeWriteError({ - message: `unsafe declarative export path: ${file.path}`, + message: `unsafe declarative export path: ${name}`, }), ); } const targetPath = path.join(declarativeDir, rel); yield* fs.makeDirectory(path.dirname(targetPath), { recursive: true }); yield* fs.writeFileString(targetPath, file.sql); + writtenFiles.push(name.split("\\").join("/")); + } + + const manifest = "manifest" in output ? output.manifest : undefined; + if (manifest !== undefined) { + const serialized: LegacyPgDeltaExportManifest & { + readonly formatVersion: 1; + readonly files: ReadonlyArray; + } = { + formatVersion: 1, + ...manifest, + files: [...writtenFiles].sort(), + }; + yield* fs.writeFileString( + path.join(declarativeDir, EXPORT_MANIFEST_FILE), + `${JSON.stringify(serialized, null, 2)}\n`, + ); } }); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.unit.test.ts index 3e619d6322..93c9ff5ec5 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.unit.test.ts @@ -7,14 +7,18 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Path } from "effect"; import { legacyBold } from "../../../shared/legacy-colors.ts"; -import { LegacyDeclarativeWriteError } from "./legacy-pgdelta.errors.ts"; import type { LegacyDeclarativeOutput } from "../../../shared/legacy-pgdelta.ts"; +import { LegacyDeclarativeWriteError } from "./legacy-pgdelta.errors.ts"; +import type { LegacyPgDeltaDeclarativeExportResult } from "./legacy-pgdelta-engine.service.ts"; import { legacyDeclarativeSchemaWrittenLine, legacyWriteDeclarativeSchemas, } from "./legacy-pgdelta.write.ts"; -const write = (declarativeDir: string, output: LegacyDeclarativeOutput) => +const write = ( + declarativeDir: string, + output: LegacyDeclarativeOutput | LegacyPgDeltaDeclarativeExportResult, +) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -41,6 +45,32 @@ describe("legacyWriteDeclarativeSchemas", () => { expect(existsSync(join(declDir, "stale.sql"))).toBe(false); expect(readFileSync(join(declDir, "public.sql"), "utf8")).toBe("create table a();"); expect(readFileSync(join(declDir, "auth", "roles.sql"), "utf8")).toBe("create role app;"); + expect(existsSync(join(declDir, ".pgdelta-export.json"))).toBe(false); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("writes the next export manifest with the generated file list", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-decl-write-")); + const declDir = join(dir, "supabase", "database"); + return write(declDir, { + files: [ + { name: "schemas/z.sql", sql: "select 'z';" }, + { name: "schemas/a.sql", sql: "select 'a';" }, + ], + manifest: { redactSecrets: true, scope: "database", profile: "supabase" }, + }).pipe( + Effect.tap(() => + Effect.sync(() => { + expect(JSON.parse(readFileSync(join(declDir, ".pgdelta-export.json"), "utf8"))).toEqual({ + formatVersion: 1, + redactSecrets: true, + scope: "database", + profile: "supabase", + files: ["schemas/a.sql", "schemas/z.sql"], + }); rmSync(dir, { recursive: true, force: true }); }), ), diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.ts b/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.ts index 957b8793ba..19bd81c47a 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 @@ -51,6 +51,7 @@ import type { LegacyLocalDbContainerInputs } from "../../../shared/db-bootstrap/ import type { LegacyVaultSecret } from "../../../shared/legacy-vault.ts"; import { legacyMigrateShadowDatabase, + legacyMigrateNextShadowDatabase, LegacyShadowDbError, type LegacyShadowConnectionInput, type LegacyShadowDatabaseHandle, @@ -174,6 +175,8 @@ export interface LegacyPrepareShadowSourceInput extends LegacyShadowConnectio readonly targetLocal: boolean; /** Selects the declarative-apply engine for the local-declared branch, matching `DiffDatabase`. */ readonly usePgDelta: boolean; + /** Selects the historical shadow baseline or pg-delta next's config-gated baseline. */ + 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; @@ -252,7 +255,11 @@ export const legacyPrepareShadowSource = ( password: input.password, database: "postgres", }; - yield* legacyMigrateShadowDatabase(spawner, { + const migrateShadow = + input.migrationMode === "pgdelta-next" + ? legacyMigrateNextShadowDatabase + : legacyMigrateShadowDatabase; + yield* migrateShadow(spawner, { fs: input.fs, path: input.path, workdir: input.workdir, 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 b84f27bcf7..caccfcc8e2 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 @@ -265,6 +265,7 @@ interface SetupOpts { readonly running?: boolean; readonly runningFails?: boolean; readonly configContents?: string; + readonly projectEnvContents?: string; readonly skipConfig?: boolean; readonly workdir?: string; readonly cwd?: string; @@ -288,6 +289,9 @@ function setup(opts: SetupOpts = {}) { const workdir = opts.workdir ?? tempRoot.current; if (opts.skipConfig !== true) { writeConfig(workdir, opts.configContents ?? 'project_id = "test"\n'); + if (opts.projectEnvContents !== undefined) { + writeFileSync(join(workdir, "supabase", ".env"), opts.projectEnvContents); + } } const out = mockOutput({ format: opts.format ?? "text" }); const telemetry = mockLegacyTelemetryStateTracked(); @@ -524,10 +528,11 @@ describe("legacy db start", () => { ); it.live( - "caches the migrations catalog after a fresh-volume setup when pg-delta is enabled", + "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"}', }); @@ -549,10 +554,11 @@ describe("legacy db start", () => { ); it.live( - "warns without failing db start when the migrations-catalog export fails on a fresh volume", + "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", }); 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 8073b711cb..2cace5b741 100644 --- a/apps/cli/src/legacy/commands/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/start/start.integration.test.ts @@ -2389,13 +2389,14 @@ content_path = "./templates/custom_notice.html" ); it.live( - "caches the migrations catalog after a fresh-volume setup when pg-delta is enabled", + "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"); @@ -2416,11 +2417,12 @@ content_path = "./templates/custom_notice.html" it.live( "warns without failing supabase start when the migrations-catalog export fails on a fresh volume", () => { - const { layer, out } = setup({ + 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); 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 dd09d0ab44..15dcb8eee3 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts @@ -31,14 +31,18 @@ * `STORAGE_S3_REGION`, no JWKS) — built locally, not reused. * - `initAuthJob` (`start.go:319-332`) — ditto, a minimal env distinct from * `gotrue.service.ts`'s full container builder. - * 2. **`ApplyApiPrivileges`** (`start.go:414-435`) — tri-state on + * 2. **Database Webhooks activation** — installs `pg_net` when the user opted + * into `experimental.webhooks`, unless the setup caller disables user extension + * activation. Legacy callers may explicitly request the historical `pg_net` + * baseline independently of that user config. + * 3. **`ApplyApiPrivileges`** (`start.go:414-435`) — tri-state on * `api.auto_expose_new_tables`: `true` is a no-op (keep the bundled initial-schema * grants); unset/`false` execs {@link LEGACY_START_REVOKE_API_PRIVILEGES_SQL} * (Go's inline `RevokeDefaultDataApiPrivilegesSql` constant, `start.go:405-412`) * via a temp file, same as the schema SQL above. - * 3. **Vault upsert** (`start.go:390-393`) — `legacyUpsertVaultSecrets`, run BEFORE + * 4. **Vault upsert** (`start.go:390-393`) — `legacyUpsertVaultSecrets`, run BEFORE * the custom-roles seed "so roles.sql can reference them" (Go's own comment). - * 4. **Custom-roles seed** (`start.go:394-398` + `pkg/migration/seed.go:84-97`) — + * 5. **Custom-roles seed** (`start.go:394-398` + `pkg/migration/seed.go:84-97`) — * prints "Seeding globals from roles.sql..." UNCONDITIONALLY, BEFORE checking * whether `supabase/roles.sql` even exists (Go's `SeedGlobals` prints first, * then attempts the read), then execs the file via `legacyExecSqlFile` only when @@ -46,7 +50,7 @@ * os.ErrNotExist)` check, reproduced here as an existence check ahead of the read * rather than a caught not-found error — see the call site's own comment for why); * any other read/exec error propagates. - * 5. **`apply.MigrateAndSeed`** (`start.go:368`, via the already-ported + * 6. **`apply.MigrateAndSeed`** (`start.go:368`, via the already-ported * `legacyMigrateAndSeed`) with the caller-supplied {@link * LegacyStartSetupLocalDatabaseInput.version} — `""` (every pending migration) for * `db start`'s own call, matching `SetupLocalDatabase`'s call in the `start` @@ -56,7 +60,7 @@ * `--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}. - * 6. **`pgcache.TryCacheMigrationsCatalog`** (`start.go:371-379`) — a best-effort + * 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 @@ -138,6 +142,7 @@ import { LegacyEdgeRuntimeScript } from "../legacy-edge-runtime-script.service.t import { legacyMigrateAndSeed } from "../legacy-migrate-and-seed.ts"; import { LegacyMigrationApplyError, legacyExecSqlFile } from "../legacy-migration-apply.ts"; import { legacyTryCacheMigrationsCatalog } from "../legacy-pgdelta.cache.ts"; +import { 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"; @@ -175,6 +180,9 @@ alter default privileges for role postgres in schema public revoke execute on functions from anon, authenticated, service_role; `; +const LEGACY_START_ENABLE_DATABASE_WEBHOOKS_SQL = + "create extension if not exists pg_net schema extensions;"; + /** * A SQL exec (schema/globals/API-privileges) or one-shot service-migration Docker * job failed, or the scratch temp directory/file could not be created. The Docker @@ -440,6 +448,14 @@ export interface LegacySetupDatabaseInput { readonly vault: ReadonlyArray; } +/** Controls the extension side effects of {@link legacySetupDatabase}. */ +export interface LegacySetupDatabaseOptions { + /** Apply extensions enabled by project config. Disabled for a declarative desired-state scratch. */ + readonly activateUserExtensions?: boolean; + /** Install `pg_net` as a legacy platform baseline, independently of project config. */ + readonly legacyPgNetBaseline?: boolean; +} + /** Input to {@link legacyStartSetupLocalDatabase}. */ export interface LegacyStartSetupLocalDatabaseInput extends Omit< LegacySetupDatabaseInput, @@ -899,6 +915,26 @@ export const legacyApplyApiPrivileges = Effect.fnUntraced(function* ( ); }); +const legacyApplyDatabaseWebhooks = Effect.fnUntraced(function* ( + input: LegacySetupDatabaseInput, + tmpDir: string, + options: LegacySetupDatabaseOptions, +) { + const activateUserExtensions = options.activateUserExtensions ?? true; + const legacyPgNetBaseline = options.legacyPgNetBaseline ?? false; + const userEnabled = + activateUserExtensions && input.config.experimental.webhooks?.enabled === true; + if (!legacyPgNetBaseline && !userEnabled) return; + yield* legacyExecSqlConstant( + input.session, + input.fs, + input.path, + tmpDir, + "enable-database-webhooks.sql", + LEGACY_START_ENABLE_DATABASE_WEBHOOKS_SQL, + ); +}); + /** * Port of Go's `initCurrentBranch` (`start.go:233-241`): writes * `supabase/.branches/_current_branch` = `"main"` (Go's `CurrBranchPath`, @@ -961,6 +997,7 @@ export const legacyStartInitCurrentBranch = Effect.fnUntraced(function* ( export const legacySetupDatabase = ( spawner: Spawner, input: LegacySetupDatabaseInput, + options: LegacySetupDatabaseOptions = {}, ): Effect.Effect< void, LegacyDbSetupError | LegacyMigrationVaultError | LegacyImagePrepullError, @@ -969,7 +1006,7 @@ export const legacySetupDatabase = ( Effect.gen(function* () { const { session, fs, path, workdir } = input; - // initSchema -> ApplyApiPrivileges (start.go:383-389). + // initSchema -> user/baseline extension activation -> ApplyApiPrivileges. yield* Effect.scoped( Effect.gen(function* () { const tmpDir = yield* fs @@ -984,6 +1021,7 @@ export const legacySetupDatabase = ( ), ); yield* legacyStartInitSchema(spawner, input, tmpDir); + yield* legacyApplyDatabaseWebhooks(input, tmpDir, options); yield* legacyApplyApiPrivileges(session, fs, path, tmpDir, input.apiAutoExposeNewTables); }), ); @@ -1121,6 +1159,9 @@ export const legacyStartSetupLocalDatabase = ( input.version.length === 0 && (toml.pgDelta.enabled || legacyParseBoolEnv(toml.envLookup("SUPABASE_EXPERIMENTAL_PG_DELTA"))); + const pgDeltaImplementation = legacyResolvePgDeltaImplementation( + toml.envLookup("SUPABASE_USE_PG_DELTA_NEXT"), + ); const pgDeltaCtx: LegacyPgDeltaContext = { projectId: input.projectId, cwd: workdir, @@ -1141,7 +1182,8 @@ export const legacyStartSetupLocalDatabase = ( Effect.gen(function* () { yield* legacyApplyProjectEnv(input.projectEnvValues ?? {}); yield* legacyTryCacheMigrationsCatalog(fs, path, pgDeltaCtx, { - enabled: cacheEnabled, + // The catalog is a legacy-engine artifact with no in-process consumer. + enabled: cacheEnabled && pgDeltaImplementation === "legacy", targetUrl: input.dbUrl, conn: { host: hostDbUrl.hostname, 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 628bd0c8c9..92d80caec7 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 @@ -48,6 +48,7 @@ const SCHEMA_13_FINGERPRINT = const SCHEMA_14_FINGERPRINT_SUFFIX = "CREATE SCHEMA IF NOT EXISTS graphql"; const REVOKE_PRIVILEGES_FINGERPRINT = "revoke execute on functions from anon, authenticated, service_role"; +const PG_NET_CREATE_FINGERPRINT = "create extension if not exists pg_net schema extensions"; function fakeSession() { const calls: Array<{ kind: "exec" | "query"; sql: string; params?: ReadonlyArray }> = []; @@ -590,6 +591,42 @@ describe("legacyStartSetupLocalDatabase", () => { ); }); + describe("Database Webhooks", () => { + it.effect("does not install pg_net merely because Edge Runtime is enabled", () => { + const workdir = makeWorkdir(); + const { session, calls } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + const config = decodeConfig({ edge_runtime: { enabled: true } }); + return run(baseInput(workdir, session, { majorVersion: 14, config }), out, docker).pipe( + Effect.map(() => { + const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql); + expect(execSql.some((sql) => sql.includes(PG_NET_CREATE_FINGERPRINT))).toBe(false); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }); + + it.effect("installs pg_net when Database Webhooks is enabled without Edge Runtime", () => { + const workdir = makeWorkdir(); + writeConfigToml(workdir, "[experimental.webhooks]\nenabled = true\n"); + const { session, calls } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + const config = decodeConfig({ + edge_runtime: { enabled: false }, + experimental: { webhooks: { enabled: true } }, + }); + return run(baseInput(workdir, session, { majorVersion: 14, config }), out, docker).pipe( + Effect.map(() => { + const execSql = calls.filter((c) => c.kind === "exec").map((c) => c.sql); + expect(execSql.filter((sql) => sql.includes(PG_NET_CREATE_FINGERPRINT))).toHaveLength(1); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }); + }); + describe("vault upsert + custom-roles seed", () => { it.effect("upserts vault secrets before seeding supabase/roles.sql", () => { const workdir = makeWorkdir(); @@ -659,42 +696,53 @@ describe("legacyStartSetupLocalDatabase", () => { ); }); - it.effect( - "caches the migrations catalog after MigrateAndSeed when [experimental.pgdelta] 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(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 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( "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\n"); + 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(); @@ -732,7 +780,7 @@ describe("legacyStartSetupLocalDatabase", () => { mkdirSync(join(workdir, "supabase"), { recursive: true }); writeFileSync( join(workdir, "supabase", ".env"), - "PGDELTA_NPM_REGISTRY=https://registry.example.com/supabase\n", + "PGDELTA_NPM_REGISTRY=https://registry.example.com/supabase\nSUPABASE_USE_PG_DELTA_NEXT=false\n", ); const { session } = fakeSession(); const out = mockOutput(); @@ -771,6 +819,7 @@ describe("legacyStartSetupLocalDatabase", () => { () => { 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(); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts index 122418f02f..440aadfa80 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/postgres.service.unit.test.ts @@ -89,6 +89,11 @@ describe("legacyBuildPostgresStartContainerSpec", () => { ); expect(script).not.toContain(LEGACY_POSTGRES_DEFAULT_ROOT_KEY); expect(script).not.toContain("pgsodium_root.key"); + expect(LEGACY_START_DB_WEBHOOK_SQL).not.toContain("CREATE EXTENSION IF NOT EXISTS pg_net"); + expect(LEGACY_START_DB_WEBHOOK_SQL).toContain( + "CREATE OR REPLACE FUNCTION extensions.grant_pg_net_access()", + ); + expect(LEGACY_START_DB_WEBHOOK_SQL).toContain("CREATE EVENT TRIGGER issue_pg_net_access"); expect(spec.tmpfs).toBeUndefined(); expect(spec.secretFiles).toEqual([ { diff --git a/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts index e9f4c01ba6..055842026f 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts @@ -71,6 +71,7 @@ import { legacyToPostgresURL } from "../legacy-postgres-url.ts"; import { type LegacyFreshDbSetupInput, type LegacySetupDatabaseInput, + type LegacySetupDatabaseOptions, type LegacyStartDbSetupImages, type LegacyStartSetupLocalDatabaseError, legacyResolveDbSetupPrelude, @@ -414,13 +415,14 @@ export const legacyPrepareRawShadow = ( export const legacySetupShadowConn = ( spawner: Spawner, input: LegacySetupDatabaseInput, + options: LegacySetupDatabaseOptions = {}, ): Effect.Effect< void, LegacyStartSetupLocalDatabaseError | LegacyShadowDbError, Output | LegacyDockerRun | RuntimeInfo > => Effect.gen(function* () { - yield* legacySetupDatabase(spawner, input); + yield* legacySetupDatabase(spawner, input, options); yield* input.session.exec(LEGACY_SHADOW_CREATE_TEMPLATE_SQL).pipe( Effect.mapError( (cause) => @@ -518,6 +520,7 @@ export const legacyBuildShadowSetupDatabaseInput = ( export const legacySetupShadowDatabase = ( spawner: Spawner, input: LegacyShadowSetupRunInput, + options: LegacySetupDatabaseOptions = {}, ): Effect.Effect< void, LegacyStartSetupLocalDatabaseError | LegacyShadowDbError | LegacyImagePrepullError | E, @@ -530,6 +533,7 @@ export const legacySetupShadowDatabase = ( yield* legacySetupShadowConn( spawner, legacyBuildShadowSetupDatabaseInput(input, session, resolved), + options, ); }), ); @@ -547,9 +551,10 @@ export const legacySetupShadowDatabase = ( * own doc comment for why the ordering matters. Connection closed once this resolves, matching * Go's `defer conn.Close(...)`. */ -export const legacyMigrateShadowDatabase = ( +const migrateShadowDatabase = ( spawner: Spawner, input: LegacyShadowSetupRunInput, + setupOptions: LegacySetupDatabaseOptions, ): Effect.Effect< void, LegacyStartSetupLocalDatabaseError | LegacyShadowDbError | LegacyImagePrepullError | E, @@ -573,6 +578,7 @@ export const legacyMigrateShadowDatabase = ( yield* legacySetupShadowConn( spawner, legacyBuildShadowSetupDatabaseInput(input, session, resolved), + setupOptions, ); yield* legacyApplyMigrations( session, @@ -583,3 +589,31 @@ export const legacyMigrateShadowDatabase = ( ); }), ); + +/** + * Migrates a shadow for migra and the legacy pg-delta engine. Those Go-backed + * workflows historically include `pg_net` in the platform baseline regardless of + * project config, so preserve that baseline while sharing the native TS setup path. + */ +export const legacyMigrateShadowDatabase = ( + spawner: Spawner, + input: LegacyShadowSetupRunInput, +): Effect.Effect< + void, + LegacyStartSetupLocalDatabaseError | LegacyShadowDbError | LegacyImagePrepullError | E, + Output | LegacyDockerRun | RuntimeInfo | LegacyDbConnection +> => migrateShadowDatabase(spawner, input, { legacyPgNetBaseline: true }); + +/** + * Migrates a shadow for the in-process pg-delta engine. Unlike the legacy engine, + * extension activation follows project config through `legacySetupDatabase`'s + * default options. + */ +export const legacyMigrateNextShadowDatabase = ( + spawner: Spawner, + input: LegacyShadowSetupRunInput, +): Effect.Effect< + void, + LegacyStartSetupLocalDatabaseError | LegacyShadowDbError | LegacyImagePrepullError | E, + Output | LegacyDockerRun | RuntimeInfo | LegacyDbConnection +> => migrateShadowDatabase(spawner, input, {}); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.unit.test.ts index b303ae756a..b3f32546dd 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.unit.test.ts @@ -23,6 +23,7 @@ import { legacyConnectShadowDatabase, legacyCreateShadowDatabase, legacyMigrateShadowDatabase, + legacyMigrateNextShadowDatabase, legacyRemoveShadowDatabase, legacySetupShadowConn, legacySetupShadowDatabase, @@ -32,6 +33,7 @@ import { const decodeConfig = Schema.decodeUnknownSync(ProjectConfigSchema); const defaultConfig: ProjectConfig = decodeConfig({}); +const PG_NET_CREATE_FINGERPRINT = "create extension if not exists pg_net schema extensions"; const tempRoot = useLegacyTempWorkdir("legacy-shadow-database-"); @@ -404,6 +406,30 @@ describe("legacySetupShadowConn", () => { ), ); }); + + it.effect("can disable config-driven extension activation for a desired-state scratch", () => { + const { session, calls } = fakeSession(); + const workdir = tempRoot.current; + const mock = mockSpawner(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const input = baseSetupDatabaseInput(session, fs, path, workdir); + yield* legacySetupShadowConn( + mock.spawner, + { + ...input, + config: decodeConfig({ experimental: { webhooks: { enabled: true } } }), + }, + { activateUserExtensions: false }, + ); + expect(calls.some((call) => call.sql.includes(PG_NET_CREATE_FINGERPRINT))).toBe(false); + }).pipe( + Effect.provide( + Layer.mergeAll(BunServices.layer, mockOutput().layer, mockDockerRun(), mockRuntimeInfo()), + ), + ); + }); }); function baseShadowSetup( @@ -556,6 +582,7 @@ describe("legacySetupShadowDatabase / legacyMigrateShadowDatabase", () => { setup: baseShadowSetup(), }); expect(calls.some((c) => c.sql === LEGACY_SHADOW_CREATE_TEMPLATE_SQL)).toBe(true); + expect(calls.some((c) => c.sql.includes(PG_NET_CREATE_FINGERPRINT))).toBe(true); expect(calls.some((c) => c.sql.includes("create table t ()"))).toBe(true); }).pipe( Effect.provide( @@ -571,6 +598,44 @@ describe("legacySetupShadowDatabase / legacyMigrateShadowDatabase", () => { }, ); + it.effect("next migrated shadows keep pg_net activation config-gated", () => { + const { session, calls } = fakeSession(); + const workdir = tempRoot.current; + const mock = mockSpawner(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(workdir, "supabase", "migrations"), { recursive: true }); + yield* legacyMigrateNextShadowDatabase(mock.spawner, { + fs, + path, + workdir, + projectId: "proj", + container: "shadow-container-id-0123456789abcdef", + networkId: "supabase_network_proj", + connConfig: { + host: "127.0.0.1", + port: 54320, + user: "postgres", + password: "postgres", + database: "postgres", + }, + setup: baseShadowSetup(), + }); + expect(calls.some((call) => call.sql.includes(PG_NET_CREATE_FINGERPRINT))).toBe(false); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + mockOutput().layer, + mockDockerRun(), + mockRuntimeInfo(), + mockDbConnection(session), + ), + ), + ); + }); + it.effect( "does not resolve JWKS on PG14 even when realtime is enabled (Go's initSchema never reaches ResolveJWKS for MajorVersion <= 14)", () => { diff --git a/apps/cli/src/legacy/shared/db-bootstrap/templates/db-initial-schema-14.sql.ts b/apps/cli/src/legacy/shared/db-bootstrap/templates/db-initial-schema-14.sql.ts index 8199aaad59..b2b506eb09 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/templates/db-initial-schema-14.sql.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/templates/db-initial-schema-14.sql.ts @@ -79,20 +79,6 @@ CREATE SCHEMA IF NOT EXISTS graphql_public; ALTER SCHEMA graphql_public OWNER TO supabase_admin; --- --- Name: pg_net; Type: EXTENSION; Schema: -; Owner: - --- - -CREATE EXTENSION IF NOT EXISTS pg_net WITH SCHEMA extensions; - - --- --- Name: EXTENSION pg_net; Type: COMMENT; Schema: -; Owner: --- - -COMMENT ON EXTENSION pg_net IS 'Async HTTP'; - - -- -- Name: pgbouncer; Type: SCHEMA; Schema: -; Owner: pgbouncer -- diff --git a/apps/cli/src/legacy/shared/db-bootstrap/templates/db-webhook.sql.ts b/apps/cli/src/legacy/shared/db-bootstrap/templates/db-webhook.sql.ts index 5aa85e84a5..d71eeff247 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/templates/db-webhook.sql.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/templates/db-webhook.sql.ts @@ -7,9 +7,6 @@ */ export const LEGACY_START_DB_WEBHOOK_SQL = `BEGIN; --- Create pg_net extension -CREATE EXTENSION IF NOT EXISTS pg_net SCHEMA extensions; - -- Create supabase_functions schema CREATE SCHEMA supabase_functions AUTHORIZATION supabase_admin; 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 bd6e2f9372..a897c0b585 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 @@ -1000,8 +1000,8 @@ const DEFAULT_SUPABASE_ENV = "development"; * `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 every pg-delta edge-runtime invocation — diff, declarative - * export/sync, and the push/pull/dump migrations-catalog cache). Go's + * `process.env` for legacy-opt-out pg-delta edge-runtime invocations). The bundled + * next implementation never consults it. 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 @@ -1546,10 +1546,11 @@ const readDbTomlCore = Effect.fnUntraced(function* ( .readFileString(poolerUrlPath) .pipe(Effect.map(nonEmptyString), Effect.orElseSucceed(Option.none)); - // Go: `config.go:700-709` — the pg-delta npm version is read from + // Go: `config.go:700-709` — 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`). + // 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())), diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts index c2ca32c7c7..8653dd405e 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts @@ -13,7 +13,10 @@ import { Effect } from "effect"; import { LEGACY_SUGGEST_ENV_VAR, LEGACY_SUGGEST_LOCAL_STACK } from "./legacy-connect-errors.ts"; import type { LegacyDbConnectError, LegacyDbExecError } from "./legacy-db-connection.errors.ts"; import { type LegacyPgConnInput, LegacyDbConnection } from "./legacy-db-connection.service.ts"; -import { legacyDbConnectionSqlPgLayer } from "./legacy-db-connection.sql-pg.layer.ts"; +import { + legacyAcquirePgPool, + legacyDbConnectionSqlPgLayer, +} from "./legacy-db-connection.sql-pg.layer.ts"; const SUGGESTION_CONTEXT = { dashboardUrl: "https://supabase.com/dashboard", @@ -339,3 +342,37 @@ describe("legacyDbConnectionSqlPgLayer exec failures", () => { }), ); }); + +describe("legacyAcquirePgPool", () => { + it.live("returns the winning raw pool and ends it when the caller scope closes", () => + Effect.gen(function* () { + const server = yield* Effect.promise(() => + fakeQueryServer(() => Buffer.concat([commandComplete("SELECT 1"), READY_FOR_QUERY])), + ); + yield* Effect.gen(function* () { + let acquired: import("pg").Pool | undefined; + + yield* Effect.gen(function* () { + const pool = yield* legacyAcquirePgPool( + { + host: "127.0.0.1", + port: server.port, + user: "postgres", + password: SENTINEL_PASSWORD, + database: "postgres", + sslmode: "disable", + }, + { isLocal: true, dnsResolver: "native" }, + ); + acquired = pool; + expect(pool.ending).toBe(false); + expect(pool.ended).toBe(false); + yield* Effect.tryPromise(() => pool.query("select 1")); + }).pipe(Effect.scoped); + + expect(acquired?.ending).toBe(true); + expect(acquired?.ended).toBe(true); + }).pipe(Effect.ensuring(Effect.sync(server.close))); + }), + ); +}); diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts index 50e51a2048..126b7505b4 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts @@ -575,15 +575,33 @@ export const legacyAcquireProbedPool =

( return pool; }); +/** Map a driver connect failure to the credential-free Go-compatible error. */ +const legacyToConnectError = ( + cfg: LegacyPgConnInput, + isLocal: boolean, + error: unknown, +): LegacyDbConnectError => { + const suggestion = + cfg.suggestionContext === undefined + ? undefined + : legacyConnectSuggestion(error, { ...cfg.suggestionContext, isLocal }); + return new LegacyDbConnectError({ + message: `failed to connect to postgres: ${legacyConnectFailureMessage(cfg, error)}`, + ...(suggestion === undefined ? {} : { suggestion }), + ...(legacyIsDialFailure(error) ? { retryable: true } : {}), + }); +}; + /** - * Default `LegacyDbConnection` layer, backed by `@effect/sql-pg` (pure-JS `pg` - * driver, no native addon — bundles under `bun build --compile`). Each - * `connect` builds a scoped single-client connection that closes on scope exit. + * Acquire the winning raw pool through the full Go-compatible connection attempt + * chain. The pool finalizer is owned by the caller's scope; both the legacy session + * adapter and direct-pool consumers use this one acquisition core so their DNS, + * TLS, fallback, and role behavior cannot drift apart. */ -const connect = ( +const acquirePgPoolConnection = ( cfg: LegacyPgConnInput, { isLocal, dnsResolver }: LegacyDbConnectOptions, -): Effect.Effect => +) => Effect.gen(function* () { // pgconn dials the primary host then each HA fallback in order // (`config.go:326-362`); `cfg.fallbacks` carries the extras parsed from a @@ -617,8 +635,8 @@ const connect = ( // `AfterConnect` hook only on the remote path (`ConnectByConfigStream`, // `connect.go:342-362`), not `ConnectLocalPostgres`, so gate on `!isLocal`. const stepDownRequired = !isLocal && needsRoleStepDown(cfg.user); - // Build the primary connection over a self-managed `pg.Pool` (via - // `PgClient.fromPool`) rather than `PgClient.make`, so we control two pool + // Build the primary connection over a self-managed `pg.Pool` rather than + // `PgClient.make`, so we control two pool // behaviors `PgClient.make` does not expose: `idleTimeoutMillis: 0` (never reap // the single pooled connection — see `legacyBuildPoolConfig`; the fix for the // `db pull` step-down loss) and the per-connection role step-down `verify` hook @@ -627,12 +645,12 @@ const connect = ( // the pool on scope exit AND on every failure/timeout (the leak `PgClient.make` // has). `probe` (below) runs each attempt in a forked scope so a failed fallback // attempt's pool closes immediately, before the next host is dialed. - const makeClient = ( + const makePool = ( dialHost: string, port: number, sslOption: boolean | ConnectionOptions | undefined, - ) => { - const acquire = legacyAcquireProbedPool( + ) => + legacyAcquireProbedPool( () => new Pg.Pool( legacyBuildPoolConfig( @@ -646,8 +664,6 @@ const connect = ( ), connectTimeoutSeconds, ); - return PgClient.fromPool({ acquire }).pipe(Effect.provide(Reactivity.layer)); - }; // Go's `ConnectByUrl` calls `SetConnectSuggestion(err)` on every connect failure // (`connect.go:187`), mapping the driver error to an actionable hint that replaces @@ -658,18 +674,6 @@ const connect = ( // to postgres:` prefix plus the `host=… user=… database=…` identity and the // underlying driver cause — not the bare `SqlError` toString, which drops all // of that detail. - const toConnectError = (error: unknown) => { - const suggestion = - cfg.suggestionContext === undefined - ? undefined - : legacyConnectSuggestion(error, { ...cfg.suggestionContext, isLocal }); - return new LegacyDbConnectError({ - message: `failed to connect to postgres: ${legacyConnectFailureMessage(cfg, error)}`, - ...(suggestion === undefined ? {} : { suggestion }), - ...(legacyIsDialFailure(error) ? { retryable: true } : {}), - }); - }; - // Load the `sslrootcert` CA bundle (pgconn reads it into `RootCAs` at parse // time; a missing/unreadable file aborts). Skipped for local connections, which // never use TLS. pgconn builds TLS per fallback host, so the CA must be loaded @@ -725,7 +729,7 @@ const connect = ( const attempts = dialTargets.flatMap(({ dialHost, port, servername }) => legacySslConfigsFor(cfg.sslmode, isLocal, servername, caCert, dialHost, clientCert).map( (ssl) => ({ - client: makeClient(dialHost, port, ssl), + pool: makePool(dialHost, port, ssl), // pgconn only short-circuits the fallback chain on an auth error when the // failed attempt used TLS (`pgconn.go:182`, gated on `fc.TLSConfig != nil`); // a TLS config is any non-plaintext `ssl` value. @@ -758,9 +762,8 @@ const connect = ( // session and closes with it. const sessionScope = yield* Scope.Scope; const attemptScope = yield* Scope.fork(sessionScope); - return yield* attempt.client.pipe( - Effect.tap((candidate) => candidate`select 1`), - Effect.map((candidate) => ({ candidate, rawConfig: attempt.rawConfig })), + return yield* attempt.pool.pipe( + Effect.map((pool) => ({ pool, rawConfig: attempt.rawConfig })), Scope.provide(attemptScope), Effect.onExit((exit) => Exit.isSuccess(exit) ? Effect.void : Scope.close(attemptScope, exit), @@ -768,7 +771,7 @@ const connect = ( ); }); const lastIndex = attempts.length - 1; - const { candidate: client, rawConfig: winningRawConfig } = yield* attempts + const { pool, rawConfig: winningRawConfig } = yield* attempts .slice(0, lastIndex) .reduceRight( (next, attempt) => @@ -779,26 +782,58 @@ const connect = ( ), probe(attempts[lastIndex]!), ) - .pipe(Effect.mapError(toConnectError)); + .pipe(Effect.mapError((error) => legacyToConnectError(cfg, isLocal, error))); // Step down from the temp/privileged login role before any further SQL — but // only for remote connections: Go installs this hook in `ConnectByConfigStream`, // not `ConnectLocalPostgres`, so a local `--db-url` using `supabase_admin`/ - // `cli_login_*` must not run it. The pool's `"connect"` hook already ran this on - // the physical connection (and on any silent redial); this explicit one-shot is - // the fail-fast path — the hook swallows errors, so a real role-privilege problem - // only surfaces here, as `LegacyDbConnectError: failed to set session role: ...` - // (Go parity). `max: 1` + `idleTimeoutMillis: 0` keep the stepped-down connection + // `cli_login_*` must not run it. The pool's `verify` hook already ran this on + // the physical connection (and runs it on any silent redial); this explicit + // one-shot preserves the fail-fast `LegacyDbConnectError: failed to set session + // role: ...` path. `max: 1` + `idleTimeoutMillis: 0` keep the stepped-down connection // alive so the session-scoped role persists for every later `exec`/`query`. if (stepDownRequired) { - yield* client.unsafe(SET_SESSION_ROLE).pipe( - Effect.asVoid, - Effect.mapError( - (error) => new LegacyDbConnectError({ message: `failed to set session role: ${error}` }), - ), - ); + yield* Effect.tryPromise({ + try: () => pool.query(SET_SESSION_ROLE), + catch: (error) => + new LegacyDbConnectError({ message: `failed to set session role: ${error}` }), + }); } + return { pool, winningRawConfig, stepDownRequired }; + }); + +/** + * Acquire a live `pg.Pool` using the same scoped lifecycle and connection parity + * as `LegacyDbConnection.connect`. The caller owns the surrounding scope; closing + * it ends the winning pool, while every losing fallback attempt is closed before + * the next target is tried. + */ +export const legacyAcquirePgPool = ( + cfg: LegacyPgConnInput, + options: LegacyDbConnectOptions, +): Effect.Effect => + acquirePgPoolConnection(cfg, options).pipe(Effect.map(({ pool }) => pool)); + +/** + * Default `LegacyDbConnection` layer, backed by `@effect/sql-pg` (pure-JS `pg` + * driver, no native addon — bundles under `bun build --compile`). Each + * `connect` builds a scoped single-client connection that closes on scope exit. + */ +const connect = ( + cfg: LegacyPgConnInput, + options: LegacyDbConnectOptions, +): Effect.Effect => + Effect.gen(function* () { + const { pool, winningRawConfig, stepDownRequired } = yield* acquirePgPoolConnection( + cfg, + options, + ); + const client = yield* PgClient.fromPool({ acquire: Effect.succeed(pool) }).pipe( + Effect.provide(Reactivity.layer), + Effect.mapError((error) => legacyToConnectError(cfg, options.isLocal, error)), + ); + // `inspect report` runs ~14 `COPY (...) TO STDOUT` statements. node-postgres' // COPY protocol needs the raw client (which `@effect/sql-pg` does not surface), // so the session opens ONE dedicated raw connection against the winning dial @@ -828,7 +863,7 @@ const connect = ( const fresh = new Pg.Client(winningRawConfig); yield* Effect.tryPromise({ try: () => fresh.connect(), - catch: toConnectError, + catch: (error) => legacyToConnectError(cfg, options.isLocal, error), }); if (stepDownRequired) { yield* Effect.tryPromise({ 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 808203c0c3..04e5709efc 100644 --- a/apps/cli/src/legacy/shared/legacy-db-push-core.ts +++ b/apps/cli/src/legacy/shared/legacy-db-push-core.ts @@ -9,6 +9,7 @@ import { } from "./legacy-pgdelta.cache.ts"; import { type LegacyPgDeltaContext } from "./legacy-pgdelta.ts"; import { legacyParseBoolEnv } from "./legacy-diff-engine.ts"; +import { legacyResolvePgDeltaImplementation } from "./legacy-pgdelta-next-flag.ts"; import { LEGACY_ERR_MISSING_LOCAL, LEGACY_ERR_MISSING_REMOTE, @@ -322,6 +323,9 @@ export const legacyDbPushCore = Effect.fnUntraced(function* (input: LegacyDbPush const cacheEnabled = toml.pgDelta.enabled || legacyParseBoolEnv(toml.envLookup("SUPABASE_EXPERIMENTAL_PG_DELTA")); + const pgDeltaImplementation = legacyResolvePgDeltaImplementation( + toml.envLookup("SUPABASE_USE_PG_DELTA_NEXT"), + ); const pgDeltaCtx: LegacyPgDeltaContext = { // Go's `flags.LoadConfig` seeds `Config.ProjectId = ProjectRef` before // `Config.Load` runs, so an absent config.toml `project_id` retains the @@ -346,7 +350,10 @@ export const legacyDbPushCore = Effect.fnUntraced(function* (input: LegacyDbPush projectEnv: toml.projectEnv, }; yield* legacyTryCacheMigrationsCatalog(fs, path, pgDeltaCtx, { - enabled: cacheEnabled, + // 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, diff --git a/apps/cli/src/legacy/shared/legacy-diff-engine.ts b/apps/cli/src/legacy/shared/legacy-diff-engine.ts index 12079b9ab8..16c65e0190 100644 --- a/apps/cli/src/legacy/shared/legacy-diff-engine.ts +++ b/apps/cli/src/legacy/shared/legacy-diff-engine.ts @@ -3,6 +3,9 @@ // byte-identical to the Go CLI. No Effect / service dependencies — unit-tested // directly. +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 Go's `shouldUsePgDelta` * (`db.go:375-376`): `utils.IsPgDeltaEnabled() || usePgDelta || viper.GetBool("EXPERIMENTAL_PG_DELTA")`. diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index 701c233b9c..9d323eb4a2 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -15,8 +15,9 @@ import { MIGRATE_FILE_PATTERN, legacyCreateMigrationTable, } from "./legacy-migration-history.ts"; +import { legacyParseMigrationContent } from "./legacy-migration-file.ts"; import { legacySqlFilesGlob } from "./legacy-sql-files-glob.ts"; -import { legacySplitAndTrim, legacySplitSqlTokens } from "./legacy-sql-split.ts"; +import { legacySplitSqlTokens } from "./legacy-sql-split.ts"; /** * Applying a migration file failed (Go's `ApplyMigrations` / `ExecBatch` error). @@ -63,6 +64,8 @@ const REINDEX_CONCURRENTLY_PATTERN = /^REINDEX(?:\s|\().*\sCONCURRENTLY(?:\s|$)/ const VACUUM_PATTERN = /^VACUUM(?:\s|\(|$)/u; const ALTER_SYSTEM_PATTERN = /^ALTER\s+SYSTEM(?:\s|$)/u; const CLUSTER_PATTERN = /^CLUSTER(?:\s|$)/u; +const TRANSACTION_CONTROL_PATTERN = + /^(?:BEGIN|START\s+TRANSACTION|COMMIT|END|ROLLBACK|ABORT|PREPARE\s+TRANSACTION)(?:\s|$)/u; /** * Strips a leading BOM, whitespace, and SQL line (`--`) and block comments from the @@ -109,6 +112,10 @@ export const legacyIsPipelineIncompatible = (sql: string): boolean => { ); }; +/** Whether the statement owns a transaction boundary that must not be nested. */ +export const legacyHasTransactionControl = (sql: string): boolean => + TRANSACTION_CONTROL_PATTERN.test(legacyTrimLeadingSqlComments(sql).toUpperCase()); + /** A buffered statement awaiting the next batch flush; `version` is the history insert. */ type LegacyBatchItem = | { readonly kind: "exec"; readonly sql: string } @@ -486,12 +493,17 @@ export const legacyFormatExecBatchError = ( * statement runs standalone, then batching resumes (supabase/cli#5156). The history * insert goes in the final batch, so the migration is recorded only after every * statement succeeds. A file with no such statements is a single `BEGIN`/`COMMIT`. + * Pg-delta files whose first line is `-- pg-delta: transaction=false` instead run + * every statement sequentially without a CLI-owned transaction. This keeps their + * session preamble, nontransactional action, and cleanup on the same connection. * - * Does NOT create the history table and does NOT `RESET ALL` — Go's `ExecBatch` does - * neither; those are the migration-apply path's responsibility (`ApplyMigrations`, - * apply.go:65-69), so role/globals files (`legacySeedGlobals`) stay reset-free like Go. - * When `forceNoVersion` is set the history insert is skipped regardless of filename - * (Go's `SeedGlobals` clears `Version`). + * Does NOT create the history table and does not unconditionally `RESET ALL` — Go's + * `ExecBatch` does neither; those are the migration-apply path's responsibility + * (`ApplyMigrations`, apply.go:65-69), so ordinary role/globals files + * (`legacySeedGlobals`) stay reset-free like Go. The one exception is best-effort + * cleanup after a failed pg-delta no-transaction file. When `forceNoVersion` is set + * the history insert is skipped regardless of filename (Go's `SeedGlobals` clears + * `Version`). * * `projectEnv` is forwarded to {@link checkScannerBufferSize} — see its own doc comment * for why a project-`.env`-only `SUPABASE_SCANNER_BUFFER_SIZE` must be visible here too. @@ -564,12 +576,68 @@ const execMigrationBatch = ( // mirrors `NewMigrationFromFile`). Only execution failures get `CmdSuggestion` // (`apply.go:61-63`); callers rely on this tag to replicate that split. yield* Effect.gen(function* () { - const statements = legacySplitAndTrim(content); + const { statements, transactionMode } = legacyParseMigrationContent(content); const filename = path.basename(migrationPath); const matches = MIGRATE_FILE_PATTERN.exec(filename); const version = forceNoVersion ? "" : (matches?.[1] ?? ""); const name = matches?.[2] ?? ""; + // The pg-delta directive is file-level execution metadata. Run the complete + // sequence on this session without adding transaction boundaries so session + // settings remain active for the nontransactional action. History is recorded + // only after every statement succeeds. A failed sequence gets a best-effort + // session reset because the generated trailing RESET ALL may not have run yet. + if (transactionMode === "none") { + const nonTransactional = Effect.gen(function* () { + for (const [index, statement] of statements.entries()) { + yield* session + .exec(statement) + .pipe( + Effect.mapError((cause) => legacyFormatExecBatchError(cause, index, statement)), + ); + } + if (version.length > 0) { + yield* session + .query(INSERT_MIGRATION_VERSION, [version, name, statements]) + .pipe( + Effect.mapError((cause) => + legacyFormatExecBatchError(cause, statements.length, INSERT_MIGRATION_VERSION), + ), + ); + } + }); + return yield* nonTransactional.pipe( + Effect.tapError(() => session.exec("RESET ALL").pipe(Effect.ignore)), + ); + } + + // A headerless file with authored transaction boundaries owns those semantics. + // Execute the statements exactly as written, clean up a failed authored + // transaction, and only send the history insert after every statement succeeds. + if (statements.some(legacyHasTransactionControl)) { + const authored = Effect.gen(function* () { + for (const [index, statement] of statements.entries()) { + yield* session + .exec(statement) + .pipe( + Effect.mapError((cause) => legacyFormatExecBatchError(cause, index, statement)), + ); + } + if (version.length > 0) { + yield* session + .query(INSERT_MIGRATION_VERSION, [version, name, statements]) + .pipe( + Effect.mapError((cause) => + legacyFormatExecBatchError(cause, statements.length, INSERT_MIGRATION_VERSION), + ), + ); + } + }); + return yield* authored.pipe( + Effect.tapError(() => session.exec("ROLLBACK").pipe(Effect.ignore)), + ); + } + // `executed` is the global statement index of the next statement to run, so the // error context stays accurate across flushed batches and standalone statements // (Go threads the same counter through `ExecBatch`). 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 c41c48e77c..1b6a5f549a 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 @@ -15,6 +15,7 @@ import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; import { legacyApplyMigrationFile, legacyApplySchemaFiles, + legacyHasTransactionControl, legacyIsPipelineIncompatible, legacyMarkError, legacySeedGlobals, @@ -207,6 +208,75 @@ describe("legacyApplyMigrationFile", () => { ); }); + it.effect("honors pg-delta's file-level no-transaction directive", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const file = join(dir, "20240101120000_drop_subscription.sql"); + writeFileSync( + file, + "-- pg-delta: transaction=false\n" + + "SET check_function_bodies = off;\n" + + "DROP SUBSCRIPTION app_events;\n" + + "RESET ALL;", + ); + const { session, calls } = fakeSession(); + return run(session, file).pipe( + Effect.tap(() => + Effect.sync(() => { + const execs = calls.filter((call) => call.kind === "exec").map((call) => call.sql); + const setupCommit = execs.indexOf("COMMIT"); + const set = execs.indexOf("SET check_function_bodies = off"); + const action = execs.indexOf("DROP SUBSCRIPTION app_events"); + const cleanup = execs.lastIndexOf("RESET ALL"); + + // The history-table setup owns the only CLI transaction. Pg-delta's + // preamble, action, and cleanup then run sequentially on this session. + expect(execs.filter((sql) => sql === "BEGIN")).toHaveLength(1); + expect(execs.filter((sql) => sql === "COMMIT")).toHaveLength(1); + expect(set).toBeGreaterThan(setupCommit); + expect(action).toBeGreaterThan(set); + expect(cleanup).toBeGreaterThan(action); + + const history = calls.filter((call) => call.kind === "query"); + expect(history).toHaveLength(1); + expect(history[0]?.params).toEqual([ + "20240101120000", + "drop_subscription", + ["SET check_function_bodies = off", "DROP SUBSCRIPTION app_events", "RESET ALL"], + ]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("resets the session and omits history when a no-transaction migration fails", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const file = join(dir, "20240101120000_drop_subscription.sql"); + writeFileSync( + file, + "-- pg-delta: transaction=false\n" + + "SET check_function_bodies = off;\n" + + "DROP SUBSCRIPTION app_events;\n" + + "RESET ALL;", + ); + const { session, calls } = fakeSession({ failOn: "DROP SUBSCRIPTION" }); + return run(session, file).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + const execs = calls.filter((call) => call.kind === "exec").map((call) => call.sql); + expect(execs.at(-1)).toBe("RESET ALL"); + expect(calls.some((call) => call.kind === "query")).toBe(false); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("At statement: 1"); + } + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + it.effect("reports a pipeline-incompatible statement failure with its statement index", () => { const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); const file = join(dir, "20240101120000_add_index.sql"); @@ -230,6 +300,62 @@ describe("legacyApplyMigrationFile", () => { ), ); }); + + it.effect("preserves authored transaction boundaries and records history afterwards", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const file = join(dir, "20240101120000_authored.sql"); + writeFileSync(file, "BEGIN;\nSET LOCAL check_function_bodies = off;\nCOMMIT;"); + const { session, calls } = fakeSession(); + return run(session, file).pipe( + Effect.tap(() => + Effect.sync(() => { + const execs = calls.filter((call) => call.kind === "exec").map((call) => call.sql); + // One BEGIN/COMMIT belongs to history-table setup; the other pair is + // exactly the authored boundary, with no nested migration wrapper. + expect(execs.filter((sql) => sql === "BEGIN")).toHaveLength(2); + expect(execs.filter((sql) => sql === "COMMIT")).toHaveLength(2); + expect(execs).toContain("SET LOCAL check_function_bodies = off"); + const history = calls.filter((call) => call.kind === "query"); + expect(history).toHaveLength(1); + expect(history[0]?.params?.[0]).toBe("20240101120000"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("does not record history when an authored transaction fails", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const file = join(dir, "20240101120000_authored.sql"); + writeFileSync(file, "BEGIN;\nCREATE TABLE broken (;\nCOMMIT;"); + const { session, calls } = fakeSession({ failOn: "CREATE TABLE broken" }); + return run(session, file).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + expect(calls.some((call) => call.kind === "query")).toBe(false); + expect(calls.some((call) => call.kind === "exec" && call.sql === "ROLLBACK")).toBe(true); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); +}); + +describe("legacyHasTransactionControl", () => { + it("recognizes authored boundaries after comments without matching routine bodies", () => { + expect(legacyHasTransactionControl("-- authored\nBEGIN")).toBe(true); + expect(legacyHasTransactionControl("START TRANSACTION ISOLATION LEVEL SERIALIZABLE")).toBe( + true, + ); + expect(legacyHasTransactionControl("ROLLBACK TO SAVEPOINT before_change")).toBe(true); + expect( + legacyHasTransactionControl( + "CREATE FUNCTION f() RETURNS void AS $$ BEGIN END $$ LANGUAGE plpgsql", + ), + ).toBe(false); + }); }); describe("migration failure rendering (Go ExecBatch parity)", () => { diff --git a/apps/cli/src/legacy/shared/legacy-migration-file.ts b/apps/cli/src/legacy/shared/legacy-migration-file.ts index f8a7b7cd6e..6f2d52668e 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-file.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-file.ts @@ -1,5 +1,44 @@ import type { Path } from "effect"; +import { legacySplitAndTrim } from "./legacy-sql-split.ts"; + +type LegacyMigrationTransactionMode = "transactional" | "none"; + +export interface LegacyParsedMigrationContent { + readonly statements: ReadonlyArray; + readonly transactionMode: LegacyMigrationTransactionMode; +} + +const PG_DELTA_NO_TRANSACTION_DIRECTIVE = "-- pg-delta: transaction=false"; + +/** + * Parses the durable execution metadata and SQL statements in a migration file. + * Pg-delta writes its no-transaction directive as the first line because migration + * apply commands only retain the generated file, not the in-memory plan metadata. + * The exact directive may follow a UTF-8 BOM and may end with LF or CRLF. Marker-like + * comments anywhere else remain ordinary SQL comments and preserve the established + * transactional default. + */ +export function legacyParseMigrationContent(content: string): LegacyParsedMigrationContent { + const withoutBom = content.charCodeAt(0) === 0xfeff ? content.slice(1) : content; + const firstNewline = withoutBom.indexOf("\n"); + const rawFirstLine = firstNewline < 0 ? withoutBom : withoutBom.slice(0, firstNewline); + const firstLine = rawFirstLine.endsWith("\r") ? rawFirstLine.slice(0, -1) : rawFirstLine; + + if (firstLine === PG_DELTA_NO_TRANSACTION_DIRECTIVE) { + const sql = firstNewline < 0 ? "" : withoutBom.slice(firstNewline + 1); + return { + statements: legacySplitAndTrim(sql), + transactionMode: "none", + }; + } + + return { + statements: legacySplitAndTrim(content), + transactionMode: "transactional", + }; +} + /** * Go's `GetCurrentTimestamp` (`apps/cli-go/internal/utils/misc.go:130`): the * current time formatted UTC as `YYYYMMDDHHMMSS` (Go's `layoutVersion` diff --git a/apps/cli/src/legacy/shared/legacy-migration-file.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-file.unit.test.ts index e35fc6e311..d3d265007b 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-file.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-file.unit.test.ts @@ -1,7 +1,50 @@ import type { Path } from "effect"; import { describe, expect, it } from "vitest"; -import { legacyFormatMigrationTimestamp, legacyGetMigrationPath } from "./legacy-migration-file.ts"; +import { + legacyFormatMigrationTimestamp, + legacyGetMigrationPath, + legacyParseMigrationContent, +} from "./legacy-migration-file.ts"; + +describe("legacyParseMigrationContent", () => { + it.each([ + ["LF", "-- pg-delta: transaction=false\nSET check_function_bodies = off;"], + ["CRLF", "-- pg-delta: transaction=false\r\nSET check_function_bodies = off;"], + ["a UTF-8 BOM", "\uFEFF-- pg-delta: transaction=false\nSET check_function_bodies = off;"], + ])("recognizes the anchored no-transaction directive with %s", (_name, content) => { + expect(legacyParseMigrationContent(content)).toEqual({ + statements: ["SET check_function_bodies = off"], + transactionMode: "none", + }); + }); + + it("defaults an ordinary migration to transactional execution", () => { + expect(legacyParseMigrationContent("CREATE TABLE example (id bigint);")).toEqual({ + statements: ["CREATE TABLE example (id bigint)"], + transactionMode: "transactional", + }); + }); + + it("leaves a later transaction marker as an ordinary transactional comment", () => { + const content = "-- generated migration\n-- pg-delta: transaction=false\nVACUUM;"; + expect(legacyParseMigrationContent(content)).toEqual({ + statements: [content.slice(0, -1)], + transactionMode: "transactional", + }); + }); + + it.each([ + "-- pg-delta: transaction=true\nSELECT 1;", + " -- pg-delta: transaction=false\nSELECT 1;", + "-- pg-delta: transaction=none\nSELECT 1;", + ])("leaves a malformed first-line marker transactional: %s", (content) => { + expect(legacyParseMigrationContent(content)).toEqual({ + statements: [content.trim().slice(0, -1)], + transactionMode: "transactional", + }); + }); +}); describe("legacyFormatMigrationTimestamp", () => { it("formats epoch millis as UTC YYYYMMDDHHMMSS", () => { diff --git a/apps/cli/src/legacy/shared/legacy-migration-history.ts b/apps/cli/src/legacy/shared/legacy-migration-history.ts index 5231def40a..03ff80ebc8 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-history.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-history.ts @@ -9,7 +9,7 @@ import { legacyParseMigrationVersion, } from "./legacy-migration-timestamp.format.ts"; import { LegacyMigrationsReadError } from "./legacy-migration.errors.ts"; -import { legacySplitAndTrim } from "./legacy-sql-split.ts"; +import { legacyParseMigrationContent } from "./legacy-migration-file.ts"; /** * Consolidated `supabase_migrations.schema_migrations` history module — the @@ -470,11 +470,12 @@ export const legacyReadMigrationFile = ( }), ), Effect.map((content) => { + const parsed = legacyParseMigrationContent(content); const match = MIGRATE_FILE_PATTERN.exec(path.basename(migrationPath)); return { version: match?.[1] ?? "", name: match?.[2] ?? "", - statements: legacySplitAndTrim(content), + statements: parsed.statements, }; }), ); diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta-next-flag.ts b/apps/cli/src/legacy/shared/legacy-pgdelta-next-flag.ts new file mode 100644 index 0000000000..39f4729dfc --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-pgdelta-next-flag.ts @@ -0,0 +1,22 @@ +export type LegacyPgDeltaImplementation = "next" | "legacy"; + +/** + * 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 new file mode 100644 index 0000000000..1768456b35 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-pgdelta-next-flag.unit.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { legacyResolvePgDeltaImplementation } from "./legacy-pgdelta-next-flag.ts"; + +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.cache.ts b/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts index ce1de27f3e..594918db1a 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta.cache.ts @@ -55,7 +55,7 @@ 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 to `setupInputsToken` — everything `start.SetupDatabase` consumes. */ +/** 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; diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta.integration.test.ts b/apps/cli/src/legacy/shared/legacy-pgdelta.integration.test.ts index 2028a30d92..123246afe9 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta.integration.test.ts @@ -213,6 +213,39 @@ describe("legacyDiffPgDelta", () => { 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", () => { diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta.ts b/apps/cli/src/legacy/shared/legacy-pgdelta.ts index 3d1657dd4c..0c14d827ae 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta.ts @@ -23,6 +23,7 @@ import { LegacyDeclarativeParseOutputError, LegacyPgDeltaDiffParseError, } from "../commands/db/shared/legacy-pgdelta.errors.ts"; +import type { LegacyPgDeltaTransactionMode } from "../commands/db/shared/legacy-pgdelta-engine.service.ts"; const PG_DELTA_NPM_REGISTRY_ENV = "PGDELTA_NPM_REGISTRY"; @@ -49,14 +50,18 @@ export interface LegacyDeclarativeOutput { interface LegacyPgDeltaPlanFile { readonly order: number; readonly name: string; - readonly transactionMode: string; + readonly transactionMode: LegacyPgDeltaTransactionMode; readonly sql: string; } /** The pg-delta diff envelope. Mirrors Go's `PgDeltaDiffOutput`. */ interface LegacyPgDeltaDiffOutput { readonly version: number; - readonly files: ReadonlyArray; + readonly files: ReadonlyArray< + Omit & { + readonly transactionMode: string; + } + >; } /** @@ -71,7 +76,7 @@ interface LegacyPgDeltaDiffResult { } /** - * Ambient inputs shared by every pg-delta invocation: the project id (for the + * 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). */ @@ -294,7 +299,19 @@ export const legacyDiffPgDelta = Effect.fnUntraced(function* ( }:\n${result.stderr}`, }), }); - const files = envelope.files ?? []; + 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"); diff --git a/apps/cli/src/shared/init/project-init.templates.ts b/apps/cli/src/shared/init/project-init.templates.ts index 2c6a823579..7acc2f30a0 100644 --- a/apps/cli/src/shared/init/project-init.templates.ts +++ b/apps/cli/src/shared/init/project-init.templates.ts @@ -412,6 +412,7 @@ enabled = true # declarative_schema_path = "./database" # JSON string passed through to pg-delta SQL formatting. # format_options = "{\\"keywordCase\\":\\"upper\\",\\"indent\\":2,\\"maxWidth\\":80,\\"commaStyle\\":\\"trailing\\"}" +# Set to "null" to disable formatting while retaining plan compaction. `; export const INIT_GITIGNORE_TEMPLATE = `# Supabase diff --git a/apps/cli/tests/fixtures/compiled-libpg-query.ts b/apps/cli/tests/fixtures/compiled-libpg-query.ts new file mode 100644 index 0000000000..f367f9e377 --- /dev/null +++ b/apps/cli/tests/fixtures/compiled-libpg-query.ts @@ -0,0 +1,21 @@ +import { validateSqlSyntax } from "@supabase/pg-topo"; +import "@supabase/pg-delta/core"; + +const embeddedParser = Bun.embeddedFiles.find((file) => file.type === "application/wasm"); + +if (!embeddedParser) { + throw new Error("libpg-query.wasm was not embedded in the executable"); +} + +const wasmBytes = new Uint8Array(await embeddedParser.arrayBuffer()); +if ( + wasmBytes[0] !== 0x00 || + wasmBytes[1] !== 0x61 || + wasmBytes[2] !== 0x73 || + wasmBytes[3] !== 0x6d +) { + throw new Error("the embedded libpg-query asset is not WebAssembly"); +} + +await validateSqlSyntax("select 1"); +console.log("libpg-query.wasm loaded"); diff --git a/apps/cli/tests/helpers/live.ts b/apps/cli/tests/helpers/live.ts index 89d8092ba3..327cceacb3 100644 --- a/apps/cli/tests/helpers/live.ts +++ b/apps/cli/tests/helpers/live.ts @@ -1,3 +1,4 @@ +import { execSync } from "node:child_process"; import { describe } from "vitest"; import { runSupabase } from "./cli.ts"; @@ -39,6 +40,23 @@ export { */ export const describeLive = describe.skipIf(!isLiveConfigured()); +function hasDockerDaemon(): boolean { + try { + execSync("docker info", { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +/** + * `describe` for local-stack live tests that only require a real Docker daemon. + * Unlike `describeLive`, this gate does not require platform credentials or a + * Management API. The synchronous `docker info` probe is read-only and runs once + * when this helper module is collected. + */ +export const describeDockerLive = describe.skipIf(!hasDockerDaemon()); + /** * `describe` for project-scoped live suites: runs only when the live env is * configured AND a project ref is available. On a control-plane-only stack diff --git a/patches/@libpg-query__parser@17.6.10.patch b/patches/@libpg-query__parser@17.6.10.patch new file mode 100644 index 0000000000..191d73ed04 --- /dev/null +++ b/patches/@libpg-query__parser@17.6.10.patch @@ -0,0 +1,17 @@ +diff --git a/wasm/index.js b/wasm/index.js +index 00caf4f1591549e445b97c5deeed95a9d8dabd8b..ce4a88226d12687644ca76805e08d11a6696b00e 100644 +--- a/wasm/index.js ++++ b/wasm/index.js +@@ -65,10 +65,11 @@ export function formatSqlError(error, query, options = {}) { + } + // @ts-ignore + import PgQueryModule from './libpg-query.js'; ++import libPgQueryWasmPath from './libpg-query.wasm' with { type: 'file' }; + // @ts-ignore + import { pg_query } from '../proto.js'; + let wasmModule; +-const initPromise = PgQueryModule().then((module) => { ++const initPromise = PgQueryModule({ locateFile: () => libPgQueryWasmPath }).then((module) => { + wasmModule = module; + }); + function ensureLoaded() { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4624e456dd..f965360f49 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,10 @@ catalogs: overrides: '@effect/platform-node-shared': 4.0.0-beta.103 + '@launchql/protobufjs>@types/node': 24.10.4 + +patchedDependencies: + '@libpg-query/parser@17.6.10': ed67c0ca88b6ced3ec50fd6862f191d6192a246cf20d9c777d45efdb8373bed3 importers: @@ -150,6 +154,12 @@ importers: '@supabase/config': specifier: workspace:* version: link:../../packages/config + '@supabase/pg-delta': + specifier: 1.0.0-alpha.34 + version: 1.0.0-alpha.34(@supabase/pg-topo@1.0.0-alpha.5) + '@supabase/pg-topo': + specifier: 1.0.0-alpha.5 + version: 1.0.0-alpha.5 '@supabase/process-compose': specifier: workspace:* version: link:../../packages/process-compose @@ -712,10 +722,18 @@ packages: resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.28.5': + resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.7': resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} engines: {node: '>=6.9.0'} + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} @@ -1192,6 +1210,13 @@ packages: '@keyv/serialize@1.1.1': resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + '@launchql/protobufjs@7.2.6': + resolution: {integrity: sha512-vwi1nG2/heVFsIMHQU1KxTjUp5c757CTtRAZn/jutApCkFlle1iv8tzM/DHlSZJKDldxaYqnNYTg0pTyp8Bbtg==} + engines: {node: '>=12.0.0'} + + '@libpg-query/parser@17.6.10': + resolution: {integrity: sha512-AT/IM9H24/u70HvBhzkYlSBlYQWhJK3Z4CTmTnd3PnMqHU7Ib3o5pk2TEik6IblWsU64D+4GGURn94v2iSRe1A==} + '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} @@ -2208,6 +2233,15 @@ packages: resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} engines: {node: '>= 10.0.0'} + '@pgsql/quotes@17.1.0': + resolution: {integrity: sha512-J/H+LcrENBpYgL45WW6aTjb5Yk4tX4+AmB2/k8KZa+Zh3wiCtqmNIag+HZz5HmWaF6EZK9ZGC95NBD1fs+rUvg==} + + '@pgsql/traverse@17.2.6': + resolution: {integrity: sha512-BLOE9DUcvd3y3Ogf56mmpTONPylnMuFCo9PvHQA9SXavcRPhRtvIZ/sRO2ja+bUWK/3KTLJ1Hb61CbbdPkcHoA==} + + '@pgsql/types@17.6.2': + resolution: {integrity: sha512-1UtbELdbqNdyOShhrVfSz3a1gDi0s9XXiQemx+6QqtsrXe62a6zOGU+vjb2GRfG5jeEokI1zBBcfD42enRv0Rw==} + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -2229,6 +2263,36 @@ packages: '@posthog/types@1.398.0': resolution: {integrity: sha512-sJMkl4k+u8yS/0fjHsKqE9xTdsAh30a2WvgChiptellnVoE0e8QJKFgqOMD2sk8FaEArPdeFklAhXvmENAt3Sg==} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.2': + resolution: {integrity: sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.2': + resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==} + '@radix-ui/number@1.1.3': resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==} @@ -2775,6 +2839,19 @@ packages: resolution: {integrity: sha512-RW/OCsd6MO592zU8ifzP8/f8XzxxIdpb+Up5XaOtE26Fw+3zTp475WX7+GuuktiD1WF8pFUDe6khUPbMp77RCw==} engines: {node: '>=22.0.0'} + '@supabase/pg-delta@1.0.0-alpha.34': + resolution: {integrity: sha512-xjNBdFl4/DXIxZufUK6t52wTywMS4sl1rcXvxTgh3mv1Q50tDeMPkjp8QM5YRjYo/mrdxZJ94kmbf6XAQn0jRg==} + engines: {node: '>=20.0.0'} + hasBin: true + peerDependencies: + '@supabase/pg-topo': ^1.0.0-alpha.3 + peerDependenciesMeta: + '@supabase/pg-topo': + optional: true + + '@supabase/pg-topo@1.0.0-alpha.5': + resolution: {integrity: sha512-a34YbUsQhBvS3Of5Gh/M4nXyGebwlID2lI7Od/YQPSA4jzGCR1D8EO/bV+kfAbTb84I/go8Pl1Y91XqFjRaqHg==} + '@supabase/phoenix@0.4.5': resolution: {integrity: sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==} @@ -2948,6 +3025,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@24.10.4': + resolution: {integrity: sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==} + '@types/node@26.1.1': resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} @@ -3602,6 +3682,10 @@ packages: caniuse-lite@1.0.30001806: resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + case@1.6.3: + resolution: {integrity: sha512-mzDSXIPaFwVDvZAHqZ9VlbyF4yyXRuX6IvB06WvPYkqJVO24kX1PPhv9bfpKNFZyxYFmmgo03HUiD8iklmJYRQ==} + engines: {node: '>= 0.8.0'} + caseless@0.12.0: resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} @@ -3899,6 +3983,10 @@ packages: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} @@ -5070,6 +5158,9 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -5444,6 +5535,9 @@ packages: nerf-dart@1.0.0: resolution: {integrity: sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==} + nested-obj@0.2.2: + resolution: {integrity: sha512-M1etu+T6Ai9Bo06L3K3nWD0ytZWltggBGsrxJlOGvMNGlCA4fokUVlbPKoWzsiiRX+PXq6Cb1xFEn4chiyC7MQ==} + next-themes@0.4.6: resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: @@ -5839,6 +5933,9 @@ packages: peerDependencies: pg: '>=8.0' + pg-proto-parser@1.30.6: + resolution: {integrity: sha512-2XwPyl9oz5Pest4ebaovRTTJN8MXaa/XvqMQzKq127fFcl4I1POUgV/FtzHzg/p8FjtO5yHsipeW/kAumzNxxw==} + pg-protocol@1.15.0: resolution: {integrity: sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==} @@ -5862,6 +5959,9 @@ packages: pgpass@1.0.5: resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + pgsql-deparser@17.18.5: + resolution: {integrity: sha512-C23etz+aWjp5d09SQwrByisCIV0Zy1dPI0IdBPBaRiMRrDQ2MH8O9txvqpxPyWiXEGRU+MMvZqk48UHxWWbODg==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -5906,6 +6006,12 @@ packages: resolution: {integrity: sha512-IkmRFE+Xk2xsT2Jikwd40eY2E9yRplA+0OHqeRBZql2Y1b/SY9XUK9wtEMrL9ZzdlYLKma5vNJPkCNx91ov+zg==} hasBin: true + plpgsql-deparser@0.7.13: + resolution: {integrity: sha512-vigoLMQL4NdMx4FjP6Q1IEIiThL+mt483ETFtcBoFJJOMxLg8h29k/NMi79XMahqDu3QvQiQnQ8JNK4hPC74Tw==} + + plpgsql-parser@0.5.16: + resolution: {integrity: sha512-zMHt7xLNW//88KzoKSDyhbDvQeEISzllZKYLl5VcpUlKy/v/EA2SnRkBQz2L6Rv+cOMNv/mzckOpyBUdUMjPdA==} + postcss@8.5.10: resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} engines: {node: ^10 || ^12 || >=14} @@ -6471,6 +6577,9 @@ packages: streamx@2.28.0: resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} + strfy-js@3.2.2: + resolution: {integrity: sha512-hUgJ5k2PR1ivhq4uObxnin5j6GcOr0Y0N1lzi3z6SRhxNqu4rzpDfyoC2ToUAyM8yXNXM0zs6f4KIiqj8NqheQ==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -6740,6 +6849,9 @@ packages: resolution: {integrity: sha512-60m9IVGbavD6jholbxt0jVBXZkEB/HsMZq7Tyaghseve2/Sf0zQRAIfWsD34sde+DKP2tBxJS2wP88ZM0D1FhA==} engines: {node: '>=14'} + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici-types@8.3.0: resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} @@ -7276,6 +7388,18 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 + '@babel/traverse@7.28.5': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + '@babel/traverse@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -7288,6 +7412,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -7669,6 +7798,26 @@ snapshots: '@keyv/serialize@1.1.1': {} + '@launchql/protobufjs@7.2.6': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.2 + '@types/node': 24.10.4 + long: 5.3.2 + + '@libpg-query/parser@17.6.10(patch_hash=ed67c0ca88b6ced3ec50fd6862f191d6192a246cf20d9c777d45efdb8373bed3)': + dependencies: + '@launchql/protobufjs': 7.2.6 + '@pgsql/types': 17.6.2 + '@mdx-js/mdx@3.1.1': dependencies: '@types/estree': 1.0.9 @@ -8352,6 +8501,17 @@ snapshots: '@parcel/watcher-win32-arm64': 2.6.0 '@parcel/watcher-win32-x64': 2.6.0 + '@pgsql/quotes@17.1.0': {} + + '@pgsql/traverse@17.2.6': + dependencies: + '@pgsql/types': 17.6.2 + pg-proto-parser: 1.30.6 + transitivePeerDependencies: + - supports-color + + '@pgsql/types@17.6.2': {} + '@pinojs/redact@0.4.0': {} '@pnpm/config.env-replace@1.1.0': {} @@ -8372,6 +8532,28 @@ snapshots: '@posthog/types@1.398.0': {} + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.2': {} + '@radix-ui/number@1.1.3': {} '@radix-ui/primitive@1.1.7': {} @@ -8909,6 +9091,24 @@ snapshots: dependencies: tslib: 2.8.1 + '@supabase/pg-delta@1.0.0-alpha.34(@supabase/pg-topo@1.0.0-alpha.5)': + dependencies: + debug: 4.4.3(supports-color@7.2.0) + pg: 8.22.0 + pg-connection-string: 2.14.0 + optionalDependencies: + '@supabase/pg-topo': 1.0.0-alpha.5 + transitivePeerDependencies: + - pg-native + - supports-color + + '@supabase/pg-topo@1.0.0-alpha.5': + dependencies: + '@pgsql/traverse': 17.2.6 + plpgsql-parser: 0.5.16 + transitivePeerDependencies: + - supports-color + '@supabase/phoenix@0.4.5': {} '@supabase/postgrest-js@2.111.0': @@ -9070,6 +9270,10 @@ snapshots: '@types/ms@2.1.0': {} + '@types/node@24.10.4': + dependencies: + undici-types: 7.16.0 + '@types/node@26.1.1': dependencies: undici-types: 8.3.0 @@ -9721,6 +9925,8 @@ snapshots: caniuse-lite@1.0.30001806: {} + case@1.6.3: {} + caseless@0.12.0: {} ccount@2.0.1: {} @@ -9983,6 +10189,8 @@ snapshots: deep-extend@0.6.0: {} + deepmerge@4.3.1: {} + defaults@1.0.4: dependencies: clone: 1.0.4 @@ -11319,6 +11527,8 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + long@5.3.2: {} + longest-streak@3.1.0: {} lowdb@1.0.0: @@ -11920,6 +12130,8 @@ snapshots: nerf-dart@1.0.0: {} + nested-obj@0.2.2: {} + next-themes@0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: react: 19.2.8 @@ -12410,6 +12622,20 @@ snapshots: dependencies: pg: 8.22.0 + pg-proto-parser@1.30.6: + dependencies: + '@babel/generator': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + '@launchql/protobufjs': 7.2.6 + case: 1.6.3 + deepmerge: 4.3.1 + nested-obj: 0.2.2 + strfy-js: 3.2.2 + transitivePeerDependencies: + - supports-color + pg-protocol@1.15.0: {} pg-types@2.2.0: @@ -12444,6 +12670,11 @@ snapshots: dependencies: split2: 4.2.0 + pgsql-deparser@17.18.5: + dependencies: + '@pgsql/quotes': 17.1.0 + '@pgsql/types': 17.6.2 + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -12488,6 +12719,21 @@ snapshots: pkg-pr-new@0.0.82: {} + plpgsql-deparser@0.7.13: + dependencies: + '@pgsql/types': 17.6.2 + pgsql-deparser: 17.18.5 + + plpgsql-parser@0.5.16: + dependencies: + '@libpg-query/parser': 17.6.10(patch_hash=ed67c0ca88b6ced3ec50fd6862f191d6192a246cf20d9c777d45efdb8373bed3) + '@pgsql/traverse': 17.2.6 + '@pgsql/types': 17.6.2 + pgsql-deparser: 17.18.5 + plpgsql-deparser: 0.7.13 + transitivePeerDependencies: + - supports-color + postcss@8.5.10: dependencies: nanoid: 3.3.16 @@ -13204,6 +13450,10 @@ snapshots: - bare-abort-controller - react-native-b4a + strfy-js@3.2.2: + dependencies: + minimatch: 10.2.5 + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -13467,6 +13717,8 @@ snapshots: unbash@4.0.4: {} + undici-types@7.16.0: {} + undici-types@8.3.0: {} undici@6.28.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9a8ff42897..1a70d9f196 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,7 @@ packages: allowBuilds: '@parcel/watcher': true + '@launchql/protobufjs': true "@swc/core": true esbuild: true msgpackr-extract: true @@ -37,6 +38,8 @@ blockExoticSubdeps: true overrides: "@effect/platform-node-shared": "4.0.0-beta.103" + # pg-topo's parser chain otherwise resolves bleeding-edge Node globals that conflict with Bun's web types. + "@launchql/protobufjs>@types/node": "24.10.4" minimumReleaseAge: 10200 minimumReleaseAgeExclude: @@ -46,6 +49,8 @@ minimumReleaseAgeExclude: - "@effect/platform-node-shared@4.0.0-beta.103" - "@effect/sql-pg@4.0.0-beta.103" - "@effect/vitest@4.0.0-beta.103" + - "@supabase/pg-delta@1.0.0-alpha.34" + - "@supabase/pg-topo@1.0.0-alpha.5" - "effect@4.0.0-beta.103" supportedArchitectures: @@ -62,3 +67,6 @@ supportedArchitectures: - darwin - linux - win32 + +patchedDependencies: + '@libpg-query/parser@17.6.10': patches/@libpg-query__parser@17.6.10.patch