diff --git a/apps/cli/src/command-internal/migration-apply.ts b/apps/cli/src/command-internal/migration-apply.ts index 937709cd4f..d97a34a60f 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"; /** * A migration file failed to apply. Used by `migration up`/`down`'s migrate-and-seed step; the @@ -382,6 +382,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); + } + }), + ), + ); + + // Nontransactional units stay on one session; sequentialFailureCleanup is optional. + 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. * @@ -435,152 +614,20 @@ const execMigrationBatch = ( // Every failure from here on is an execution failure, tagged "exec" (vs. the "read" failures // above) — only execution failures get a suggestion attached; callers rely on this tag. - 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); - } - }), - ), - ); - - // Session settings must remain active for the nontransactional action, so no transaction - // boundary is added around this branch. - if (transactionMode === "none") { - return yield* executeSequentially("RESET ALL"); - } - - // A file with authored transaction boundaries owns those semantics; execute statements - // exactly as written and only record history after they all succeed. - if (statements.some(hasTransactionControl)) { - return yield* executeSequentially("ROLLBACK"); - } - - // The global statement index of the next statement to run, so error context stays accurate - // across flushed batches and standalone statements. - 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 filename = path.basename(migrationPath); + const matches = MIGRATE_FILE_PATTERN.exec(filename); + 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 e1b33615b0..5325cb7266 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 8c3f4541a7..1992c52385 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.errors.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.errors.ts @@ -62,6 +62,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); message text is an established output contract. 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 e3b9301104..60b3c48eeb 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.flow.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.flow.ts @@ -45,6 +45,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 a0277b4aaf..9520f75f26 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.ts @@ -67,13 +67,13 @@ const formatImplicitExtensionLoadFailure = ( }); /** - * Computes the diff between local migrations state and the declarative schema. - * The pg-delta engine owns both sides of the plan, planning against its scoped - * migrations/declarative shadows. + * Plans declarative schema against the migrations and declarative shadows, or + * against a live source plus one declarative shadow when `source` is set. */ -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 +103,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 +137,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/SIDE_EFFECTS.md b/apps/cli/src/commands/db/schema/declarative/generate/SIDE_EFFECTS.md index 78945c5395..83115d3194 100644 --- a/apps/cli/src/commands/db/schema/declarative/generate/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/schema/declarative/generate/SIDE_EFFECTS.md @@ -92,8 +92,8 @@ always go to stderr, in every `--output-format`. On success: - **Architecture:** the engine extracts and renders the target in-process. - **Stale local-container guard.** `--local`/smart-mode's Local target inspects the running local `db` container's actual image and compares it against the - currently-configured/resolved one before reading from it. 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`. + currently-configured/resolved one before reading from it. 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. 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 862855f61e..8643f43846 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..6d71b1ffc7 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,18 +32,20 @@ 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/debug//` | dir | durable apply or image-preflight failure, and transient execution failure; warns and omits the path when the directory cannot be created | | `/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) | | `~/.supabase/cache/shadow-baseline/shadow-baseline-.tar..partial` | tar | during a cold export — the in-flight temp file, `rename`d into the tar above on success and removed on failure; only a crash/SIGKILL leaves it behind, and later cold exports / warm hits sweep leftovers older than 5 minutes | ## 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 +60,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 or image-preflight failure — native local apply (`applyMigrationToLocal` or `applyRenderedSqlToLocal`) | +| `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 +79,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 +118,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 +136,28 @@ 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, - 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. +- 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/`. Generated migration files from this invocation + are kept. Image-preflight failures use a distinct preflight message. 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). +- 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 c196f92522..b5f0bf5b22 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 @@ -42,6 +42,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), @@ -53,9 +59,9 @@ export type DbSchemaDeclarativeSyncFlags = CliCommand.Command.Config.Infer Effect.gen(function* () { // `--no-cache` is shared on the parent group; read the resolved value there. @@ -75,6 +81,7 @@ export const dbSchemaDeclarativeSyncCommand = Command.make("sync", config).pipe( name: merged.name, apply: merged.apply, "no-apply": merged.noApply, + transient: merged.transient, }, // Telemetry reports changed flags by canonical name, so map the shorthands: `sync // -s public -f out.sql` must log `schema`/`file`. diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.e2e.test.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.e2e.test.ts index 532ad6bce9..c64dd67c2e 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.e2e.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.e2e.test.ts @@ -1,9 +1,10 @@ -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; -import { afterAll, beforeAll, expect, test } from "vitest"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { describe } from "vitest"; import { + formatCliFailure, + listMigrationSqlFiles, makeTempCliStackProject, overrideStackPorts, requireCliSuccess, @@ -31,19 +32,6 @@ select id, email from auth.users; `; -function commandFailure(result: { stdout: string; stderr: string }): string { - return `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`; -} - -function migrationFiles(projectDir: string): ReadonlyArray { - const migrationsDir = path.join(projectDir, "supabase", "migrations"); - return existsSync(migrationsDir) - ? readdirSync(migrationsDir) - .filter((file) => file.endsWith(".sql")) - .sort() - : []; -} - describe("db schema declarative sync (e2e)", () => { let project: Awaited> | undefined; @@ -139,9 +127,9 @@ describe("db schema declarative sync (e2e)", () => { exitTimeoutMs: SCENARIO_COMMAND_TIMEOUT_MS, }, ); - expect(sync.exitCode, commandFailure(sync)).toBe(0); + expect(sync.exitCode, formatCliFailure(sync)).toBe(0); - const migrations = migrationFiles(projectDir); + const migrations = listMigrationSqlFiles(projectDir); expect(migrations.length).toBeGreaterThan(0); const sql = migrations .map((file) => readFileSync(path.join(projectDir, "supabase", "migrations", file), "utf8")) @@ -167,7 +155,7 @@ describe("db schema declarative sync (e2e)", () => { exitTimeoutMs: SCENARIO_COMMAND_TIMEOUT_MS, }, ); - expect(converged.exitCode, commandFailure(converged)).toBe(0); + expect(converged.exitCode, formatCliFailure(converged)).toBe(0); expect(`${converged.stdout}${converged.stderr}`).toContain("No schema changes found"); // Extension-managed objects on the converged tree. The stack teardown after every test @@ -184,10 +172,10 @@ describe("db schema declarative sync (e2e)", () => { // A next-engine plan may span several ordered migration files; read every // file a sync added rather than only the last one. const syncAndReadSql = async (name: string) => { - const before = new Set(migrationFiles(projectDir)); + const before = new Set(listMigrationSqlFiles(projectDir)); const result = await runSync(name); - expect(result.exitCode, commandFailure(result)).toBe(0); - const added = migrationFiles(projectDir).filter((file) => !before.has(file)); + expect(result.exitCode, formatCliFailure(result)).toBe(0); + const added = listMigrationSqlFiles(projectDir).filter((file) => !before.has(file)); expect(added.length, "sync did not write a migration").toBeGreaterThan(0); return { result, 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 f061a8e813..63b8c4122c 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 @@ -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"; @@ -39,12 +43,17 @@ import { 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 +65,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 +75,7 @@ import { type DeclarativeSyncResult, diffDeclarativeToMigrations, generateDeclarativeOutput, + planDeclarativeToDatabase, } from "../declarative.orchestrate.ts"; import { DeclarativeSeam } from "../../../shared/pgdelta.seam.service.ts"; import { @@ -76,14 +87,11 @@ import type { DbSchemaDeclarativeSyncFlags } from "./sync.command.ts"; const DEFAULT_SYNC_NAME = "declarative_sync"; -/** UTC timestamp format `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; @@ -125,6 +133,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}`, + }), + ); + } + } // The config value verbatim (already `supabase/`-prefixed when relative) or the relative // `supabase/schemas` default; printed verbatim in the bootstrap's written-to line below. @@ -160,7 +211,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); // Warns (rather than masking the apply error) and treats the bundle path as empty when the // debug directory cannot be created, so an apply failure still surfaces without claiming a @@ -172,7 +223,7 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f output .raw(`Warning: failed to save debug artifacts: ${error.message}\n`, "stderr") .pipe(Effect.as("")), - onSuccess: Effect.succeed, + onSuccess: (directory) => Effect.succeed(directory), }), ); @@ -181,6 +232,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); // `--yes`/`SUPABASE_YES` auto-confirms, but still echoes the `