Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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,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,290 @@
import { Effect } from "effect";

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

export type LegacyDeclarativeShadowConnection = {
readonly query: (sql: string) => Promise<{ readonly rows: ReadonlyArray<unknown> }>;
readonly release: (error?: Error | boolean) => void;
readonly on?: (event: "error", listener: (error: Error) => void) => void;
readonly removeListener?: (event: "error", listener: (error: Error) => void) => void;
};

export type LegacyDeclarativeShadowClient = {
readonly connect: () => Promise<LegacyDeclarativeShadowConnection>;
};

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;

const DOLLAR_TAG_RE = /^\$[A-Za-z_]?[A-Za-z0-9_]*\$/;

const isIdentChar = (ch: string): boolean =>
(ch >= "A" && ch <= "Z") ||
(ch >= "a" && ch <= "z") ||
(ch >= "0" && ch <= "9") ||
ch === "_" ||
ch === "$";

/** `E'...'` / `e'...'` only when E is not part of a preceding identifier. */
const isEscapeString = (sql: string, quoteIndex: number): boolean => {
if (quoteIndex < 1) return false;
const prefix = sql[quoteIndex - 1];
if (prefix !== "E" && prefix !== "e") return false;
if (quoteIndex === 1) return true;
return !isIdentChar(sql[quoteIndex - 2] ?? "");
};

const blankRange = (sql: string, start: number, end: number): string => {
let blanked = "";
for (let i = start; i < end; i++) {
const ch = sql[i];
blanked += ch === "\n" || ch === "\r" ? ch : " ";
}
return blanked;
};

/** Blank comments and literals, including nested block comments and E-string escapes. */
const maskSqlNonCode = (sql: string): string => {
const out: string[] = [];
let i = 0;
const n = sql.length;
while (i < n) {
const c = sql[i] ?? "";
const next = sql[i + 1];
if (c === "-" && next === "-") {
const start = i;
while (i < n && sql[i] !== "\n") i++;
out.push(blankRange(sql, start, i));
continue;
}
if (c === "/" && next === "*") {
const start = i;
let depth = 1;
i += 2;
while (i < n && depth > 0) {
if (sql[i] === "/" && sql[i + 1] === "*") {
depth += 1;
i += 2;
} else if (sql[i] === "*" && sql[i + 1] === "/") {
depth -= 1;
i += 2;
} else i += 1;
}
out.push(blankRange(sql, start, i));
continue;
}
if (c === "'") {
const start = i;
const escape = isEscapeString(sql, i);
i += 1;
while (i < n) {
if (escape && sql[i] === "\\") {
i += 2;
continue;
}
if (sql[i] === "'" && sql[i + 1] === "'") i += 2;
else if (sql[i] === "'") {
i += 1;
break;
} else i += 1;
}
out.push(blankRange(sql, start, i));
continue;
}
if (c === "$") {
const tagMatch = DOLLAR_TAG_RE.exec(sql.slice(i));
Comment thread
avallete marked this conversation as resolved.
Outdated
if (tagMatch !== null) {
const tag = tagMatch[0];
const end = sql.indexOf(tag, i + tag.length);
const stop = end === -1 ? n : end + tag.length;
out.push(blankRange(sql, i, stop));
i = stop;
continue;
}
}
out.push(c);
i += 1;
}
return out.join("");
};

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 }>,
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 SHADOW_PREP_INTERRUPTED = "shadow prep interrupted";

/** Checkout so interrupt can discard a locked DROP instead of hanging the shared pool. */
const queryShadow = (client: LegacyDeclarativeShadowClient, sql: string) =>
Effect.callback<{ readonly rows: ReadonlyArray<unknown> }, LegacyPgDeltaEngineError>((resume) => {
let settled = false;
let released = false;
let conn: LegacyDeclarativeShadowConnection | undefined;
const onError = (error: Error) => {
finish(Effect.fail(queryError(sql, error)), error);
};
const releaseConn = (error?: Error) => {
if (released || conn === undefined) return;
released = true;
conn.removeListener?.("error", onError);
conn.release(error);
};
const finish = (
effect: Effect.Effect<{ readonly rows: ReadonlyArray<unknown> }, LegacyPgDeltaEngineError>,
releaseError?: Error,
) => {
if (settled) return;
settled = true;
releaseConn(releaseError);
resume(effect);
};
void client.connect().then(
(acquired) => {
conn = acquired;
acquired.on?.("error", onError);
if (settled) {
releaseConn(new Error(SHADOW_PREP_INTERRUPTED));
return;
}
void acquired.query(sql).then(
(result) => finish(Effect.succeed(result)),
(cause) =>
finish(
Effect.fail(queryError(sql, cause)),
cause instanceof Error ? cause : new Error(String(cause)),
),
);
},
(cause) => finish(Effect.fail(queryError(sql, cause))),
);
return Effect.sync(() => {
if (settled) return;
settled = true;
releaseConn(new Error(SHADOW_PREP_INTERRUPTED));
});
});

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