Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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,9 @@
import type { LegacyPgDeltaImplementation } from "../../../../shared/legacy-pgdelta-next-flag.ts";
import { legacySchemaToCsvField } from "../../../../shared/legacy-schema-flags.ts";
import {
legacyDeclaredSqlExtensions,
legacyMaskSqlComments,
} 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,37 +163,10 @@ 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, " "),
);
}

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 All @@ -210,7 +187,7 @@ function locateSignature(
const diagnosticFile = files.find((file) => diagnosticMessage.startsWith(`${file.name}:`));
const candidates = diagnosticFile === undefined ? files : [diagnosticFile];
for (const file of candidates) {
const match = pattern.exec(maskSqlComments(file.sql));
const match = pattern.exec(legacyMaskSqlComments(file.sql));
if (match?.index === undefined) continue;
return {
file: file.name,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { Effect } from "effect";

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

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

export interface LegacyDeclarativeShadowPrepResult {
/** True only when prep dropped an installed image pgjwt to recreate pgcrypto. */
readonly restorePgjwt: boolean;
}

/** 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 simple strings; keep offsets for locateSignature line mapping. */
export const legacyMaskSqlComments = (sql: string): string =>
sql.replaceAll(/--[^\r\n]*|\/\*[\s\S]*?\*\/|'(?:[^']|'')*'/g, (matched) =>
matched.replaceAll(/[^\r\n]/g, " "),
);

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 legacyMaskSqlComments(file.sql).matchAll(CREATE_EXTENSION_RE)) {
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;
};

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

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 }>,
restorePgjwt: boolean,
): ReadonlyArray<{ readonly name: string; readonly sql: string }> => {
if (!restorePgjwt) 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 : "";
};

const rowHasPgjwt = (rows: ReadonlyArray<unknown>): boolean =>
rows.some((row) => {
if (typeof row !== "object" || row === null) return false;
const name = Reflect.get(row, "extname");
return name === "pgjwt";
});

const INSTALLED_PGJWT_SQL = "SELECT extname FROM pg_extension WHERE extname = 'pgjwt'";

const queryShadow = (client: LegacyDeclarativeShadowClient, sql: string) =>
Effect.tryPromise({
try: () => client.query(sql),
catch: (cause) => queryError(sql, cause),
Comment thread
avallete marked this conversation as resolved.
});

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 { restorePgjwt: false } satisfies LegacyDeclarativeShadowPrepResult;
let restorePgjwt = false;
if (declared.has("pgcrypto") && !declared.has("pgjwt")) {
const installed = yield* queryShadow(client, INSTALLED_PGJWT_SQL);
restorePgjwt = rowHasPgjwt(installed.rows);
Comment thread
avallete marked this conversation as resolved.
}
const versionRows = yield* queryShadow(client, "SHOW server_version");
const statements = legacyDeclarativeBaselinePrepStatements(
legacyParsePostgresMajorVersion(readServerVersion(versionRows.rows)),
declared,
);
for (const sql of statements) {
yield* queryShadow(client, sql);
}
return { restorePgjwt } satisfies LegacyDeclarativeShadowPrepResult;
});
Loading
Loading