diff --git a/apps/cli/src/command-internal/migration-apply.ts b/apps/cli/src/command-internal/migration-apply.ts index 8cfb3dabf5..2df61a3e4a 100644 --- a/apps/cli/src/command-internal/migration-apply.ts +++ b/apps/cli/src/command-internal/migration-apply.ts @@ -16,9 +16,9 @@ import { createMigrationTable, sortMigrationPathsByVersion, } from "./migration-history.ts"; -import { parseMigrationContent } from "./migration-file.ts"; +import { type MigrationTransactionMode, parseMigrationContent } from "./migration-file.ts"; import { sqlFilesGlob } from "./sql-files-glob.ts"; -import { splitSqlTokens } from "./sql-split.ts"; +import { splitAndTrim, splitSqlTokens } from "./sql-split.ts"; /** * Applying a migration file failed (`ApplyMigrations` / `ExecBatch` error). @@ -525,6 +525,185 @@ const formattedExecBatchDbError = (error: unknown): DbExecError | undefined => { return dbError instanceof DbExecError ? dbError : undefined; }; +interface MigrationHistoryRecord { + readonly version: string; + readonly name: string; +} + +interface ExecMigrationStatementsOptions { + readonly history?: MigrationHistoryRecord; + readonly sequentialFailureCleanup?: string; +} + +const execMigrationStatements = ( + session: DbSession, + statements: ReadonlyArray, + transactionMode: MigrationTransactionMode, + options: ExecMigrationStatementsOptions = {}, +): Effect.Effect => + Effect.gen(function* () { + const restoreRole = session.restoreRoleSql; + + const executeSequentially = (cleanup?: string) => + Effect.gen(function* () { + for (const [index, statement] of statements.entries()) { + yield* session + .exec(statement) + .pipe(Effect.mapError((cause) => formatExecBatchError(cause, index, statement))); + if (restoreRole !== undefined && revertsToLoginRole(statement)) { + yield* session + .exec(restoreRole) + .pipe(Effect.mapError((cause) => formatExecBatchError(cause, index, restoreRole))); + } + } + if ( + restoreRole !== undefined && + !(statements.length > 0 && revertsToLoginRole(statements[statements.length - 1]!)) + ) { + yield* session + .exec(restoreRole) + .pipe( + Effect.mapError((cause) => + formatExecBatchError(cause, statements.length, restoreRole), + ), + ); + } + if (options.history !== undefined) { + yield* session + .query(INSERT_MIGRATION_VERSION, [ + options.history.version, + options.history.name, + statements, + ]) + .pipe( + Effect.mapError((cause) => + formatExecBatchError(cause, statements.length, INSERT_MIGRATION_VERSION), + ), + ); + } + }).pipe( + Effect.tapError(() => + Effect.gen(function* () { + if (cleanup !== undefined) { + yield* session.exec(cleanup).pipe(Effect.ignore); + } + if (restoreRole !== undefined) { + yield* session.exec(restoreRole).pipe(Effect.ignore); + } + }), + ), + ); + + // Pg-delta nontransactional units still share one session and its cleanup. + if (transactionMode === "none") { + return yield* executeSequentially(options.sequentialFailureCleanup); + } + + // Authored transaction boundaries cannot be nested inside a CLI-owned batch. + if (statements.some(hasTransactionControl)) { + return yield* executeSequentially("ROLLBACK"); + } + + let pending: Array = []; + // Error positions stay global when incompatible statements split the batches. + let executed = 0; + + const flushBatch = (final: boolean) => + Effect.gen(function* () { + const recordVersion = final && options.history !== undefined; + const trailingRestore = final ? restoreRole : undefined; + if (pending.length === 0 && !recordVersion && trailingRestore === undefined) return; + const batchStatements = pending; + const operations: Array = []; + // Injected role restores must not shift user-facing statement numbers. + const injectedBefore: Array = []; + let injected = 0; + let lastOpIsInjectedRestore = false; + for (const sql of batchStatements) { + operations.push({ sql }); + injectedBefore.push(injected); + lastOpIsInjectedRestore = false; + if (restoreRole !== undefined && revertsToLoginRole(sql)) { + injected += 1; + operations.push({ sql: restoreRole }); + injectedBefore.push(injected); + lastOpIsInjectedRestore = true; + } + } + if (trailingRestore !== undefined && !lastOpIsInjectedRestore) { + operations.push({ sql: trailingRestore }); + injectedBefore.push(injected); + injected += 1; + } + if (recordVersion) { + operations.push({ + sql: INSERT_MIGRATION_VERSION, + params: [options.history.version, options.history.name, statements], + }); + injectedBefore.push(injected); + } + const base = executed; + yield* session.execBatch(operations).pipe( + Effect.mapError((cause) => { + // A connection failure happened before there was a statement to attribute. + if (cause instanceof DbConnectError) return cause; + const raw = cause.statementIndex ?? 0; + const globalIndex = base + raw - (injectedBefore[raw] ?? injected); + return formatExecBatchError( + cause, + globalIndex, + operations[raw]?.sql ?? statements[globalIndex] ?? INSERT_MIGRATION_VERSION, + ); + }), + ); + pending = []; + executed += batchStatements.length; + }); + + for (const statement of statements) { + if (isPipelineIncompatible(statement)) { + // Commit pending work before running a statement forbidden in a batch. + yield* flushBatch(false); + const index = executed; + yield* session + .exec(statement) + .pipe(Effect.mapError((cause) => formatExecBatchError(cause, index, statement))); + executed += 1; + } else { + pending.push(statement); + } + } + yield* flushBatch(true); + }); + +export interface RenderedSqlUnit { + readonly name: string; + readonly sql: string; + readonly transactionMode: MigrationTransactionMode; +} + +/** + * Applies in-memory rendered SQL units in order without migration-history or + * per-unit connection-reset writes. + */ +export const applyRenderedSqlUnits = ( + session: DbSession, + units: ReadonlyArray, + mapError: (message: string, dbError?: DbExecError) => E, +): Effect.Effect => + Effect.forEach( + units, + (unit) => + execMigrationStatements(session, splitAndTrim(unit.sql), unit.transactionMode).pipe( + Effect.mapError((error) => + error instanceof DbConnectError + ? error + : mapError(errorMessage(error), formattedExecBatchDbError(error)), + ), + ), + { discard: true }, + ); + /** * Runs a single migration/seed file's statements (plus the optional history insert). * Statements run inside an implicitly transactional extended-protocol batch, @@ -618,157 +797,19 @@ const execMigrationBatch = ( // execution failure, tagged "exec" (as opposed to the "read" failure above, which // mirrors `NewMigrationFromFile`). Only execution failures get `CmdSuggestion`; // callers rely on this tag to replicate that split. - yield* Effect.gen(function* () { - const { statements, transactionMode } = parseMigrationContent(content); - const filename = path.basename(migrationPath); - const matches = MIGRATE_FILE_PATTERN.exec(filename); - const version = forceNoVersion ? "" : (matches?.[1] ?? ""); - const name = matches?.[2] ?? ""; - - const restoreRole = session.restoreRoleSql; - - const executeSequentially = (cleanup: string) => - Effect.gen(function* () { - for (const [index, statement] of statements.entries()) { - yield* session - .exec(statement) - .pipe(Effect.mapError((cause) => formatExecBatchError(cause, index, statement))); - if (restoreRole !== undefined && revertsToLoginRole(statement)) { - yield* session - .exec(restoreRole) - .pipe(Effect.mapError((cause) => formatExecBatchError(cause, index, restoreRole))); - } - } - if ( - restoreRole !== undefined && - !(statements.length > 0 && revertsToLoginRole(statements[statements.length - 1]!)) - ) { - yield* session - .exec(restoreRole) - .pipe( - Effect.mapError((cause) => - formatExecBatchError(cause, statements.length, restoreRole), - ), - ); - } - if (version.length > 0) { - yield* session - .query(INSERT_MIGRATION_VERSION, [version, name, statements]) - .pipe( - Effect.mapError((cause) => - formatExecBatchError(cause, statements.length, INSERT_MIGRATION_VERSION), - ), - ); - } - }).pipe( - Effect.tapError(() => - Effect.gen(function* () { - yield* session.exec(cleanup).pipe(Effect.ignore); - // Sequential statements ran outside a CLI transaction, so a failed - // file's `RESET ROLE` survives the cleanup; restore best-effort. - if (restoreRole !== undefined) { - yield* session.exec(restoreRole).pipe(Effect.ignore); - } - }), - ), - ); - - // The pg-delta directive is file-level execution metadata. Run the complete - // sequence on this session without adding transaction boundaries so session - // settings remain active for the nontransactional action. History is recorded - // only after every statement succeeds. A failed sequence gets a best-effort - // session reset because the generated trailing RESET ALL may not have run yet. - if (transactionMode === "none") { - return yield* executeSequentially("RESET ALL"); - } - - // A headerless file with authored transaction boundaries owns those semantics. - // Execute the statements exactly as written, clean up a failed authored - // transaction, and only send the history insert after every statement succeeds. - if (statements.some(hasTransactionControl)) { - return yield* executeSequentially("ROLLBACK"); - } - - // `executed` is the global statement index of the next statement to run, so the - // error context stays accurate across flushed batches and standalone statements - // (Go threads the same counter through `ExecBatch`). - let pending: Array = []; - let executed = 0; - - const flushBatch = (final: boolean) => - Effect.gen(function* () { - const recordVersion = final && version.length > 0; - const trailingRestore = final ? restoreRole : undefined; - if (pending.length === 0 && !recordVersion && trailingRestore === undefined) return; - const batchStatements = pending; - const operations: Array = []; - // Injected role restores don't count toward `At statement: N`; track how - // many precede each op so failures keep the file's own numbering (a - // mid-file restore inherits its host statement's index; the trailing - // restore and the history insert report the file's statement count). - const injectedBefore: Array = []; - let injected = 0; - let lastOpIsInjectedRestore = false; - for (const sql of batchStatements) { - operations.push({ sql }); - injectedBefore.push(injected); - lastOpIsInjectedRestore = false; - if (restoreRole !== undefined && revertsToLoginRole(sql)) { - injected += 1; - operations.push({ sql: restoreRole }); - injectedBefore.push(injected); - lastOpIsInjectedRestore = true; - } - } - if (trailingRestore !== undefined && !lastOpIsInjectedRestore) { - operations.push({ sql: trailingRestore }); - injectedBefore.push(injected); - injected += 1; - } - if (recordVersion) { - operations.push({ - sql: INSERT_MIGRATION_VERSION, - params: [version, name, statements], - }); - injectedBefore.push(injected); - } - const base = executed; - yield* session.execBatch(operations).pipe( - Effect.mapError((cause) => { - // The batch's connection failed, either on checkout or before any of - // it reached the wire: there is no failing statement to name, so the - // connect error is surfaced verbatim instead of `At statement: N`. - if (cause instanceof DbConnectError) return cause; - // `statementIndex` is set by every batch failure the driver raises; a - // session that omits it can only have failed before the first statement. - const raw = cause.statementIndex ?? 0; - const globalIndex = base + raw - (injectedBefore[raw] ?? injected); - return formatExecBatchError( - cause, - globalIndex, - operations[raw]?.sql ?? statements[globalIndex] ?? INSERT_MIGRATION_VERSION, - ); - }), - ); - pending = []; - executed += batchStatements.length; - }); - - for (const statement of statements) { - if (isPipelineIncompatible(statement)) { - // Flush the open batch, then run the incompatible statement on its own (no - // surrounding transaction) so PostgreSQL accepts it. - yield* flushBatch(false); - const index = executed; - yield* session - .exec(statement) - .pipe(Effect.mapError((cause) => formatExecBatchError(cause, index, statement))); - executed += 1; - } else { - pending.push(statement); - } - } - yield* flushBatch(true); + const { statements, transactionMode } = parseMigrationContent(content); + const matches = MIGRATE_FILE_PATTERN.exec(path.basename(migrationPath)); + const version = forceNoVersion ? "" : (matches?.[1] ?? ""); + const history = + version.length === 0 + ? undefined + : { + version, + name: matches?.[2] ?? "", + }; + yield* execMigrationStatements(session, statements, transactionMode, { + history, + sequentialFailureCleanup: "RESET ALL", }).pipe( Effect.mapError((error) => // A batch connection failure is not an execution failure: it keeps its own diff --git a/apps/cli/src/command-internal/migration-apply.unit.test.ts b/apps/cli/src/command-internal/migration-apply.unit.test.ts index 9e183196b3..e111c49a89 100644 --- a/apps/cli/src/command-internal/migration-apply.unit.test.ts +++ b/apps/cli/src/command-internal/migration-apply.unit.test.ts @@ -15,6 +15,7 @@ import { DbConnectError } from "./db-connection.errors.ts"; import type { DbBatchStatement, DbSession } from "./db-connection.service.ts"; import { applyMigrationFile, + applyRenderedSqlUnits, applySchemaFiles, hasTransactionControl, isPipelineIncompatible, @@ -127,6 +128,119 @@ const run = ( ); }).pipe(Effect.provide(BunServices.layer)); +describe("applyRenderedSqlUnits", () => { + it.effect("applies mixed transaction modes in unit order without history or reset writes", () => { + const { session, calls } = fakeSession(); + return applyRenderedSqlUnits( + session, + [ + { + name: "tables", + sql: "CREATE TABLE widgets (id bigint);\nALTER TABLE widgets ENABLE ROW LEVEL SECURITY;", + transactionMode: "transactional", + }, + { + name: "enum", + sql: "SET check_function_bodies = off;\nALTER TYPE mood ADD VALUE 'fine';", + transactionMode: "none", + }, + { + name: "grants", + sql: "GRANT SELECT ON TABLE widgets TO anon;", + transactionMode: "transactional", + }, + ], + (message) => new TestError({ message }), + ).pipe( + Effect.tap(() => + Effect.sync(() => { + expect(calls.map(({ kind }) => kind)).toEqual(["batch", "exec", "exec", "batch"]); + expect(executedSql(calls)).toEqual([ + "CREATE TABLE widgets (id bigint)", + "ALTER TABLE widgets ENABLE ROW LEVEL SECURITY", + "SET check_function_bodies = off", + "ALTER TYPE mood ADD VALUE 'fine'", + "GRANT SELECT ON TABLE widgets TO anon", + ]); + expect(executedSql(calls).some((sql) => sql === "RESET ALL")).toBe(false); + expect( + calls.some( + ({ sql }) => sql.includes("supabase_migrations") || sql.includes("schema_migrations"), + ), + ).toBe(false); + expect(calls.some(({ kind }) => kind === "query")).toBe(false); + }), + ), + ); + }); + + it.effect("maps transactional failures with the unit-local statement index", () => { + const { session, calls } = fakeSession({ failOn: "missing_column" }); + return applyRenderedSqlUnits( + session, + [ + { + name: "broken", + sql: "SELECT 1;\nSELECT missing_column;\nSELECT 3;", + transactionMode: "transactional", + }, + { + name: "not_reached", + sql: "SELECT 4;", + transactionMode: "transactional", + }, + ], + (message) => new TestError({ message }), + ).pipe( + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error.message).toContain("At statement: 1"); + expect(error.message).toContain("SELECT missing_column"); + expect(executedSql(calls)).not.toContain("SELECT 4"); + }), + ), + ); + }); + + it.effect( + "restores a stepped-down role after a sequential failure without resetting the unit", + () => { + const restoreRoleSql = "SET SESSION ROLE postgres"; + const { session, calls } = fakeSession({ + failOn: "missing_column", + restoreRoleSql, + }); + return applyRenderedSqlUnits( + session, + [ + { + name: "broken_nontransactional", + sql: "RESET ROLE;\nSELECT missing_column;", + transactionMode: "none", + }, + ], + (message) => new TestError({ message }), + ).pipe( + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error.message).toContain("At statement: 1"); + expect(executedSql(calls)).toEqual([ + "RESET ROLE", + restoreRoleSql, + "SELECT missing_column", + restoreRoleSql, + ]); + expect(executedSql(calls)).not.toContain("RESET ALL"); + expect(calls.some(({ kind }) => kind === "query")).toBe(false); + }), + ), + ); + }, + ); +}); + describe("applyMigrationFile", () => { it.effect( "creates the history table, then runs the statements + history insert in a transaction", diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.errors.ts b/apps/cli/src/commands/db/schema/declarative/declarative.errors.ts index 69151e5f4f..324743e2d0 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.errors.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.errors.ts @@ -71,6 +71,45 @@ export class DeclarativeInvalidDbUrlError extends Data.TaggedError("DeclarativeI } } +/** A migration stem would escape the migration directory or duplicate the SQL suffix. */ +export class DeclarativeInvalidMigrationStemError extends Data.TaggedError( + "DeclarativeInvalidMigrationStemError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} + +/** Transient apply needs explicit consent when no interactive prompt is available. */ +export class DeclarativeTransientConfirmationRequiredError extends Data.TaggedError( + "DeclarativeTransientConfirmationRequiredError", +)<{ + readonly message: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +/** + * `--transient` plans against the already-running local database and must not + * `db start` as a side effect (fresh-volume start would migrate, seed, and + * record history before the user confirms the planned SQL). + */ +export class DeclarativeLocalDbNotRunningError extends Data.TaggedError( + "DeclarativeLocalDbNotRunningError", +)<{ + readonly message: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.startStack; + } +} + /** * `db schema declarative generate` ran but produced no declarative files (sync's * post-generate guard). Byte-matches Go's @@ -161,10 +200,3 @@ export function readErrorSuggestion(error: unknown): string | undefined { const { suggestion } = error as { suggestion: unknown }; return typeof suggestion === "string" ? suggestion : undefined; } - -/** - * Materializing the declarative export on disk failed. Byte-matches Go's - * `WriteDeclarativeSchemas` errors (`declarative.go:239`): - * `"failed to clean declarative schema directory: " + err` and - * `"unsafe declarative export path: " + path`. - */ diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.flow.ts b/apps/cli/src/commands/db/schema/declarative/declarative.flow.ts index 4c49b6b2f3..eb38a1133c 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.flow.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.flow.ts @@ -49,6 +49,20 @@ export function resolveDeclarativeMigrationName(name: string, file: string): str return name.length > 0 ? name : file; } +export function validateDeclarativeMigrationStem(stem: string): string | undefined { + const candidate = stem.trim(); + if (candidate.includes("/") || candidate.includes("\\")) { + return "migration names must not contain path separators"; + } + if (/\.sql$/i.test(candidate)) { + return "migration names must not include the .sql suffix"; + } + if (candidate !== stem) { + return "migration names must not have leading or trailing whitespace"; + } + return undefined; +} + /** Whether sync applies the generated migration, prompts, or skips. */ export type DeclarativeApplyDecision = "apply" | "skip" | "prompt"; diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.flow.unit.test.ts b/apps/cli/src/commands/db/schema/declarative/declarative.flow.unit.test.ts index 395c05bca8..a509bc72fd 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.flow.unit.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.flow.unit.test.ts @@ -10,6 +10,7 @@ import { resolveDeclarativeMigrationName, resolveDeclarativeSyncApplyDecision, resolveStagedDeclarativeDir, + validateDeclarativeMigrationStem, } from "./declarative.flow.ts"; const stuck = (message: string) => ({ @@ -448,6 +449,23 @@ describe("resolveDeclarativeMigrationName", () => { }); }); +describe("validateDeclarativeMigrationStem", () => { + it.each([ + ["nested/name", "migration names must not contain path separators"], + ["nested\\name", "migration names must not contain path separators"], + ["change.sql", "migration names must not include the .sql suffix"], + ["change.SQL", "migration names must not include the .sql suffix"], + ["change.SQL ", "migration names must not include the .sql suffix"], + [" add_users ", "migration names must not have leading or trailing whitespace"], + ])("rejects %j", (stem, expected) => { + expect(validateDeclarativeMigrationStem(stem)).toBe(expected); + }); + + it("accepts a plain migration stem", () => { + expect(validateDeclarativeMigrationStem("add_customer_status")).toBeUndefined(); + }); +}); + describe("resolveDeclarativeSyncApplyDecision", () => { it.each([ ["--no-apply wins", { apply: true, noApply: true, yes: true, tty: true }, "skip"], diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts index 66311e5435..0dcaa3367c 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts @@ -9,11 +9,14 @@ import type { DbTomlValues } from "../../../../command-internal/db-config.toml-r import { PgDeltaEngine, type PgDeltaDeclarativePlanInput, + PgDeltaEngineError, } from "../../shared/pgdelta-engine.service.ts"; +import { DeclarativeCompatibilityError } from "./declarative.errors.ts"; import { type DeclarativeRunContext, diffDeclarativeToMigrations, generateDeclarativeOutput, + planDeclarativeToDatabase, } from "./declarative.orchestrate.ts"; const ctx = (cwd: string, declarativeDir: string): DeclarativeRunContext => ({ @@ -217,6 +220,115 @@ describe("diffDeclarativeToMigrations", () => { }); }); +describe("planDeclarativeToDatabase", () => { + it.effect("forwards the database source through the shared planning path", () => { + const dir = mkdtempSync(join(tmpdir(), "decl-orch-")); + const declDir = join(dir, "supabase", "database"); + mkdirSync(declDir, { recursive: true }); + writeFileSync(join(declDir, "public.sql"), "drop table public.accounts;"); + const calls: PgDeltaDeclarativePlanInput[] = []; + const engine = Layer.succeed( + PgDeltaEngine, + PgDeltaEngine.of({ + diffExplicit: () => Effect.die("diffExplicit not used"), + diffDatabase: () => Effect.die("diffDatabase not used"), + exportDeclarativeSchema: () => Effect.die("exportDeclarativeSchema not used"), + planDeclarativeSchema: (input) => { + calls.push(input); + return Effect.succeed({ + changes: true, + sql: "drop table public.accounts;", + files: [], + sourceRef: "pg-delta-next:database", + targetRef: "pg-delta-next:declarative", + }); + }, + }), + ); + const source = { + kind: "database" as const, + ref: "postgresql://postgres:secret@localhost/postgres", + connectOptions: { isLocal: true, dnsResolver: "native" as const }, + }; + + return planDeclarativeToDatabase(ctx(dir, declDir), toml, source).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(calls).toHaveLength(1); + expect(calls[0]?.source).toBe(source); + expect(calls[0]?.files).toEqual([ + { name: "public.sql", sql: "drop table public.accounts;" }, + ]); + expect(result).toMatchObject({ + diffSQL: "drop table public.accounts;", + sourceRef: "pg-delta-next:database", + targetRef: "pg-delta-next:declarative", + manifestPresent: false, + removals: { extensions: [], extensionIntents: [] }, + }); + expect(result.sourceRef).not.toContain("secret"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + Effect.provide(Layer.mergeAll(engine, BunServices.layer)), + ); + }); + + it.effect("maps declarative load failures through the shared compatibility gate", () => { + const dir = mkdtempSync(join(tmpdir(), "decl-orch-")); + const declDir = join(dir, "supabase", "database"); + mkdirSync(declDir, { recursive: true }); + writeFileSync(join(declDir, "members.sql"), "select extensions.uuid_generate_v4();"); + const engine = Layer.succeed( + PgDeltaEngine, + PgDeltaEngine.of({ + diffExplicit: () => Effect.die("diffExplicit not used"), + diffDatabase: () => Effect.die("diffDatabase not used"), + exportDeclarativeSchema: () => Effect.die("exportDeclarativeSchema not used"), + planDeclarativeSchema: () => + Effect.fail( + new PgDeltaEngineError({ + message: "declarative load did not converge", + cause: "load failed", + diagnostics: [ + { + code: "max_rounds_exceeded", + severity: "error", + message: "members.sql: function extensions.uuid_generate_v4() does not exist", + }, + ], + }), + ), + }), + ); + const source = { + kind: "database" as const, + ref: "postgresql://postgres@localhost/postgres", + connectOptions: { isLocal: true, dnsResolver: "native" as const }, + }; + + return planDeclarativeToDatabase(ctx(dir, declDir), toml, source).pipe( + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error).toBeInstanceOf(DeclarativeCompatibilityError); + if (error instanceof DeclarativeCompatibilityError) { + expect(error.loadFindings).toEqual([ + expect.objectContaining({ + extension: "uuid-ossp", + file: "members.sql", + line: 1, + }), + ]); + } + rmSync(dir, { recursive: true, force: true }); + }), + ), + Effect.provide(Layer.mergeAll(engine, BunServices.layer)), + ); + }); +}); + describe("generateDeclarativeOutput", () => { it.effect("propagates debug and strict coverage to the engine", () => { const calls: Array<{ diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.ts b/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.ts index 289808267e..777cce6fb0 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.ts @@ -71,9 +71,10 @@ const formatImplicitExtensionLoadFailure = ( * The pg-delta engine owns both sides of the plan, planning against its scoped * migrations/declarative shadows. */ -export const diffDeclarativeToMigrations = Effect.fnUntraced(function* ( +const planDeclarative = Effect.fnUntraced(function* ( run: DeclarativeRunContext, toml: DbTomlValues, + source?: PgDeltaDatabaseEndpoint, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -103,6 +104,7 @@ export const diffDeclarativeToMigrations = Effect.fnUntraced(function* ( files, noCache: run.noCache, toml, + ...(source !== undefined ? { source } : {}), ...(run.linkedProjectRef !== undefined ? { projectRef: run.linkedProjectRef } : {}), ...(manifest !== undefined ? { manifest } : {}), }) @@ -136,6 +138,17 @@ export const diffDeclarativeToMigrations = Effect.fnUntraced(function* ( } satisfies DeclarativeSyncResult; }); +/** Plans from the local migrations state to the declarative schema. */ +export const diffDeclarativeToMigrations = (run: DeclarativeRunContext, toml: DbTomlValues) => + planDeclarative(run, toml); + +/** Plans from a live database to the declarative schema without migration history. */ +export const planDeclarativeToDatabase = ( + run: DeclarativeRunContext, + toml: DbTomlValues, + source: PgDeltaDatabaseEndpoint, +) => planDeclarative(run, toml, source); + export const generateDeclarativeOutput = Effect.fnUntraced(function* ( run: DeclarativeRunContext, target: PgDeltaDatabaseEndpoint, diff --git a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts index 8877e5035a..03a06ae5f6 100644 --- a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts @@ -124,6 +124,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { Effect.sync(() => { ensureStartedCalls += 1; }), + isLocalDatabaseRunning: () => Effect.die("isLocalDatabaseRunning not used in generate tests"), ensureLocalPostgresImageCurrent: () => Effect.sync(() => { localPostgresImageChecks.push(true); diff --git a/apps/cli/src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md b/apps/cli/src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md index bebbb3fbb9..f5c71022a5 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/schema/declarative/sync/SIDE_EFFECTS.md @@ -1,9 +1,12 @@ # `supabase db schema declarative sync` -Diffs local migrations state against declarative schema files and writes the delta -as a new timestamped migration. +Diffs declarative schema files against either local migrations state or, with +`--transient`, the running local database. Durable sync writes timestamped +migrations; transient sync executes the plan directly without migration files or +migration-history rows. -Pg-delta runs in-process and uses two scoped shadow databases. Coverage gaps +Pg-delta runs in-process and uses two scoped shadow databases for durable sync, +or one declarative shadow when the running database is the transient source. Coverage gaps warn; `--strict-coverage` makes them fatal, while `PGDELTA_DEBUG` writes diagnostic JSON under `supabase/.temp/pgdelta/v2/debug//`. The engine may emit ordered @@ -19,7 +22,7 @@ disabling safe compaction. | --------------------------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `/supabase/config.toml` | TOML | always — pg-delta gate, format options | | `/supabase/schemas/**/*.sql` (default declarative dir) | SQL | always — must exist (else error) | -| `/supabase/migrations/*.sql` | SQL | applied to the live migrations shadow | +| `/supabase/migrations/*.sql` | SQL | durable sync only — applied to the live migrations shadow | | `/supabase/roles.sql` | SQL | hashed into the shadow-baseline cache key on every cache-eligible acquire, warm hits included, and applied to a cold shadow's baseline; missing file tolerated (hashed as empty) | | `/supabase/schemas/.pgdelta-export.json` | JSON | export metadata, when present | | `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | warm shadow-cache hit (migrations/declarative shadows); every cache-eligible acquire (warm hit and successful cold export) also enumerates and `stat`s every `shadow-baseline-*.tar` for LRU keep-3 + 2-day mtime TTL and may delete other keys (`SUPABASE_HOME` overrides the `~/.supabase` root) | @@ -29,7 +32,7 @@ disabling safe compaction. | Path | Format | When | | --------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/migrations/_[_].sql` | SQL | changes; bundled engine may emit ordered segments | +| `/supabase/migrations/_[_].sql` | SQL | durable changes only; bundled engine may emit ordered segments. Never written by `--transient` | | `/supabase/schemas/extension.sql` | SQL | accepted legacy-extension repair | | `/supabase/.temp/pgdelta/v2/debug//*.json` | JSON | bundled engine with `PGDELTA_DEBUG` | | `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar` | tar | cache-enabled COLD shadow provision creates the current key's snapshot — migrations/declarative shadows (`--no-cache` bypasses the snapshot cache entirely — neither read nor written); a warm hit `touch`es its mtime (LRU); every cache-eligible acquire may delete other keys under LRU keep-3 + 2-day mtime TTL — ~90MB (`SUPABASE_HOME` overrides the root) | @@ -37,10 +40,11 @@ disabling safe compaction. ## Subprocesses / Containers -| What | When | -| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -| Two natively-provisioned shadows (migrated source + declarative target) via `acquireShadowDatabase` — ephemeral host ports, settings-keyed global baseline cache | always | -| `docker`/`podman` container recreate for the local `db` (+ satellite restarts, Kong reload) — the same primitives `db start`/`db reset` use, via `resetLocalDatabase` — only on the failed-apply recovery path | TTY only, apply failed, and the user confirms "reset and reapply" | +| What | When | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| Natively-provisioned shadows via `acquireShadowDatabase` — migrated source + declarative target for durable sync, declarative target only for `--transient`; ephemeral host ports, settings-keyed cache | always | +| Direct SQL execution on the running local database, preserving each rendered unit's transaction mode and omitting migration-history/reset SQL | `--transient`, after confirmation or `--yes`; the local `db` container must already be running — `--transient` never calls `db start` | +| `docker`/`podman` container recreate for the local `db` (+ satellite restarts, Kong reload) — the same primitives `db start`/`db reset` use, via `resetLocalDatabase` — only on the failed-apply recovery path | TTY only, apply failed, and the user confirms "reset and reapply" | ## Environment Variables @@ -55,15 +59,17 @@ disabling safe compaction. ## Exit Codes -| Code | Condition | -| ---- | --------------------------------------------------------------------------------------------------- | -| `0` | success (migration created, applied, or "No schema changes found") | -| `1` | pg-delta not enabled | -| `1` | conflicting `--apply`/`--no-apply` (mutually exclusive) | -| `1` | no declarative schema files found | -| `1` | shadow-database / selected pg-delta engine / diff failure | -| `1` | apply failure (when applied) — propagated from the native migration apply (`applyMigrationToLocal`) | -| `1` | repairable legacy extension omissions in non-interactive mode | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------- | +| `0` | success (migration created, applied, or "No schema changes found") | +| `1` | pg-delta not enabled | +| `1` | conflicting flags, including `--transient` with `--no-apply`, `--file`, `--name`, or `--apply=false` | +| `1` | `--transient` when the local database container is not already running | +| `1` | `--transient` without `--yes` when no TTY is available or machine output is selected | +| `1` | no declarative schema files found | +| `1` | shadow-database / selected pg-delta engine / diff failure | +| `1` | apply failure (when applied) — propagated from the native migration apply (`applyMigrationToLocal`) | +| `1` | repairable legacy extension omissions in non-interactive mode | The pg-delta gate and the mutex check are both raised before any side effects run, but the gate wins when both conditions apply simultaneously: the gate check runs @@ -72,13 +78,22 @@ first, so a closed gate (missing `--experimental`) surfaces before an ## Output -Text mode only. The generated SQL, the created-migration path, drop-statement -warnings, and apply status are written to stderr. The no-files bootstrap also +Durable text mode writes generated SQL, created-migration paths, drop-statement +warnings, and apply status to stderr. Transient text mode writes the exact +ordered SQL to stdout before confirmation and again after successful execution; +diagnostics and warnings stay on stderr. JSON and stream-json transient results +include `changed`, `applied`, `migration_written`, `history_recorded`, +flattened `sql`, and ordered `units` with name, transaction mode, and SQL. +Failures after planning attach the same plan to the structured error envelope. +The no-files bootstrap also prints `Declarative schema written to ` (the relative declarative dir) to stderr after generating and writing — on both interactive and `--yes` paths. `--no-apply` writes the migration only (never prompts/applies); `--apply` applies without prompting; both override the global `--yes`. `--no-apply` and `--apply` are mutually exclusive. +`--transient` is local-only, requires an already-running local database, a text-mode TTY confirmation or `--yes`, and never +bootstraps a missing declarative tree. Redundant `--apply=true` is accepted but +does not provide consent. A stopped local database is refused (`supabase start is not running`) rather than auto-started. A manifest-less CLI tree is refused by two compatibility gates — one when the tree fails to load on the bundled engine's shadow, one when the plan drops an @@ -102,7 +117,9 @@ existing SQL or creates an export manifest. - Requires `--experimental` or `[experimental.pgdelta] enabled = true`. - `--file` sets the migration filename stem (default `declarative_sync`); `--name` - overrides it. In a TTY without `--name`/`--yes`, the name is prompted. + overrides it. Stems cannot contain either path separator or a case-insensitive + `.sql` suffix. In a TTY without `--name`/`--yes`, the name is prompted and + invalid input is re-prompted. - When no declarative files exist, a TTY offers to generate them (from local) first. - The declarative directory is the complete desired state: omitted objects, including extensions, are removals. Use `generate --output-dir ` @@ -118,21 +135,33 @@ existing SQL or creates an export manifest. or an export manifest, a WARNING on stderr explains the default move and how to keep the existing tree. Read-only probe; never changes behavior or exit codes (a non-interactive run still fails with "no declarative schema found"). -- The migration apply is native (connects to the local DB and records migration - history). On apply failure a debug bundle is written under - `supabase/.temp/pgdelta/debug/` and, in a TTY, a reset-and-reapply is offered - (the reset itself is native too — `resetLocalDatabase` — run in-process, +- Durable migration apply is native (connects to the local DB and records migration + history). On apply or image-preflight failure a debug bundle is written under + `supabase/.temp/pgdelta/debug/` before cleanup. Accepted reset/reapply and reset + failures preserve generated files. Once any generated segment is recorded in + migration history, all generated files are preserved. Otherwise, declined recovery + asks whether to keep them (default No); non-TTY and `--yes` failures delete all files + generated by that invocation. A debug-bundle failure preserves them, and cleanup + failures only warn. Image-preflight failures use a distinct preflight message while + following the same bundle and cleanup rules. In a TTY, a reset-and-reapply is offered + after image preflight succeeds and local apply is attempted, including connection + failures before SQL execution (the reset itself is native too — + `resetLocalDatabase` — run in-process, sharing this command's own telemetry/linked-project-cache finalizer cycle rather than firing a second one from a child process). -- **Architecture:** the engine plans and renders in-process from two live - shadows. +- A transient execution failure saves the planned SQL, warns that earlier or + nontransactional units may have applied, and requires rerunning to re-plan. + Reset-and-replay is never offered because no durable migration exists. +- **Architecture:** the engine plans and renders in-process from two live shadows + for durable sync and from the running local database plus one declarative shadow + for transient sync. - **Stale local-container guard.** Before diffing against the running local `db` target, the running container's actual image is inspected and compared - against the currently-configured/resolved one. A same-tag family mismatch - (slim vs docker.io, e.g. after toggling `SUPABASE_USE_SLIM_IMAGES` without - restarting) fails with a suggestion to `supabase stop` then `supabase start` - with the same flag. A real version/tag mismatch still suggests - `supabase stop --all --no-backup` then `supabase start`. + against the currently-configured/resolved one. Same-major tag and slim/docker.io + family changes use data-preserving `supabase stop` then `supabase start`. A proven + Postgres-major upgrade **or** a standard↔OrioleDB storage-engine change uses + `supabase stop --all --no-backup` then `supabase start` and explicitly warns that + local data will be deleted. ### Shadow baseline cache (`SUPABASE_SHADOW_CACHE`, default ON) diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.command.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.command.ts index 2bb511c109..b94a32fd96 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.command.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.command.ts @@ -47,6 +47,12 @@ const config = { ), Flag.optional, ), + transient: Flag.boolean("transient").pipe( + Flag.withDescription( + "Apply declarative schema changes directly to the already-running local database without writing migration files or migration history. Does not start a stopped local database.", + ), + Flag.optional, + ), } as const; // `--no-cache` is a shared flag on the `declarative` group (read from the parent), @@ -58,9 +64,9 @@ export type DbSchemaDeclarativeSyncFlags = CliCommand.Command.Config.Infer Effect.gen(function* () { // `--no-cache` is shared on the parent group; read the resolved value there. @@ -80,6 +86,7 @@ export const dbSchemaDeclarativeSyncCommand = Command.make("sync", config).pipe( name: merged.name, apply: merged.apply, "no-apply": merged.noApply, + transient: merged.transient, }, // Go registers `--schema`/`-s` (StringSliceVarP) and `--file`/`-f` // (StringVarP) (`cmd/db_schema_declarative.go:484-485`); telemetry reports diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts index 7faf3976f0..fbbce95b90 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts @@ -1,4 +1,4 @@ -import { Cause, Clock, Effect, Exit, FileSystem, Option, Path, Result } from "effect"; +import { Cause, Clock, Effect, Exit, FileSystem, Option, Path, Ref, Result } from "effect"; import { DnsResolverFlag, @@ -6,11 +6,13 @@ import { resolveYesWithProjectEnv, } from "../../../../../command-internal/global-flags.ts"; import { promptYesNo } from "../../../../../command-internal/prompt-yes-no.ts"; +import { MachineErrorContext } from "../../../../../shared/output/machine-error-context.service.ts"; import { Output } from "../../../../../shared/output/output.service.ts"; import { Tty } from "../../../../../shared/runtime/tty.service.ts"; import { CommandSettings } from "../../../../../config/command-settings.service.ts"; import { resetLocalDatabase } from "../../../../../command-internal/db-bootstrap/reset-local-database.ts"; -import { bold, red, yellow } from "../../../../../command-internal/colors.ts"; +import { aqua, bold, red, yellow } from "../../../../../command-internal/colors.ts"; +import { DbConnectError } from "../../../../../command-internal/db-connection.errors.ts"; import { DbConnection } from "../../../../../command-internal/db-connection.service.ts"; import { getHostname } from "../../../../../command-internal/hostname.ts"; import { @@ -18,8 +20,10 @@ import { readDbToml, resolveDeclarativeDir, } from "../../../../../command-internal/db-config.toml-read.ts"; -import { makeDir } from "../../../../../command-internal/make-dir.ts"; -import { applyMigrationFile } from "../../../../../command-internal/migration-apply.ts"; +import { + applyMigrationFile, + applyRenderedSqlUnits, +} from "../../../../../command-internal/migration-apply.ts"; import { ENABLE_LOCAL_WEBHOOKS_SUGGESTION } from "../../../../../command-internal/pg-net-guidance.ts"; import { readProjectRefFile } from "../../../../../command-internal/temp-paths.ts"; import { LinkedProjectCache } from "../../../../../telemetry/linked-project-cache.service.ts"; @@ -34,17 +38,23 @@ import { writePgDeltaMigrations } from "../../../shared/pgdelta-migrations.write import { localEndpoint, resolveSmartTargetEndpoint } from "../declarative.smart-target.ts"; import { type DebugBundle, + type DebugBundleResult, collectMigrationsList, debugBundleMessage, formatDebugId, saveDebugBundle, } from "../../../shared/debug-bundle.ts"; +import { ListPgDeltaSqlFiles } from "../../../shared/pgdelta-files.ts"; import { DeclarativeApplyError, DeclarativeCompatibilityError, + DeclarativeDiffError, + DeclarativeInvalidMigrationStemError, + DeclarativeLocalDbNotRunningError, DeclarativeMutuallyExclusiveFlagsError, DeclarativeNoFilesGeneratedError, DeclarativeNonInteractiveError, + DeclarativeTransientConfirmationRequiredError, readErrorSuggestion, } from "../declarative.errors.ts"; import { @@ -56,6 +66,7 @@ import { resolveStagedDeclarativeDir, resolveDeclarativeMigrationName, resolveDeclarativeSyncApplyDecision, + validateDeclarativeMigrationStem, } from "../declarative.flow.ts"; import { warnFormerDeclarativeDefault } from "../declarative.former-default.ts"; import { appendExtensionDeclarations } from "../declarative.extension-repair.ts"; @@ -65,6 +76,7 @@ import { type DeclarativeSyncResult, diffDeclarativeToMigrations, generateDeclarativeOutput, + planDeclarativeToDatabase, } from "../declarative.orchestrate.ts"; import { DeclarativeSeam } from "../../../shared/pgdelta.seam.service.ts"; import { @@ -76,14 +88,11 @@ import type { DbSchemaDeclarativeSyncFlags } from "./sync.command.ts"; const DEFAULT_SYNC_NAME = "declarative_sync"; -/** Go's `GetCurrentTimestamp`: UTC `YYYYMMDDHHmmss`. */ -const formatTimestamp = (millis: number): string => - new Date(millis).toISOString().replace(/\D/g, "").slice(0, 14); - export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(function* ( flags: DbSchemaDeclarativeSyncFlags, ) { const output = yield* Output; + const machineErrorContext = yield* Effect.serviceOption(MachineErrorContext); const tty = yield* Tty; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -141,6 +150,49 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f }), ); } + const transient = Option.getOrElse(flags.transient, () => false); + if (transient) { + if (Option.isSome(flags.apply) && !flags.apply.value) { + return yield* Effect.fail( + new DeclarativeMutuallyExclusiveFlagsError({ + message: "--transient cannot be combined with --apply=false", + }), + ); + } + const conflicts: Array = []; + if (Option.isSome(flags.noApply)) conflicts.push("no-apply"); + if (Option.isSome(flags.file)) conflicts.push("file"); + if (Option.isSome(flags.name)) conflicts.push("name"); + if (conflicts.length > 0) { + return yield* Effect.fail( + new DeclarativeMutuallyExclusiveFlagsError({ + message: `--transient cannot be combined with ${conflicts + .map((flag) => `--${flag}`) + .join(", ")}`, + }), + ); + } + } + if (Option.isSome(flags.file)) { + const validation = validateDeclarativeMigrationStem(flags.file.value); + if (validation !== undefined) { + return yield* Effect.fail( + new DeclarativeInvalidMigrationStemError({ + message: `invalid --file value: ${validation}`, + }), + ); + } + } + if (Option.isSome(flags.name)) { + const validation = validateDeclarativeMigrationStem(flags.name.value); + if (validation !== undefined) { + return yield* Effect.fail( + new DeclarativeInvalidMigrationStemError({ + message: `invalid --name value: ${validation}`, + }), + ); + } + } // Go's `utils.GetDeclarativeDir()` — the config value verbatim (already // `supabase/`-prefixed when relative) or the relative `supabase/schemas` @@ -180,7 +232,7 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f }; const ensureLocalPostgresImageCurrent = seam.ensureLocalPostgresImageCurrent(); yield* warnFormerDeclarativeDefault(fs, path, cliSettings.workdir, toml.pgDelta); - const declarativeFilesExist = yield* declarativeDirHasFiles(fs, declarativeDir); + const declarativeFilesExist = yield* declarativeDirHasSqlFiles(fs, declarativeDir); // Go's `saveApplyDebugBundle`: warn (rather than masking the apply error) and // treat the bundle path as empty when the debug directory cannot be created, so @@ -193,8 +245,18 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f onFailure: (error) => output .raw(`Warning: failed to save debug artifacts: ${error.message}\n`, "stderr") - .pipe(Effect.as("")), - onSuccess: Effect.succeed, + .pipe( + Effect.as({ + directory: "", + migrationSqlSaved: false, + } satisfies DebugBundleResult), + ), + onSuccess: (result) => + result.migrationSqlSaved + ? Effect.succeed(result) + : output + .raw("Warning: failed to save generated SQL debug artifact.\n", "stderr") + .pipe(Effect.as(result)), }), ); @@ -203,6 +265,7 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f const noFiles = new DeclarativeNonInteractiveError({ message: "no declarative schema found. Run supabase db schema declarative generate first", }); + if (transient) return yield* Effect.fail(noFiles); if (!tty.stdinIsTty && !yes) return yield* Effect.fail(noFiles); // Go asks via Console.PromptYesNo (db_schema_declarative.go:381, default // true): --yes/SUPABASE_YES auto-confirms WITH the `