Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/cli/docs/supabase/db/diff.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ By default, all schemas in the target database are diffed. Use the `--schema pub

Projects created by a recent `supabase init` default to the pg-delta diff engine (`[experimental.pgdelta] enabled = true` in `config.toml`). Existing projects are unaffected and keep using migra unless they opt in. To fall back to the legacy migra engine, set `enabled = false` under `[experimental.pgdelta]`, or pass `--use-migra` for a single run.

With the bundled pg-delta engine, diff SQL defaults to lowercase keywords and a maximum width of 180, matching its declarative export. When `-f` writes migrations, execution-aware transaction semantics are preserved as ordered per-unit files; non-transactional units carry a directive that the CLI apply path honors. Flattened review output retains the rendered SQL and preambles, but not the unit boundaries supplied to a migration runner. Configure overrides with `[experimental.pgdelta] format_options`, or set `format_options = "null"` to emit raw, unformatted statements.
With the bundled pg-delta engine, diff SQL defaults to uppercase keywords, indent 2, a maximum width of 180, trailing commas, and column/key alignment, matching its declarative export. When `-f` writes migrations, execution-aware transaction semantics are preserved as ordered per-unit files; non-transactional units carry a directive that the CLI apply path honors. Flattened review output retains the rendered SQL and preambles, but not the unit boundaries supplied to a migration runner. Configure overrides with `[experimental.pgdelta] format_options`, or set `format_options = "null"` to emit raw, unformatted statements.

While the diff command is able to capture most schema changes, there are cases where it is known to fail. Currently, this could happen if you schema contains:

Expand Down
2 changes: 2 additions & 0 deletions apps/cli/docs/supabase/db/schema-declarative-generate.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ Exports the schema of a live database (local, linked, or custom URL) into SQL fi

The bundled pg-delta engine writes one directory per schema at the root of that directory (`supabase/schemas/public/tables/users.sql`, `supabase/schemas/public/schema.sql`), with cluster-level objects that belong to no schema under a reserved `_cluster/` directory (`supabase/schemas/_cluster/roles.sql`). A schema literally named `_cluster` or `_custom`, in any casing, has its leading underscore percent-encoded (`%5Fcluster/`) so it can never claim a directory the export owns. Hand-authored SQL that pg-delta does not model belongs in `_custom/`, which the export never writes to and never prunes.

Emitted SQL uses the same default format as `db pull` (uppercase keywords, indent 2, width 180, column-aligned). Override with `[experimental.pgdelta] format_options`, or set `format_options = "null"` for raw statements.

Requires `--experimental` flag or `[experimental.pgdelta] enabled = true` in config.
2 changes: 1 addition & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
"@parcel/watcher": "^2.6.0",
"@supabase/api": "workspace:*",
"@supabase/config": "workspace:*",
"@supabase/pg-delta": "1.0.0-alpha.42",
"@supabase/pg-delta": "1.0.0-alpha.46",
"@supabase/pg-topo": "1.0.0-alpha.5",
"@supabase/process-compose": "workspace:*",
"@supabase/stack": "workspace:*",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { LegacyPgDeltaImplementation } from "../../../../shared/legacy-pgdelta-next-flag.ts";
import { legacySchemaToCsvField } from "../../../../shared/legacy-schema-flags.ts";
import { legacyDeclaredSqlExtensions } from "../../shared/legacy-pgdelta-declarative-shadow-prep.ts";
import type { LegacyPgDeltaRemovalSummary } from "../../shared/legacy-pgdelta-engine.service.ts";

/** Extensions that legacy pg-delta treated as part of its implicit Supabase baseline. */
Expand Down Expand Up @@ -159,18 +160,6 @@ function matchImplicitExtension(message: string): LegacyImplicitExtensionMatch |
};
}

/**
* Masks SQL comments and strings while preserving offsets. Extension declarations
* are DDL, so occurrences inside comments, quoted values, and dollar bodies must
* not suppress compatibility guidance.
*/
function maskSqlNonCode(sql: string): string {
return sql.replaceAll(
/--[^\r\n]*|\/\*[\s\S]*?\*\/|'(?:''|[^'])*'|\$(?:[a-zA-Z_][\w$]*)?\$[\s\S]*?\$(?:[a-zA-Z_][\w$]*)?\$/g,
(matched) => matched.replaceAll(/[^\r\n]/g, " "),
);
}

function maskSqlComments(sql: string): string {
return sql.replaceAll(/--[^\r\n]*|\/\*[\s\S]*?\*\//g, (matched) =>
matched.replaceAll(/[^\r\n]/g, " "),
Expand All @@ -180,16 +169,7 @@ function maskSqlComments(sql: string): string {
export function legacyDeclaredExtensions(
files: readonly LegacyDeclarativeSqlFile[],
): ReadonlySet<string> {
const declared = new Set<string>();
const pattern =
/\bCREATE\s+EXTENSION\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:"([^"]+)"|([a-zA-Z_][\w$-]*))/gi;
for (const file of files) {
for (const match of maskSqlNonCode(file.sql).matchAll(pattern)) {
const extension = match[1] ?? match[2];
if (extension !== undefined) declared.add(extension.toLowerCase());
}
}
return declared;
return legacyDeclaredSqlExtensions(files);
}

function declaredImplicitExtensions(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { Effect } from "effect";

import { LegacyPgDeltaEngineError } from "./legacy-pgdelta-engine.service.ts";

export type LegacyDeclarativeShadowClient = {
readonly query: (sql: string) => Promise<{ readonly rows: ReadonlyArray<unknown> }>;
};

/** Image-default extensions the user may still declare; omit means keep the install. */
const IMAGE_DEFAULT_EXTENSIONS = ["pgjwt", "pgcrypto", "uuid-ossp"] as const;

const IMAGE_DEFAULT_EXTENSION_SET = new Set<string>(IMAGE_DEFAULT_EXTENSIONS);

const DROP_IMAGE_DEFAULT_EXTENSION: Record<(typeof IMAGE_DEFAULT_EXTENSIONS)[number], string> = {
pgjwt: "DROP EXTENSION IF EXISTS pgjwt",
pgcrypto: "DROP EXTENSION IF EXISTS pgcrypto",
"uuid-ossp": 'DROP EXTENSION IF EXISTS "uuid-ossp"',
};

const CREATE_EXTENSION_RE =
/\bCREATE\s+EXTENSION\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:"([^"]+)"|([a-zA-Z_][\w$-]*))/gi;

/** Blank comments and literals so CREATE EXTENSION in those positions is ignored. */
const maskSqlNonCode = (sql: string): string =>
sql.replaceAll(
/--[^\r\n]*|\/\*[\s\S]*?\*\/|'(?:''|[^'])*'|\$(?:[a-zA-Z_][\w$]*)?\$[\s\S]*?\$(?:[a-zA-Z_][\w$]*)?\$/g,
(matched) => matched.replaceAll(/[^\r\n]/g, " "),
Comment thread
avallete marked this conversation as resolved.
Outdated
);

export const legacyDeclaredSqlExtensions = (
files: ReadonlyArray<{ readonly name: string; readonly sql: string }>,
): ReadonlySet<string> => {
const declared = new Set<string>();
for (const file of files) {
for (const match of maskSqlNonCode(file.sql).matchAll(CREATE_EXTENSION_RE)) {
Comment thread
avallete marked this conversation as resolved.
Outdated
const name = (match[1] ?? match[2] ?? "").toLowerCase();
if (name !== "") declared.add(name);
}
}
return declared;
};

const declaredImageExtensions = (
files: ReadonlyArray<{ readonly name: string; readonly sql: string }>,
): ReadonlySet<string> => {
const declared = new Set<string>();
for (const name of legacyDeclaredSqlExtensions(files)) {
if (IMAGE_DEFAULT_EXTENSION_SET.has(name)) declared.add(name);
}
return declared;
};

export const legacyParsePostgresMajorVersion = (serverVersion: string): number => {
const major = Number.parseInt(serverVersion, 10);
return Number.isInteger(major) ? major : 0;
};

export const legacyDeclarativeBaselinePrepStatements = (
majorVersion: number,
declared: ReadonlySet<string>,
): ReadonlyArray<string> => {
const dropPgcrypto = declared.has("pgcrypto");
// Image pgjwt depends on pgcrypto; drop it first so pgcrypto can drop.
const dropPgjwt = declared.has("pgjwt") || dropPgcrypto;
const dropUuidOssp = declared.has("uuid-ossp");
const statements: string[] = [];
if (majorVersion === 14 && dropUuidOssp) {
statements.push("ALTER TABLE storage.objects ALTER COLUMN id DROP DEFAULT");
}
if (dropPgjwt) statements.push(DROP_IMAGE_DEFAULT_EXTENSION.pgjwt);
if (dropPgcrypto) statements.push(DROP_IMAGE_DEFAULT_EXTENSION.pgcrypto);
if (dropUuidOssp) statements.push(DROP_IMAGE_DEFAULT_EXTENSION["uuid-ossp"]);
return statements;
};

/** Recreate image pgjwt after a pgcrypto-only drop so omit still means keep. */
export const legacyFilesForDeclarativeShadowLoad = (
files: ReadonlyArray<{ readonly name: string; readonly sql: string }>,
): ReadonlyArray<{ readonly name: string; readonly sql: string }> => {
const declared = declaredImageExtensions(files);
if (!declared.has("pgcrypto") || declared.has("pgjwt")) return files;
return [
...files,
{
name: "_cli/restore-pgjwt.sql",
sql: "CREATE EXTENSION IF NOT EXISTS pgjwt WITH SCHEMA extensions;\n",
},
];
};

/** User cannot edit this SQL; a persistent miss is a CLI bug. */
const DECLARATIVE_SHADOW_PREP_FAILURE_SUGGESTION =
"This statement is CLI-owned shadow prep, not a project migration or schema file. If it persists, report it with supabase issue bug.";

const queryError = (sql: string, cause: unknown) =>
new LegacyPgDeltaEngineError({
message: `Failed to prepare the isolated declaration shadow (${sql}): ${
cause instanceof Error ? cause.message : String(cause)
}`,
cause,
suggestion: DECLARATIVE_SHADOW_PREP_FAILURE_SUGGESTION,
});

const readServerVersion = (rows: ReadonlyArray<unknown>): string => {
const row = rows[0];
if (row === undefined || typeof row !== "object" || row === null) return "";
const value = Reflect.get(row, "server_version");
return typeof value === "string" ? value : "";
};

export const legacyPrepareDeclarativeShadow = (
client: LegacyDeclarativeShadowClient,
files: ReadonlyArray<{ readonly name: string; readonly sql: string }>,
) =>
Effect.gen(function* () {
const declared = declaredImageExtensions(files);
if (declared.size === 0) return;
const versionRows = yield* Effect.tryPromise({
try: () => client.query("SHOW server_version"),
catch: (cause) => queryError("SHOW server_version", cause),
});
const statements = legacyDeclarativeBaselinePrepStatements(
legacyParsePostgresMajorVersion(readServerVersion(versionRows.rows)),
declared,
);
for (const sql of statements) {
yield* Effect.tryPromise({
try: () => client.query(sql),
catch: (cause) => queryError(sql, cause),
});
Comment thread
avallete marked this conversation as resolved.
Outdated
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { describe, expect, it } from "@effect/vitest";
import { Cause, Effect, Exit, Option } from "effect";

import {
legacyDeclarativeBaselinePrepStatements,
legacyFilesForDeclarativeShadowLoad,
legacyParsePostgresMajorVersion,
legacyPrepareDeclarativeShadow,
} from "./legacy-pgdelta-declarative-shadow-prep.ts";
import { LegacyPgDeltaEngineError } from "./legacy-pgdelta-engine.service.ts";

const allImageCreates = [
{ name: "_cluster/extensions/pgjwt.sql", sql: 'CREATE EXTENSION "pgjwt";' },
{ name: "_cluster/extensions/pgcrypto.sql", sql: 'CREATE EXTENSION "pgcrypto";' },
{ name: "_cluster/extensions/uuid-ossp.sql", sql: 'CREATE EXTENSION "uuid-ossp";' },
];

describe("legacyDeclarativeBaselinePrepStatements", () => {
it("emits nothing when declarations do not recreate image defaults", () => {
expect(legacyDeclarativeBaselinePrepStatements(14, new Set())).toEqual([]);
expect(legacyDeclarativeBaselinePrepStatements(17, new Set())).toEqual([]);
});

it("detaches PG14 storage.objects before dropping declared uuid-ossp", () => {
expect(legacyDeclarativeBaselinePrepStatements(14, new Set(["uuid-ossp"]))).toEqual([
"ALTER TABLE storage.objects ALTER COLUMN id DROP DEFAULT",
'DROP EXTENSION IF EXISTS "uuid-ossp"',
]);
});

it("drops image pgjwt before a declared pgcrypto recreate", () => {
expect(legacyDeclarativeBaselinePrepStatements(14, new Set(["pgcrypto"]))).toEqual([
"DROP EXTENSION IF EXISTS pgjwt",
"DROP EXTENSION IF EXISTS pgcrypto",
]);
expect(legacyDeclarativeBaselinePrepStatements(17, new Set(["pgcrypto"]))).toEqual([
"DROP EXTENSION IF EXISTS pgjwt",
"DROP EXTENSION IF EXISTS pgcrypto",
]);
});

it("drops declared image defaults on PG15+, pgjwt before pgcrypto", () => {
expect(
legacyDeclarativeBaselinePrepStatements(17, new Set(["pgjwt", "pgcrypto", "uuid-ossp"])),
).toEqual([
"DROP EXTENSION IF EXISTS pgjwt",
"DROP EXTENSION IF EXISTS pgcrypto",
'DROP EXTENSION IF EXISTS "uuid-ossp"',
]);
});
});

describe("legacyFilesForDeclarativeShadowLoad", () => {
it("restores omitted pgjwt after a pgcrypto recreate", () => {
const files = [{ name: "public/01.sql", sql: "CREATE EXTENSION pgcrypto;" }];
expect(legacyFilesForDeclarativeShadowLoad(files)).toEqual([
...files,
{
name: "_cli/restore-pgjwt.sql",
sql: "CREATE EXTENSION IF NOT EXISTS pgjwt WITH SCHEMA extensions;\n",
},
]);
});

it("does not restore pgjwt when declarations recreate it", () => {
expect(legacyFilesForDeclarativeShadowLoad(allImageCreates)).toEqual(allImageCreates);
});
});

describe("legacyParsePostgresMajorVersion", () => {
it("reads the leading major from SHOW server_version", () => {
expect(legacyParsePostgresMajorVersion("17.6")).toBe(17);
expect(legacyParsePostgresMajorVersion("14.15 (Debian)")).toBe(14);
expect(legacyParsePostgresMajorVersion("")).toBe(0);
});
});

describe("legacyPrepareDeclarativeShadow", () => {
it.live("skips the shadow when declarations omit image-default extensions", () => {
const queries: string[] = [];
const client = {
query: (sql: string) => {
queries.push(sql);
return Promise.resolve({ rows: [] });
},
};
return Effect.gen(function* () {
yield* legacyPrepareDeclarativeShadow(client, [
{ name: "a.sql", sql: "create table a (id int);" },
]);
expect(queries).toEqual([]);
});
});

it.live("names the failing prep statement", () => {
const client = {
query: (sql: string) => {
if (sql === "SHOW server_version") {
return Promise.resolve({ rows: [{ server_version: "15.8" }] });
}
if (sql.includes("pgcrypto")) {
return Promise.reject(new Error("cannot drop extension pgcrypto (SQLSTATE 2BP01)"));
}
return Promise.resolve({ rows: [] });
},
};
return Effect.gen(function* () {
const exit = yield* legacyPrepareDeclarativeShadow(client, [
{ name: "public/01.sql", sql: "CREATE EXTENSION pgcrypto;" },
]).pipe(Effect.exit);
expect(Exit.isFailure(exit)).toBe(true);
const error = Exit.isFailure(exit)
? Option.getOrUndefined(Cause.findErrorOption(exit.cause))
: undefined;
expect(error).toBeInstanceOf(LegacyPgDeltaEngineError);
expect(error instanceof LegacyPgDeltaEngineError ? error.message : "").toContain(
"DROP EXTENSION IF EXISTS pgcrypto",
);
});
});

it.live("runs the version-selected prep statements against the shadow", () => {
const queries: string[] = [];
const client = {
query: (sql: string) => {
queries.push(sql);
return Promise.resolve({
rows: sql === "SHOW server_version" ? [{ server_version: "17.6" }] : [],
});
},
};
return Effect.gen(function* () {
yield* legacyPrepareDeclarativeShadow(client, allImageCreates);
expect(queries).toEqual([
"SHOW server_version",
"DROP EXTENSION IF EXISTS pgjwt",
"DROP EXTENSION IF EXISTS pgcrypto",
'DROP EXTENSION IF EXISTS "uuid-ossp"',
]);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import {
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 {
legacyFilesForDeclarativeShadowLoad,
legacyPrepareDeclarativeShadow,
} from "./legacy-pgdelta-declarative-shadow-prep.ts";
import {
LegacyPgDeltaEngine,
LegacyPgDeltaEngineError,
Expand All @@ -27,11 +31,11 @@ import {
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";
import { LegacyPgDeltaNextShadow } from "./legacy-pgdelta-next-shadow.service.ts";

function legacyPgDeltaNextConnectSuggestion(cause: unknown): string | undefined {
if (cause instanceof LegacyDbConnectError) return cause.suggestion;
Expand Down Expand Up @@ -349,10 +353,11 @@ export const legacyPgDeltaNextEngineLayer = Layer.effect(
],
{ concurrency: 2 },
);
yield* legacyPrepareDeclarativeShadow(declarativePool, input.files);
const result = yield* adapter.planDeclarativeSchema({
targetPool: migrationsPool,
shadowPool: declarativePool,
files: input.files,
files: legacyFilesForDeclarativeShadowLoad(input.files),
allowDrops: true,
debug: input.debug,
schema: input.schema,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export interface LegacyPgDeltaExportManifest {
readonly baselineDigest?: string;
readonly defaultOwner?: string | null;
readonly files?: ReadonlyArray<string>;
readonly loadOrder?: ReadonlyArray<string>;
}

export interface LegacyPgDeltaRenderedFile {
Expand Down Expand Up @@ -75,7 +76,8 @@ export type LegacyPgDeltaHazardKind =
| "access_exclusive_lock"
| "unmodeled_kind"
| "unmodeled_drift"
| "unresolved_security_label";
| "unresolved_security_label"
| "vault_presence";

interface LegacyPgDeltaActionHazard {
readonly actionIndex: number;
Expand Down
Loading
Loading