diff --git a/frontend/src/lib/components/DBManagerContent.svelte b/frontend/src/lib/components/DBManagerContent.svelte index 9c8dbfbfc4..a1e853801f 100644 --- a/frontend/src/lib/components/DBManagerContent.svelte +++ b/frontend/src/lib/components/DBManagerContent.svelte @@ -20,6 +20,10 @@ import type { SelectedTable } from './DBManager.svelte' import { getDbFeatures } from './apps/components/display/dbtable/dbFeatures' import { resource } from 'runed' + import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte' + import { createAsyncConfirmationModal } from './common/confirmationModal/asyncConfirmationModal.svelte' + import Portal from '$lib/components/Portal.svelte' + import { outOfOrderRunMessage } from './workspaceSettings/datatableMigrationUtils' interface Props { input?: DbInput @@ -52,6 +56,8 @@ let dbSchema: DBSchema | undefined = $derived(input && $dbSchemas[getDbSchemasPath(input)]) + const outOfOrderModal = createAsyncConfirmationModal() + function getDbSchemasPath(input: DbInput): string { switch (input.type) { case 'database': @@ -163,7 +169,13 @@ })} dbSchemaOps={dbSchemaOpsWithPreviewScripts({ input: _input, - workspace: $workspaceStore + workspace: $workspaceStore, + confirmRunOutOfOrder: (pending) => + outOfOrderModal.ask({ + title: 'Run migration out of order', + confirmationText: 'Run anyway', + children: outOfOrderRunMessage(pending) + }) })} initialTableKey={input.specificTable} initialSchemaKey={input.specificSchema} @@ -215,3 +227,7 @@ {/if} + + + + diff --git a/frontend/src/lib/components/DBTableEditor.svelte b/frontend/src/lib/components/DBTableEditor.svelte index 27d5eb6d0a..de3106d5ad 100644 --- a/frontend/src/lib/components/DBTableEditor.svelte +++ b/frontend/src/lib/components/DBTableEditor.svelte @@ -52,6 +52,7 @@ import Popover from './meltComponents/Popover.svelte' import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte' import { sendUserToast } from '$lib/toast' + import { MigrationRunCancelled } from './dbOps' import { getFlatTableNamesFromSchema, type DBSchema } from '$lib/stores' import { twMerge } from 'tailwind-merge' import DarkModeObserver from './DarkModeObserver.svelte' @@ -460,9 +461,13 @@ askingForConfirmation && (askingForConfirmation.loading = true) await onConfirm({ values }) } catch (e) { - let msg: string | undefined = (e as any)?.body ?? (e as Error)?.message - if (typeof msg !== 'string') msg = e ? JSON.stringify(e) : 'An error occurred' - sendUserToast(msg, true) + // User declined the out-of-order run warning: silent cancel, + // leave the editor open so they can adjust or run earlier first. + if (!(e instanceof MigrationRunCancelled)) { + let msg: string | undefined = (e as any)?.body ?? (e as Error)?.message + if (typeof msg !== 'string') msg = e ? JSON.stringify(e) : 'An error occurred' + sendUserToast(msg, true) + } } askingForConfirmation = undefined }, diff --git a/frontend/src/lib/components/DatatableSchemaDiff.svelte b/frontend/src/lib/components/DatatableSchemaDiff.svelte index 108353c582..9eccda8195 100644 --- a/frontend/src/lib/components/DatatableSchemaDiff.svelte +++ b/frontend/src/lib/components/DatatableSchemaDiff.svelte @@ -201,6 +201,12 @@ import YAML from 'yaml' import DrawerContent from './common/drawer/DrawerContent.svelte' import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte' + import { createAsyncConfirmationModal } from './common/confirmationModal/asyncConfirmationModal.svelte' + import Portal from '$lib/components/Portal.svelte' + import { + pendingMigrations, + outOfOrderRunMessage + } from './workspaceSettings/datatableMigrationUtils' import Alert from './common/alert/Alert.svelte' import ResizeTransitionWrapper from './common/ResizeTransitionWrapper.svelte' @@ -229,6 +235,7 @@ let migrationSql = $state('') let migrationRunning = $state(false) let confirmDeployOpen = $state(false) + const outOfOrderModal = createAsyncConfirmationModal() async function loadDiffs() { loading = true @@ -360,14 +367,27 @@ // prevents. Let the error propagate (fail closed, handled by the outer // catch); only fall back to raw apply when the API explicitly returns // enabled === false. - const targetUsesMigrations = ( - await WorkspaceService.getDatatableMigrationsStatus({ - workspace: targetWorkspace, - datatableName: dtName - }) - ).enabled + const status = await WorkspaceService.getDatatableMigrationsStatus({ + workspace: targetWorkspace, + datatableName: dtName + }) - if (targetUsesMigrations) { + if (status.enabled) { + // The merge migration gets the highest timestamp, so any still-pending + // migration on the target is earlier: running only the merge applies it + // out of order. Warn like the row-level Run action does. + const pending = pendingMigrations(status.migrations) + if (pending.length > 0) { + const confirmed = await outOfOrderModal.ask({ + title: 'Run migration out of order', + confirmationText: 'Run anyway', + children: outOfOrderRunMessage(pending.length) + }) + if (!confirmed) { + migrationRunning = false + return + } + } const forkName = $userWorkspaces.find((w) => w.id === currentWorkspaceId)?.name || currentWorkspaceId const migName = `merge_${forkName}`.replace(/[^a-zA-Z0-9_-]+/g, '_') @@ -635,3 +655,7 @@ >{migrationSql} + + + + diff --git a/frontend/src/lib/components/dbOps.ts b/frontend/src/lib/components/dbOps.ts index 71219f7660..21a6386478 100644 --- a/frontend/src/lib/components/dbOps.ts +++ b/frontend/src/lib/components/dbOps.ts @@ -10,6 +10,7 @@ import { stringifySchema } from './copilot/lib' import type { DbInput, DbType } from './dbTypes' import { assert } from '$lib/utils' import { WorkspaceService } from '$lib/gen' +import { pendingMigrations } from './workspaceSettings/datatableMigrationUtils' import { buildTableEditorValues, type TableEditorValues @@ -243,12 +244,25 @@ export type IDbSchemaOps = { }) => Promise } +/** Thrown by a schema op when the user declines the out-of-order run warning. + * Callers should treat it as a silent cancel (no error toast). */ +export class MigrationRunCancelled extends Error { + constructor() { + super('Migration run cancelled') + this.name = 'MigrationRunCancelled' + } +} + export function dbSchemaOpsWithPreviewScripts({ workspace, - input + input, + confirmRunOutOfOrder }: { workspace: string input: DbInput + /** Asked before running a just-created migration ahead of `pendingCount` + * still-pending earlier ones. Return false to abort (throws MigrationRunCancelled). */ + confirmRunOutOfOrder?: (pendingCount: number) => Promise }): IDbSchemaOps { const dbType = getDbType(input) const dbArg = getDatabaseArg(input) @@ -322,13 +336,22 @@ export function dbSchemaOpsWithPreviewScripts({ // that would run the change untracked (schema drift) — exactly what this // feature prevents. Let the error propagate (fail closed); only fall back to // ad-hoc when there's no data table, or `enabled === false` is returned. - const migrationsEnabled = datatableName - ? (await WorkspaceService.getDatatableMigrationsStatus({ workspace, datatableName })).enabled - : false - if (!datatableName || !migrationsEnabled) { + const status = datatableName + ? await WorkspaceService.getDatatableMigrationsStatus({ workspace, datatableName }) + : undefined + if (!datatableName || !status?.enabled) { await runScriptAndPollResult({ workspace, requestBody: { args: dbArg, content, language } }) return } + // The new migration gets the highest timestamp, so any still-pending + // migration is earlier: running only this one applies it out of order. + // Warn like the row-level Run action does (skipped if no confirm hook). + if (confirmRunOutOfOrder) { + const pending = pendingMigrations(status.migrations).length + if (pending > 0 && !(await confirmRunOutOfOrder(pending))) { + throw new MigrationRunCancelled() + } + } const codeUp = wrapMigration(await expandMarker(workspace, language, content)) // Down migrations are only generated for Postgres for now. let codeDown: string | undefined diff --git a/frontend/src/lib/components/workspaceSettings/NewDataTableMigrationModal.svelte b/frontend/src/lib/components/workspaceSettings/NewDataTableMigrationModal.svelte index fdc205a29b..d4dad04ac1 100644 --- a/frontend/src/lib/components/workspaceSettings/NewDataTableMigrationModal.svelte +++ b/frontend/src/lib/components/workspaceSettings/NewDataTableMigrationModal.svelte @@ -10,6 +10,10 @@ import { WorkspaceService, type DatatableMigration } from '$lib/gen' import { sendUserToast } from '$lib/toast' import { tick } from 'svelte' + import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte' + import { createAsyncConfirmationModal } from '../common/confirmationModal/asyncConfirmationModal.svelte' + import Portal from '$lib/components/Portal.svelte' + import { fetchPendingMigrations, outOfOrderRunMessage } from './datatableMigrationUtils' let { workspace, @@ -54,6 +58,8 @@ let codeDown = $state('') let creating = $state(false) + const confirmationModal = createAsyncConfirmationModal() + // Frame the migration body in an explicit transaction so it applies atomically. function wrapInTransaction(body: string): string { return `BEGIN;\n\n${body}\n\nEND;` @@ -91,6 +97,24 @@ sendUserToast("Invalid migration name: use only letters, digits, '_' and '-'", true) return } + if (run) { + // A new migration gets the highest timestamp, so any still-pending + // migration is earlier: running only this one applies it out of order. + // Warn just like the row-level Run action does. Best-effort: if the + // status can't be fetched, fall through and let the run/rollback handle it. + let pending: Awaited> = [] + try { + pending = await fetchPendingMigrations(workspace, datatable) + } catch {} + if (pending.length > 0) { + const confirmed = await confirmationModal.ask({ + title: 'Run migration out of order', + confirmationText: 'Run anyway', + children: outOfOrderRunMessage(pending.length) + }) + if (!confirmed) return + } + } creating = true try { const created = await WorkspaceService.createDatatableMigration({ @@ -200,3 +224,7 @@ + + + + diff --git a/frontend/src/lib/components/workspaceSettings/datatableMigrationUtils.ts b/frontend/src/lib/components/workspaceSettings/datatableMigrationUtils.ts new file mode 100644 index 0000000000..ed046418f2 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/datatableMigrationUtils.ts @@ -0,0 +1,30 @@ +import { WorkspaceService, type DatatableMigrationWithStatus } from '$lib/gen' + +/** + * Migrations that are defined but not yet applied. A newly-created migration + * always gets the highest timestamp, so every pending migration is "earlier": + * running the new one on its own would apply it ahead of them (out of order). + */ +export function pendingMigrations( + migrations: DatatableMigrationWithStatus[] +): DatatableMigrationWithStatus[] { + return migrations.filter((m) => m.status !== 'ran') +} + +/** Fetch the data table's migration status and return the pending ones. */ +export async function fetchPendingMigrations( + workspace: string, + datatableName: string +): Promise { + const { migrations } = await WorkspaceService.getDatatableMigrationsStatus({ + workspace, + datatableName + }) + return pendingMigrations(migrations) +} + +/** Confirmation copy shown before running a just-created migration ahead of + * `count` still-pending earlier ones (mirrors the row-level Run warning). */ +export function outOfOrderRunMessage(count: number): string { + return `${count} earlier migration(s) have not been run yet. This migration might depend on them. Run it anyway?` +}