diff --git a/apps/cli-go/cmd/db_schema_declarative.go b/apps/cli-go/cmd/db_schema_declarative.go index bcc689ca0b..eec16f59a8 100644 --- a/apps/cli-go/cmd/db_schema_declarative.go +++ b/apps/cli-go/cmd/db_schema_declarative.go @@ -19,6 +19,7 @@ import ( "github.com/supabase/cli/internal/db/reset" "github.com/supabase/cli/internal/db/start" "github.com/supabase/cli/internal/migration/new" + "github.com/supabase/cli/internal/pgdelta" "github.com/supabase/cli/internal/utils" "github.com/supabase/cli/internal/utils/flags" "github.com/supabase/cli/pkg/config" @@ -107,6 +108,14 @@ var ( RunE: runDeclarativeSync, } + // dbDeclarativeApplyCmd applies declarative files directly to the local database + // without creating a timestamped migration. + dbDeclarativeApplyCmd = &cobra.Command{ + Use: "apply", + Short: "Apply declarative schema to the local database", + RunE: runDeclarativeApply, + } + // dbDeclarativeGenerateCmd generates declarative files directly from a live // database target. This is the entrypoint for bootstrapping declarative mode. dbDeclarativeGenerateCmd = &cobra.Command{ @@ -236,6 +245,22 @@ func configureLocalDbConfig() { } } +func runDeclarativeApply(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + fsys := afero.NewOsFs() + + if !hasDeclarativeFiles(fsys) { + return fmt.Errorf("no declarative schema found. Run %s first", utils.Aqua("supabase db schema declarative generate")) + } + if err := ensureLocalDatabaseStarted(ctx, true, utils.AssertSupabaseDbIsRunning, func(ctx context.Context) error { + return start.Run(ctx, "", fsys) + }); err != nil { + return err + } + configureLocalDbConfig() + return pgdelta.ApplyDeclarative(ctx, flags.DbConfig, fsys) +} + // runDeclarativeGenerate implements the smart interactive generate flow. func runDeclarativeGenerate(cmd *cobra.Command, args []string) error { ctx := cmd.Context() @@ -571,6 +596,7 @@ func init() { generateFlags.StringVarP(&dbPassword, "password", "p", "", "Password to your remote Postgres database.") cobra.CheckErr(viper.BindPFlag("DB_PASSWORD", generateFlags.Lookup("password"))) + dbDeclarativeCmd.AddCommand(dbDeclarativeApplyCmd) dbDeclarativeCmd.AddCommand(dbDeclarativeSyncCmd) dbDeclarativeCmd.AddCommand(dbDeclarativeGenerateCmd) dbSchemaCmd.AddCommand(dbDeclarativeCmd) diff --git a/apps/cli-go/cmd/db_schema_declarative_test.go b/apps/cli-go/cmd/db_schema_declarative_test.go index 738e204111..45483f36c3 100644 --- a/apps/cli-go/cmd/db_schema_declarative_test.go +++ b/apps/cli-go/cmd/db_schema_declarative_test.go @@ -292,6 +292,14 @@ func TestHasMigrationFiles(t *testing.T) { }) } +func TestDeclarativeApplyCommandRegistered(t *testing.T) { + cmd, _, err := dbDeclarativeCmd.Find([]string{"apply"}) + + require.NoError(t, err) + require.NotNil(t, cmd) + assert.Equal(t, "apply", cmd.Name()) +} + func TestSaveApplyDebugBundle(t *testing.T) { t.Run("saves debug artifacts with expected content", func(t *testing.T) { fsys := afero.NewMemMapFs() diff --git a/apps/cli-go/docs/supabase/db/schema-declarative-apply.md b/apps/cli-go/docs/supabase/db/schema-declarative-apply.md new file mode 100644 index 0000000000..a20747d049 --- /dev/null +++ b/apps/cli-go/docs/supabase/db/schema-declarative-apply.md @@ -0,0 +1,7 @@ +## supabase-db-schema-declarative-apply + +Apply declarative schema files directly to the local database. + +Reads SQL files from the configured declarative schema directory and applies them to the local database using pg-delta without creating a timestamped migration. This is intended for local or CI bootstrapping, not as a replacement for migrations in controlled schema evolution. + +Requires `--experimental` flag or `[experimental.pgdelta] enabled = true` in config. diff --git a/apps/cli-go/internal/pgdelta/apply.go b/apps/cli-go/internal/pgdelta/apply.go index 22fca756a6..fe7ba69b4b 100644 --- a/apps/cli-go/internal/pgdelta/apply.go +++ b/apps/cli-go/internal/pgdelta/apply.go @@ -323,8 +323,9 @@ func ApplyDeclarative(ctx context.Context, config pgconn.Config, fsys afero.Fs) fmt.Fprintln(os.Stderr, "Applying declarative schemas via pg-delta...") var stdout, stderr bytes.Buffer script := pkgconfig.InterpolatePgDeltaScript(pkgconfig.Config(&utils.Config), pgDeltaDeclarativeApplyScript) - if err := utils.RunEdgeRuntimeScript(ctx, env, script, binds, "error running pg-delta script", &stdout, &stderr, utils.PgDeltaNpmRegistryOption()); err != nil { - return err + runErr := utils.RunEdgeRuntimeScript(ctx, env, script, binds, "error running pg-delta script", &stdout, &stderr, utils.PgDeltaNpmRegistryOption()) + if runErr != nil && len(bytes.TrimSpace(stdout.Bytes())) == 0 { + return runErr } var result ApplyResult @@ -349,6 +350,9 @@ func ApplyDeclarative(ctx context.Context, config pgconn.Config, fsys afero.Fs) } return errors.Errorf("pg-delta declarative apply failed with status: %s", result.Status) } + if runErr != nil { + return runErr + } fmt.Fprintf(os.Stderr, "Applied %d statements in %d round(s).\n", result.TotalApplied, result.TotalRounds) return nil } diff --git a/apps/cli-go/internal/pgdelta/apply_test.go b/apps/cli-go/internal/pgdelta/apply_test.go index bef780269e..acefa523dd 100644 --- a/apps/cli-go/internal/pgdelta/apply_test.go +++ b/apps/cli-go/internal/pgdelta/apply_test.go @@ -87,6 +87,12 @@ func TestFormatApplyFailure(t *testing.T) { assertContains(t, formatted, `SQL: CREATE EXTENSION pgmq WITH SCHEMA pgmq;`) } +func TestDeclarativeApplyTemplateDoesNotThrowForStructuredNonSuccess(t *testing.T) { + assertContains(t, pgDeltaDeclarativeApplyScript, "console.log(JSON.stringify(payload));") + assertNotContains(t, pgDeltaDeclarativeApplyScript, `apply.status !== "success"`) + assertNotContains(t, pgDeltaDeclarativeApplyScript, "pg-delta apply failed with status") +} + // TestApplyResultUnmarshalValidationErrors reproduces the payload shape pg-delta // emits when the final check_function_bodies=on pass fails: totalApplied // matches totalStatements, errors and stuckStatements are empty, but status is diff --git a/apps/cli-go/internal/pgdelta/templates/pgdelta_declarative_apply.ts b/apps/cli-go/internal/pgdelta/templates/pgdelta_declarative_apply.ts index a6589bf2b0..66fd190ead 100644 --- a/apps/cli-go/internal/pgdelta/templates/pgdelta_declarative_apply.ts +++ b/apps/cli-go/internal/pgdelta/templates/pgdelta_declarative_apply.ts @@ -45,9 +45,6 @@ try { diagnostics: result.diagnostics ?? [], }; console.log(JSON.stringify(payload)); - if (apply.status !== "success") { - throw new Error("pg-delta apply failed with status: " + apply.status); - } } } catch (e) { throw e instanceof Error ? e : new Error(String(e)); diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 9b22759d95..859bbd787e 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -316,6 +316,7 @@ Legend: | `db remote commit` | `wrapped` | [`../src/legacy/commands/db/remote/commit/commit.command.ts`](../src/legacy/commands/db/remote/commit/commit.command.ts) | | `db schema declarative sync` | `ported` | [`../src/legacy/commands/db/schema/declarative/sync/sync.command.ts`](../src/legacy/commands/db/schema/declarative/sync/sync.command.ts) | | `db schema declarative generate` | `ported` | [`../src/legacy/commands/db/schema/declarative/generate/generate.command.ts`](../src/legacy/commands/db/schema/declarative/generate/generate.command.ts) | +| `db schema declarative apply` | `ported` | [`../src/legacy/commands/db/schema/declarative/apply/apply.command.ts`](../src/legacy/commands/db/schema/declarative/apply/apply.command.ts) — native pg-delta direct local apply | Flag divergences from the Go reference: diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/apply/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/schema/declarative/apply/SIDE_EFFECTS.md new file mode 100644 index 0000000000..d778de9436 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/schema/declarative/apply/SIDE_EFFECTS.md @@ -0,0 +1,79 @@ +# `supabase db schema declarative apply` + +Applies existing declarative schema files directly to the local database using +pg-delta. It does not create a timestamped migration file and does not update +local migration history. + +## Files Read + +| Path | Format | When | +| --------------------------------------------------------------------------------------------------------------------------- | ---------- | -------------------------------------------------- | +| `/supabase/config.toml` | TOML | always — pg-delta gate, local DB port/password | +| `/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; configurable via `[experimental.pgdelta] declarative_schema_path`) | SQL | always — must exist and is mounted read-only | + +## Files Written + +| Path | Format | When | +| ---------------------------- | ------ | ----------------------------------------------- | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | + +This command does **not** write `supabase/migrations/*.sql` and does **not** +update migration history. + +## Subprocesses / Containers + +| Process | Condition | +| --------------------------------------------------------------------------------------------------- | ------------------------------------------------ | +| `supabase-go db start` via the declarative seam | when the local database container is not running | +| Edge-runtime container (`supabase/edge-runtime`) running the pg-delta declarative-apply Deno script | always after validation | + +## API Routes + +None. + +## 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 (Go `GetHostname`) | no | +| `DOCKER_HOST` | tcp daemon host used as the local DB host backup | no | + +## Exit Codes + +| Exit | Meaning | +| ---- | ------------------------------------------------------------------------------------------------- | +| `0` | declarative schema applied successfully | +| `1` | pg-delta disabled; no declarative files found; local database start failed; pg-delta apply failed | + +## Telemetry Events Fired + +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +## Output + +Text mode only; the command has no command-specific machine envelope. Global +`--output-format json` / `stream-json` error handling still emits the standard +wrapper error format on failures, but successful progress output remains stderr +text. + +### `--output-format text` (Go CLI compatible) + +Progress output is written to stderr: + +- `Applying declarative schemas via pg-delta...` +- `Applied statements in round(s).` + +Apply failures include pg-delta's structured status summary before returning an +error. + +### `--output-format json` / `stream-json` + +No success payload is emitted. Successful output remains the stderr text above. +On failure, the shared output wrapper emits its normal JSON / stream-json error. diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/apply/apply.command.ts b/apps/cli/src/legacy/commands/db/schema/declarative/apply/apply.command.ts new file mode 100644 index 0000000000..df5615baf0 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/schema/declarative/apply/apply.command.ts @@ -0,0 +1,33 @@ +import { Effect } from "effect"; +import { Command } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyDbSchemaDeclarativeSharedBase } from "../declarative.shared.ts"; +import { legacyDbSchemaDeclarativeApply } from "./apply.handler.ts"; +import { legacyDbSchemaDeclarativeApplyRuntimeLayer } from "./apply.layers.ts"; + +const config = {} as const; + +export type LegacyDbSchemaDeclarativeApplyFlags = CliCommand.Command.Config.Infer & { + readonly noCache: boolean; +}; + +export const legacyDbSchemaDeclarativeApplyCommand = Command.make("apply", config).pipe( + Command.withDescription("Apply declarative schema to the local database."), + Command.withShortDescription("Apply declarative schema to the local database"), + Command.withHandler((flags) => + Effect.gen(function* () { + const shared = yield* legacyDbSchemaDeclarativeSharedBase; + const merged: LegacyDbSchemaDeclarativeApplyFlags = { ...flags, noCache: shared.noCache }; + return yield* legacyDbSchemaDeclarativeApply(merged).pipe( + withLegacyCommandInstrumentation({ + flags: { "no-cache": merged.noCache }, + }), + withJsonErrorHandling, + ); + }), + ), + Command.provide(legacyDbSchemaDeclarativeApplyRuntimeLayer), +); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/apply/apply.handler.ts b/apps/cli/src/legacy/commands/db/schema/declarative/apply/apply.handler.ts new file mode 100644 index 0000000000..6fb686fa52 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/schema/declarative/apply/apply.handler.ts @@ -0,0 +1,303 @@ +import { Effect, FileSystem, Option, Path } from "effect"; + +import { + legacyResolveDebug, + legacyResolveExperimental, +} from "../../../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../../../shared/output/output.service.ts"; +import { LegacyCliConfig } from "../../../../../config/legacy-cli-config.service.ts"; +import { legacyGetHostname } from "../../../../../shared/legacy-hostname.ts"; +import { + legacyReadDbToml, + legacyResolveDeclarativeDir, +} from "../../../../../shared/legacy-db-config.toml-read.ts"; +import { legacyToPostgresURL } from "../../../../../shared/legacy-postgres-url.ts"; +import { LegacyTelemetryState } from "../../../../../telemetry/legacy-telemetry-state.service.ts"; +import { legacyApplyDeclarativePgDelta } from "../../../shared/legacy-pgdelta.ts"; +import { LegacyDeclarativeSeam } from "../../../shared/legacy-pgdelta.seam.service.ts"; +import { + LegacyDeclarativeApplyError, + LegacyDeclarativeNonInteractiveError, +} from "../declarative.errors.ts"; +import { legacyRequirePgDelta } from "../declarative.gate.ts"; +import type { LegacyDbSchemaDeclarativeApplyFlags } from "./apply.command.ts"; + +export const legacyDbSchemaDeclarativeApply = Effect.fn("legacy.db.schema.declarative.apply")( + function* (_flags: LegacyDbSchemaDeclarativeApplyFlags) { + const output = yield* Output; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cliConfig = yield* LegacyCliConfig; + const telemetryState = yield* LegacyTelemetryState; + const experimental = yield* legacyResolveExperimental; + const debug = yield* legacyResolveDebug; + const seam = yield* LegacyDeclarativeSeam; + + yield* Effect.gen(function* () { + const toml = yield* legacyReadDbToml(fs, path, cliConfig.workdir); + yield* legacyRequirePgDelta({ + experimental, + pgDeltaEnabled: toml.pgDelta.enabled, + configPath: path.join("supabase", "config.toml"), + }); + + const declarativeDir = path.resolve( + cliConfig.workdir, + legacyResolveDeclarativeDir(path, toml.pgDelta), + ); + if (!(yield* declarativeDirHasFiles(fs, declarativeDir))) { + return yield* Effect.fail( + new LegacyDeclarativeNonInteractiveError({ + message: + "no declarative schema found. Run supabase db schema declarative generate first", + }), + ); + } + + yield* seam.ensureLocalDatabaseStarted(); + + yield* output.raw("Applying declarative schemas via pg-delta...\n", "stderr"); + const result = yield* legacyApplyDeclarativePgDelta( + { + projectId: Option.getOrElse(cliConfig.projectId, () => ""), + cwd: cliConfig.workdir, + npmVersion: Option.getOrUndefined(toml.pgDelta.npmVersion), + denoVersion: toml.denoVersion, + }, + { + declarativeDir, + targetRef: legacyToPostgresURL({ + host: legacyGetHostname(), + port: toml.port, + user: "postgres", + password: toml.password, + database: "postgres", + }), + }, + ); + + if (result.status !== "success") { + yield* output.raw(`${formatApplyFailure(result, debug)}\n`, "stderr"); + if (debug) { + const debugJson = formatDebugJSON(result.raw); + if (debugJson.length > 0) { + yield* output.raw("pg-delta apply result:\n", "stderr"); + yield* output.raw(`${debugJson}\n`, "stderr"); + } + } + return yield* Effect.fail( + new LegacyDeclarativeApplyError({ + message: `pg-delta declarative apply failed with status: ${result.status}`, + }), + ); + } + + yield* output.raw( + `Applied ${result.totalApplied} statements in ${result.totalRounds} round(s).\n`, + "stderr", + ); + if (output.format === "json" || output.format === "stream-json") { + yield* output.success("Declarative schema applied.", { + status: result.status, + totalStatements: result.totalStatements, + totalRounds: result.totalRounds, + totalApplied: result.totalApplied, + totalSkipped: result.totalSkipped, + }); + } + }).pipe(Effect.ensuring(telemetryState.flush)); + }, +); + +const declarativeDirHasFiles = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + dir: string, +) { + const exists = yield* fs.exists(dir).pipe(Effect.orElseSucceed(() => false)); + if (!exists) return false; + const entries = yield* fs.readDirectory(dir).pipe(Effect.orElseSucceed((): string[] => [])); + return entries.length > 0; +}); + +function formatApplyFailure( + result: { + readonly status: string; + readonly totalStatements: number; + readonly totalRounds: number; + readonly totalApplied: number; + readonly totalSkipped: number; + readonly errors: ReadonlyArray; + readonly stuckStatements: ReadonlyArray; + readonly validationErrors: ReadonlyArray; + readonly diagnostics: ReadonlyArray; + readonly raw: string; + }, + verbose: boolean, +): string { + const totalStatements = + result.totalStatements === 0 + ? result.totalApplied + result.totalSkipped + result.stuckStatements.length + : result.totalStatements; + const lines = [ + `pg-delta apply returned status ${JSON.stringify(result.status)}.`, + `${result.totalApplied}/${totalStatements} statements applied in ${result.totalRounds} round(s); ${result.totalSkipped} skipped.`, + ]; + appendIssues(lines, "Errors:", result.errors); + appendIssues(lines, "Stuck statements:", result.stuckStatements); + appendIssues( + lines, + "Validation errors (from check_function_bodies=on pass):", + result.validationErrors, + ); + if (result.diagnostics.length > 0) { + if (verbose) { + lines.push("Diagnostics:"); + for (const diagnostic of result.diagnostics) { + lines.push(formatApplyDiagnosis(diagnostic)); + } + } else { + lines.push( + `${result.diagnostics.length} pg-topo diagnostic(s) omitted (re-run with --debug to view).`, + ); + } + } + if ( + result.errors.length === 0 && + result.stuckStatements.length === 0 && + result.validationErrors.length === 0 + ) { + lines.push( + "No per-statement diagnostics were reported by pg-delta.", + "Re-run with --debug to print the raw pg-delta payload, or open an issue at", + "https://github.com/supabase/pg-toolbelt/issues with the debug bundle attached.", + ); + } + return lines.join("\n"); +} + +function appendIssues(lines: string[], title: string, issues: ReadonlyArray): void { + if (issues.length === 0) return; + lines.push(title); + for (const issue of issues) { + lines.push(formatApplyIssue(issue)); + } +} + +function formatApplyIssue(issue: unknown): string { + if (typeof issue === "string") return `- ${issue}`; + const statement = objectProperty(issue, "statement"); + if (statement === undefined || statement === null) { + return `- ${formatApplyIssueMessage(issue)}`; + } + const id = stringProperty(statement, "id"); + let title = `- ${id.length > 0 ? id : "unknown statement"}`; + const statementClass = stringProperty(statement, "statementClass"); + if (statementClass.length > 0) { + title += ` [${statementClass}]`; + } + const lines = [title, ` ${formatApplyIssueMessage(issue)}`]; + const detail = stringProperty(issue, "detail").trim(); + if (detail.length > 0) { + lines.push(` Detail: ${detail}`); + } + const hint = stringProperty(issue, "hint").trim(); + if (hint.length > 0) { + lines.push(` Hint: ${hint}`); + } + const sql = formatStatementSQL(stringProperty(statement, "sql")); + if (sql.length > 0) { + lines.push(` SQL: ${sql}`); + } + return lines.join("\n"); +} + +function formatApplyIssueMessage(issue: unknown): string { + let message = stringProperty(issue, "message").trim(); + if (message.length === 0) { + message = typeof issue === "string" ? issue : "unknown pg-delta issue"; + } + const metadata = []; + const code = stringProperty(issue, "code"); + if (code.length > 0) { + metadata.push(`SQLSTATE ${code}`); + } + const position = numberProperty(issue, "position"); + if (position > 0) { + metadata.push(`position ${position}`); + } + if (booleanProperty(issue, "isDependencyError")) { + metadata.push("dependency error"); + } + if (metadata.length === 0) return message; + return `${message} (${metadata.join(", ")})`; +} + +function formatApplyDiagnosis(diagnostic: unknown): string { + let message = stringProperty(diagnostic, "message").trim(); + if (message.length === 0) { + message = "unknown pg-delta diagnostic"; + } + let line = "- "; + const code = stringProperty(diagnostic, "code").trim(); + if (code.length > 0) { + line += `[${code}] `; + } + line += message; + const loc = formatStatementLocation(objectProperty(diagnostic, "statementId")); + if (loc.length > 0) { + line += ` (${loc})`; + } + const suggestedFix = stringProperty(diagnostic, "suggestedFix").trim(); + if (suggestedFix.length > 0) { + line += `\n Suggested fix: ${suggestedFix}`; + } + return line; +} + +function formatStatementLocation(location: unknown): string { + if (typeof location === "string") return location.trim(); + const filePath = stringProperty(location, "filePath").trim(); + if (filePath.length === 0) return ""; + const statementIndex = numberProperty(location, "statementIndex"); + if (statementIndex > 0) { + return `${filePath}#${statementIndex}`; + } + return filePath; +} + +function formatStatementSQL(sql: string): string { + const normalized = sql.split(/\s+/).filter(Boolean).join(" "); + const maxLen = 120; + if (normalized.length <= maxLen) return normalized; + return `${normalized.slice(0, maxLen - 3)}...`; +} + +function formatDebugJSON(raw: string): string { + const trimmed = raw.trim(); + if (trimmed.length === 0) return ""; + try { + return JSON.stringify(JSON.parse(trimmed), undefined, 2); + } catch { + return trimmed; + } +} + +function objectProperty(value: unknown, key: string): unknown { + if (typeof value !== "object" || value === null) return undefined; + return Object.entries(value).find(([entryKey]) => entryKey === key)?.[1]; +} + +function stringProperty(value: unknown, key: string): string { + const property = objectProperty(value, key); + return typeof property === "string" ? property : ""; +} + +function numberProperty(value: unknown, key: string): number { + const property = objectProperty(value, key); + return typeof property === "number" ? property : 0; +} + +function booleanProperty(value: unknown, key: string): boolean { + const property = objectProperty(value, key); + return property === true; +} diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/apply/apply.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/apply/apply.integration.test.ts new file mode 100644 index 0000000000..cfca4b9133 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/schema/declarative/apply/apply.integration.test.ts @@ -0,0 +1,271 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; + +import { mockOutput } from "../../../../../../../tests/helpers/mocks.ts"; +import { + mockLegacyCliConfig, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../../../tests/helpers/legacy-mocks.ts"; +import { + LegacyDebugFlag, + LegacyExperimentalFlag, +} from "../../../../../../shared/legacy/global-flags.ts"; +import { + type LegacyEdgeRuntimeRunOpts, + LegacyEdgeRuntimeScript, +} from "../../../../../shared/legacy-edge-runtime-script.service.ts"; +import { LegacyDeclarativeSeam } from "../../../shared/legacy-pgdelta.seam.service.ts"; +import type { LegacyDbSchemaDeclarativeApplyFlags } from "./apply.command.ts"; +import { legacyDbSchemaDeclarativeApply } from "./apply.handler.ts"; + +const APPLY_SUCCESS_JSON = JSON.stringify({ + status: "success", + totalStatements: 2, + totalRounds: 1, + totalApplied: 2, + totalSkipped: 0, + errors: [], + stuckStatements: [], + validationErrors: [], + diagnostics: [], +}); + +const APPLY_ERROR_JSON = JSON.stringify({ + status: "error", + totalStatements: 2, + totalRounds: 1, + totalApplied: 1, + totalSkipped: 0, + errors: [ + { + statement: { + id: "schemas/public/functions/create_device.sql:0", + sql: "CREATE FUNCTION public.create_device() RETURNS void LANGUAGE plpgsql AS $$ BEGIN Invalid sql statement; END; $$;", + statementClass: "CREATE_FUNCTION", + }, + code: "42601", + message: 'syntax error at or near "Invalid"', + position: 72, + detail: "The statement body is not valid SQL.", + hint: "Check the function body.", + }, + ], + stuckStatements: [], + validationErrors: [], + diagnostics: [ + { + code: "UNRESOLVED_DEPENDENCY", + message: "No producer found for function.", + statementId: { + filePath: "schemas/public/functions/create_device.sql", + statementIndex: 0, + sourceOffset: 0, + }, + suggestedFix: "Add the missing function first.", + }, + ], +}); + +interface SetupOpts { + experimental?: boolean; + debug?: boolean; + format?: "text" | "json" | "stream-json"; + applyJson?: string; +} + +function setup(workdir: string, opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format }); + const telemetry = mockLegacyTelemetryStateTracked(); + const edgeCalls: LegacyEdgeRuntimeRunOpts[] = []; + const edge = Layer.succeed(LegacyEdgeRuntimeScript, { + run: (runOpts: LegacyEdgeRuntimeRunOpts) => + Effect.sync(() => { + edgeCalls.push(runOpts); + return { stdout: opts.applyJson ?? APPLY_SUCCESS_JSON, stderr: "" }; + }), + }); + let ensureStartedCalls = 0; + const seam = Layer.succeed(LegacyDeclarativeSeam, { + exportCatalog: () => Effect.die("exportCatalog not used in declarative apply tests"), + execInherit: () => Effect.die("execInherit not used in declarative apply tests"), + ensureLocalDatabaseStarted: () => + Effect.sync(() => { + ensureStartedCalls += 1; + }), + ensureLocalPostgresImageCurrent: () => Effect.void, + provisionShadow: () => Effect.die("provisionShadow not used in declarative apply tests"), + removeShadowContainer: () => Effect.void, + }); + const layer = Layer.mergeAll( + out.layer, + edge, + seam, + telemetry.layer, + mockLegacyCliConfig({ workdir, projectId: Option.some("test") }), + Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? true), + Layer.succeed(LegacyDebugFlag, opts.debug ?? false), + BunServices.layer, + ); + return { + layer, + out, + telemetry, + edgeCalls, + get ensureStartedCalls() { + return ensureStartedCalls; + }, + }; +} + +const flags = ( + over: Partial = {}, +): LegacyDbSchemaDeclarativeApplyFlags => ({ + noCache: over.noCache ?? false, +}); + +const seedDeclarative = (workdir: string) => { + const dir = join(workdir, "supabase", "database"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "public.sql"), "create table players ();"); +}; + +const failError = (exit: Exit.Exit) => + Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isFailReason)?.error : undefined; + +describe("legacy db schema declarative apply integration", () => { + const tmp = useLegacyTempWorkdir(); + + it.effect("gate: fails when pg-delta is not enabled", () => { + seedDeclarative(tmp.current); + const { layer } = setup(tmp.current, { experimental: false }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDbSchemaDeclarativeApply(flags())); + expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeNotEnabledError"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("gate: honors SUPABASE_EXPERIMENTAL", () => { + seedDeclarative(tmp.current); + const s = setup(tmp.current, { experimental: false }); + const previous = process.env["SUPABASE_EXPERIMENTAL"]; + process.env["SUPABASE_EXPERIMENTAL"] = "true"; + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeApply(flags()); + expect(s.ensureStartedCalls).toBe(1); + }).pipe( + Effect.provide(s.layer), + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) { + delete process.env["SUPABASE_EXPERIMENTAL"]; + } else { + process.env["SUPABASE_EXPERIMENTAL"] = previous; + } + }), + ), + ); + }); + + it.effect("fails when there are no declarative files", () => { + const { layer } = setup(tmp.current); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDbSchemaDeclarativeApply(flags())); + expect(Exit.isFailure(exit)).toBe(true); + expect((failError(exit) as { message: string }).message).toContain( + "no declarative schema found", + ); + }).pipe(Effect.provide(layer)); + }); + + it.effect("applies declarative files directly to the local database", () => { + seedDeclarative(tmp.current); + const s = setup(tmp.current); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeApply(flags()); + + expect(s.ensureStartedCalls).toBe(1); + expect(s.edgeCalls).toHaveLength(1); + const call = s.edgeCalls[0]!; + expect(call.env["SCHEMA_PATH"]).toBe("/declarative"); + expect(call.env["TARGET"]).toContain("postgresql://postgres:postgres@127.0.0.1:54322"); + expect(call.binds).toContain(`${join(tmp.current, "supabase", "database")}:/declarative:ro`); + expect(existsSync(join(tmp.current, "supabase", "migrations"))).toBe(false); + expect(s.out.stderrText).toContain("Applying declarative schemas via pg-delta"); + expect(s.out.stderrText).toContain("Applied 2 statements in 1 round(s)."); + expect(s.telemetry.flushed).toBe(true); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("emits structured success for JSON output modes", () => { + seedDeclarative(tmp.current); + const s = setup(tmp.current, { format: "json" }); + return Effect.gen(function* () { + yield* legacyDbSchemaDeclarativeApply(flags()); + + expect(s.out.messages).toContainEqual({ + type: "success", + message: "Declarative schema applied.", + data: { + status: "success", + totalStatements: 2, + totalRounds: 1, + totalApplied: 2, + totalSkipped: 0, + }, + }); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("surfaces unsuccessful pg-delta apply results", () => { + seedDeclarative(tmp.current); + const s = setup(tmp.current, { applyJson: APPLY_ERROR_JSON }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDbSchemaDeclarativeApply(flags())); + expect(Exit.isFailure(exit)).toBe(true); + expect(failError(exit)).toMatchObject({ + _tag: "LegacyDeclarativeApplyError", + message: "pg-delta declarative apply failed with status: error", + }); + expect(s.out.stderrText).toContain('pg-delta apply returned status "error"'); + expect(s.out.stderrText).toContain("schemas/public/functions/create_device.sql:0"); + expect(s.out.stderrText).toContain( + 'syntax error at or near "Invalid" (SQLSTATE 42601, position 72)', + ); + expect(s.out.stderrText).toContain("Detail: The statement body is not valid SQL."); + expect(s.out.stderrText).toContain("Hint: Check the function body."); + expect(s.out.stderrText).toContain("1 pg-topo diagnostic(s) omitted"); + expect(s.telemetry.flushed).toBe(true); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("prints raw pg-delta payload and diagnostics when SUPABASE_DEBUG is enabled", () => { + seedDeclarative(tmp.current); + const s = setup(tmp.current, { applyJson: APPLY_ERROR_JSON }); + const previous = process.env["SUPABASE_DEBUG"]; + process.env["SUPABASE_DEBUG"] = "true"; + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyDbSchemaDeclarativeApply(flags())); + expect(Exit.isFailure(exit)).toBe(true); + expect(s.out.stderrText).toContain("Diagnostics:"); + expect(s.out.stderrText).toContain("[UNRESOLVED_DEPENDENCY] No producer found for function."); + expect(s.out.stderrText).toContain("Suggested fix: Add the missing function first."); + expect(s.out.stderrText).toContain("pg-delta apply result:"); + expect(s.out.stderrText).toContain('"status": "error"'); + }).pipe( + Effect.provide(s.layer), + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) { + delete process.env["SUPABASE_DEBUG"]; + } else { + process.env["SUPABASE_DEBUG"] = previous; + } + }), + ), + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/apply/apply.layers.ts b/apps/cli/src/legacy/commands/db/schema/declarative/apply/apply.layers.ts new file mode 100644 index 0000000000..1083116da5 --- /dev/null +++ b/apps/cli/src/legacy/commands/db/schema/declarative/apply/apply.layers.ts @@ -0,0 +1,28 @@ +import { Layer } from "effect"; + +import { commandRuntimeLayer } from "../../../../../../shared/runtime/command-runtime.layer.ts"; +import { legacyCliConfigLayer } from "../../../../../config/legacy-cli-config.layer.ts"; +import { legacyDebugLoggerLayer } from "../../../../../shared/legacy-debug-logger.layer.ts"; +import { legacyDockerRunLayer } from "../../../../../shared/legacy-docker-run.layer.ts"; +import { legacyEdgeRuntimeScriptLayer } from "../../../../../shared/legacy-edge-runtime-script.layer.ts"; +import { legacyPgDeltaSslProbeLayer } from "../../../../../shared/legacy-pgdelta-ssl-probe.layer.ts"; +import { legacyTelemetryStateLayer } from "../../../../../telemetry/legacy-telemetry-state.layer.ts"; +import { legacyDeclarativeSeamLayer } from "../../../shared/legacy-pgdelta.seam.layer.ts"; + +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + +const edgeRuntime = legacyEdgeRuntimeScriptLayer.pipe( + Layer.provide(legacyDockerRunLayer), + Layer.provide(cliConfig), +); + +const seam = legacyDeclarativeSeamLayer.pipe(Layer.provide(cliConfig)); + +export const legacyDbSchemaDeclarativeApplyRuntimeLayer = Layer.mergeAll( + edgeRuntime, + legacyPgDeltaSslProbeLayer, + seam, + cliConfig, + legacyTelemetryStateLayer, + commandRuntimeLayer(["db", "schema", "declarative", "apply"]), +); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.command.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.command.ts index a804779727..e096b0999c 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.command.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.command.ts @@ -1,10 +1,12 @@ import { Command } from "effect/unstable/cli"; import { legacyDbSchemaDeclarativeSharedBase } from "./declarative.shared.ts"; +import { legacyDbSchemaDeclarativeApplyCommand } from "./apply/apply.command.ts"; import { legacyDbSchemaDeclarativeGenerateCommand } from "./generate/generate.command.ts"; import { legacyDbSchemaDeclarativeSyncCommand } from "./sync/sync.command.ts"; export const legacyDbSchemaDeclarativeCommand = legacyDbSchemaDeclarativeSharedBase.pipe( Command.withSubcommands([ + legacyDbSchemaDeclarativeApplyCommand, legacyDbSchemaDeclarativeSyncCommand, legacyDbSchemaDeclarativeGenerateCommand, ]), diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.ts index 625967555d..8021fb25d5 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.ts @@ -27,7 +27,7 @@ export const legacyPgDeltaCatalogExportScript = /** `internal/pgdelta/templates/pgdelta_declarative_apply.ts` — applies declarative files to TARGET. */ export const legacyPgDeltaDeclarativeApplyScript = - '// This script applies declarative schema files to a target database and emits\n// structured JSON so the Go caller can report success/failure deterministically.\nimport {\n applyDeclarativeSchema,\n loadDeclarativeSchema,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20/declarative";\n\nconst schemaPath = Deno.env.get("SCHEMA_PATH");\nconst target = Deno.env.get("TARGET");\n\nif (!schemaPath) {\n throw new Error("SCHEMA_PATH is required");\n}\nif (!target) {\n throw new Error("TARGET is required");\n}\n\ntry {\n const content = await loadDeclarativeSchema(schemaPath);\n if (content.length === 0) {\n console.log(JSON.stringify({ status: "success", totalStatements: 0 }));\n } else {\n const result = await applyDeclarativeSchema({\n content,\n targetUrl: target,\n });\n const apply = result?.apply;\n if (!apply) {\n throw new Error("pg-delta apply returned no result");\n }\n const payload = {\n status: apply.status,\n totalStatements: result.totalStatements ?? 0,\n totalRounds: apply.totalRounds ?? 0,\n totalApplied: apply.totalApplied ?? 0,\n totalSkipped: apply.totalSkipped ?? 0,\n errors: apply.errors ?? [],\n stuckStatements: apply.stuckStatements ?? [],\n // validationErrors is populated when the final\n // check_function_bodies=on pass catches issues that didn\'t surface during\n // the initial apply rounds (e.g. a function body that references a\n // column whose type changed). Without surfacing this field, callers see\n // status=error with empty errors/stuckStatements and no actionable info.\n validationErrors: apply.validationErrors ?? [],\n diagnostics: result.diagnostics ?? [],\n };\n console.log(JSON.stringify(payload));\n if (apply.status !== "success") {\n throw new Error("pg-delta apply failed with status: " + apply.status);\n }\n }\n} catch (e) {\n throw e instanceof Error ? e : new Error(String(e));\n}\n'; + '// This script applies declarative schema files to a target database and emits\n// structured JSON so the Go caller can report success/failure deterministically.\nimport {\n applyDeclarativeSchema,\n loadDeclarativeSchema,\n} from "npm:@supabase/pg-delta@1.0.0-alpha.20/declarative";\n\nconst schemaPath = Deno.env.get("SCHEMA_PATH");\nconst target = Deno.env.get("TARGET");\n\nif (!schemaPath) {\n throw new Error("SCHEMA_PATH is required");\n}\nif (!target) {\n throw new Error("TARGET is required");\n}\n\ntry {\n const content = await loadDeclarativeSchema(schemaPath);\n if (content.length === 0) {\n console.log(JSON.stringify({ status: "success", totalStatements: 0 }));\n } else {\n const result = await applyDeclarativeSchema({\n content,\n targetUrl: target,\n });\n const apply = result?.apply;\n if (!apply) {\n throw new Error("pg-delta apply returned no result");\n }\n const payload = {\n status: apply.status,\n totalStatements: result.totalStatements ?? 0,\n totalRounds: apply.totalRounds ?? 0,\n totalApplied: apply.totalApplied ?? 0,\n totalSkipped: apply.totalSkipped ?? 0,\n errors: apply.errors ?? [],\n stuckStatements: apply.stuckStatements ?? [],\n // validationErrors is populated when the final\n // check_function_bodies=on pass catches issues that didn\'t surface during\n // the initial apply rounds (e.g. a function body that references a\n // column whose type changed). Without surfacing this field, callers see\n // status=error with empty errors/stuckStatements and no actionable info.\n validationErrors: apply.validationErrors ?? [],\n diagnostics: result.diagnostics ?? [],\n };\n console.log(JSON.stringify(payload));\n }\n} catch (e) {\n throw e instanceof Error ? e : new Error(String(e));\n}\n'; /** * The npm dist-tag/version used for `@supabase/pg-delta` when diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.integration.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.integration.test.ts index a90d2476e4..e1ed315247 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.integration.test.ts @@ -14,6 +14,7 @@ import { LEGACY_PG_DELTA_NPM_VERSION_PLACEHOLDER, } from "./legacy-pgdelta.deno-templates.ts"; import { + legacyApplyDeclarativePgDelta, legacyDeclarativeExportPgDelta, legacyDiffPgDelta, legacyExportCatalogPgDelta, @@ -33,7 +34,13 @@ function fakeEdgeRuntime(outcome: { stdout?: string; stderr?: string; fail?: str run: (opts: LegacyEdgeRuntimeRunOpts) => { calls.push(opts); if (outcome.fail !== undefined) { - return Effect.fail(new LegacyEdgeRuntimeScriptError({ message: outcome.fail })); + return Effect.fail( + new LegacyEdgeRuntimeScriptError({ + message: outcome.fail, + stdout: outcome.stdout, + stderr: outcome.stderr, + }), + ); } return Effect.succeed({ stdout: outcome.stdout ?? "", @@ -50,6 +57,98 @@ const probe = Layer.succeed(LegacyPgDeltaSslProbe, { requireSsl: () => Effect.succeed(false), }); +describe("legacyApplyDeclarativePgDelta", () => { + it.effect("parses apply output and mounts the declarative directory read-only", () => { + const edge = fakeEdgeRuntime({ + stdout: JSON.stringify({ + status: "success", + totalStatements: 3, + totalRounds: 2, + totalApplied: 3, + totalSkipped: 0, + errors: [], + stuckStatements: [], + validationErrors: [], + diagnostics: [], + }), + }); + return legacyApplyDeclarativePgDelta(CTX, { + declarativeDir: "/proj/supabase/database", + targetRef: "postgresql://t", + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.status).toBe("success"); + expect(result.totalApplied).toBe(3); + const opts = edge.calls[0]!; + expect(opts.errPrefix).toBe("error running pg-delta script"); + expect(opts.env["SCHEMA_PATH"]).toBe("/declarative"); + expect(opts.env["TARGET"]).toBe("postgresql://t"); + expect(opts.binds).toEqual([ + "supabase_edge_runtime_ref:/root/.cache/deno:rw", + "/proj/supabase/database:/declarative:ro", + ]); + }), + ), + Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), + ); + }); + + it.effect("fails with parse error on invalid apply JSON", () => { + const edge = fakeEdgeRuntime({ stdout: "not json" }); + return legacyApplyDeclarativePgDelta(CTX, { + declarativeDir: "/proj/supabase/database", + targetRef: "postgresql://t", + }).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeParseOutputError"); + expect((failError(exit) as { message: string }).message).toContain( + "failed to parse pg-delta apply output:", + ); + }), + ), + Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), + ); + }); + + it.effect("parses structured apply output when edge-runtime exits non-zero", () => { + const edge = fakeEdgeRuntime({ + fail: "error running pg-delta script: error running container: exit 1", + stderr: "pg-delta apply failed with status: error", + stdout: JSON.stringify({ + status: "error", + totalStatements: 2, + totalRounds: 1, + totalApplied: 1, + totalSkipped: 0, + errors: ["permission denied"], + stuckStatements: [{ sql: "alter table public.todos add column title text" }], + validationErrors: [{ message: "function body failed validation" }], + diagnostics: [], + }), + }); + return legacyApplyDeclarativePgDelta(CTX, { + declarativeDir: "/proj/supabase/database", + targetRef: "postgresql://t", + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.status).toBe("error"); + expect(result.totalApplied).toBe(1); + expect(result.errors).toEqual(["permission denied"]); + expect(result.stuckStatements).toEqual([ + { sql: "alter table public.todos add column title text" }, + ]); + expect(result.validationErrors).toEqual([{ message: "function body failed validation" }]); + }), + ), + Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), + ); + }); +}); + const failError = (exit: Exit.Exit) => Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isFailReason)?.error : undefined; diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.ts index a7b49c230d..cec191162d 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Path } from "effect"; +import { Effect, FileSystem, Path, Schema } from "effect"; import { type LegacyEdgeRuntimeFile, @@ -12,6 +12,7 @@ import { import { legacyInterpolatePgDeltaScript, legacyPgDeltaCatalogExportScript, + legacyPgDeltaDeclarativeApplyScript, legacyPgDeltaDeclarativeExportScript, legacyPgDeltaDiffScript, } from "./legacy-pgdelta.deno-templates.ts"; @@ -38,6 +39,19 @@ export interface LegacyDeclarativeOutput { readonly files: ReadonlyArray; } +interface LegacyDeclarativeApplyResult { + readonly status: string; + readonly totalStatements: number; + readonly totalRounds: number; + readonly totalApplied: number; + readonly totalSkipped: number; + readonly errors: ReadonlyArray; + readonly stuckStatements: ReadonlyArray; + readonly validationErrors: ReadonlyArray; + readonly diagnostics: ReadonlyArray; + readonly raw: string; +} + /** Result of a pg-delta diff: the SQL statements plus edge-runtime stderr. */ interface LegacyPgDeltaDiffResult { readonly sql: string; @@ -158,6 +172,57 @@ const buildDiffEnv = Effect.fnUntraced(function* ( const toDeclarativeEdgeRuntimeError = (error: { readonly message: string }) => new LegacyDeclarativeEdgeRuntimeError({ message: error.message }); +const LegacyDeclarativeApplyResultSchema = Schema.Struct({ + status: Schema.String, + totalStatements: Schema.optionalKey(Schema.Number), + totalRounds: Schema.optionalKey(Schema.Number), + totalApplied: Schema.optionalKey(Schema.Number), + totalSkipped: Schema.optionalKey(Schema.Number), + errors: Schema.optionalKey(Schema.Array(Schema.Unknown)), + stuckStatements: Schema.optionalKey(Schema.Array(Schema.Unknown)), + validationErrors: Schema.optionalKey(Schema.Array(Schema.Unknown)), + diagnostics: Schema.optionalKey(Schema.Array(Schema.Unknown)), +}); + +const decodeApplyResult = Schema.decodeUnknownEffect(LegacyDeclarativeApplyResultSchema); + +const parseApplyResult = (stdout: string) => + Effect.try({ + try: () => JSON.parse(stdout), + catch: (cause) => + new LegacyDeclarativeParseOutputError({ + message: `failed to parse pg-delta apply output: ${ + cause instanceof Error ? cause.message : String(cause) + }`, + }), + }).pipe( + Effect.flatMap((parsed) => + decodeApplyResult(parsed).pipe( + Effect.map((decoded) => { + const normalized: LegacyDeclarativeApplyResult = { + status: decoded.status, + totalStatements: decoded.totalStatements ?? 0, + totalRounds: decoded.totalRounds ?? 0, + totalApplied: decoded.totalApplied ?? 0, + totalSkipped: decoded.totalSkipped ?? 0, + errors: decoded.errors ?? [], + stuckStatements: decoded.stuckStatements ?? [], + validationErrors: decoded.validationErrors ?? [], + diagnostics: decoded.diagnostics ?? [], + raw: stdout, + }; + return normalized; + }), + Effect.mapError( + (cause) => + new LegacyDeclarativeParseOutputError({ + message: `failed to parse pg-delta apply output: ${cause.message}`, + }), + ), + ), + ), + ); + /** * Diffs SOURCE → TARGET via the pg-delta diff script. Mirrors Go's * `DiffPgDeltaRefDetailed` (`internal/db/diff/pgdelta.go:108`). `sourceRef` may @@ -281,3 +346,38 @@ export const legacyExportCatalogPgDelta = Effect.fnUntraced(function* ( } return snapshot; }); + +export const legacyApplyDeclarativePgDelta = Effect.fnUntraced(function* ( + ctx: LegacyPgDeltaContext, + params: { readonly declarativeDir: string; readonly targetRef: string }, +) { + const edgeRuntime = yield* LegacyEdgeRuntimeScript; + const path = yield* Path.Path; + const schemaPath = "/declarative"; + const npm = legacyPgDeltaNpmRegistryOption(); + const result = yield* edgeRuntime + .run({ + script: legacyInterpolatePgDeltaScript(legacyPgDeltaDeclarativeApplyScript, ctx.npmVersion), + env: { + SCHEMA_PATH: schemaPath, + TARGET: params.targetRef, + }, + binds: [ + `${legacyEdgeRuntimeId(ctx.projectId)}:/root/.cache/deno:rw`, + `${path.resolve(params.declarativeDir)}:${schemaPath}:ro`, + ], + errPrefix: "error running pg-delta script", + extraFiles: npm.extraFiles, + extraEnv: npm.extraEnv, + denoVersion: ctx.denoVersion, + }) + .pipe( + Effect.catchTag("LegacyEdgeRuntimeScriptError", (error) => + error.stdout === undefined || error.stdout.length === 0 + ? Effect.fail(toDeclarativeEdgeRuntimeError(error)) + : Effect.succeed({ stdout: error.stdout, stderr: error.stderr ?? "" }), + ), + ); + + return yield* parseApplyResult(result.stdout); +}); diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.errors.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.errors.ts index 009d602462..340beb717f 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.errors.ts @@ -10,4 +10,6 @@ import { Data } from "effect"; */ export class LegacyEdgeRuntimeScriptError extends Data.TaggedError("LegacyEdgeRuntimeScriptError")<{ readonly message: string; + readonly stdout?: string; + readonly stderr?: string; }> {} diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts index 5b7d273c78..b1c53e13d3 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts @@ -124,16 +124,19 @@ export const legacyEdgeRuntimeScriptLayer = Layer.effect( // Go ignores the error when stderr reports the runtime tore down its // worker after the script completed (the script's output is still // valid). Any other non-zero exit is a real failure. + const stdout = new TextDecoder().decode(result.stdout); if (result.exitCode !== 0 && !result.stderr.includes("main worker has been destroyed")) { return yield* Effect.fail( new LegacyEdgeRuntimeScriptError({ message: `${opts.errPrefix}: error running container: exit ${result.exitCode}:\n${result.stderr}`, + stdout, + stderr: result.stderr, }), ); } return { - stdout: new TextDecoder().decode(result.stdout), + stdout, stderr: result.stderr, }; }), diff --git a/apps/cli/src/shared/legacy/global-flags.ts b/apps/cli/src/shared/legacy/global-flags.ts index c30423b810..6adf997108 100644 --- a/apps/cli/src/shared/legacy/global-flags.ts +++ b/apps/cli/src/shared/legacy/global-flags.ts @@ -117,3 +117,13 @@ export const legacyResolveExperimental = Effect.gen(function* () { const flag = yield* LegacyExperimentalFlag; return flag || legacyViperEnvBool("SUPABASE_EXPERIMENTAL"); }); + +/** + * `--debug` resolved with Go's viper `AutomaticEnv` fallback. Go diagnostics + * read `viper.GetBool("DEBUG")`, so `SUPABASE_DEBUG=true` enables the same + * verbose paths as passing `--debug`. + */ +export const legacyResolveDebug = Effect.gen(function* () { + const flag = yield* LegacyDebugFlag; + return flag || legacyViperEnvBool("SUPABASE_DEBUG"); +});