mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-17 08:03:06 +00:00
* feat: scope data table migrations to the schemas they declare Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep unschemed objects and unqualified sql out of per-schema scopes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: order snapshot prerequisites and widen scopes to what they reference Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: point ee-repo-ref at the migration_scope allowlist entry Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: infer scopes only from generated ddl and snapshot every user object Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: snapshot every schema from one exported snapshot, infer only unedited sql Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor: keep the initial snapshot whole-database and scopes author-declared Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: warn before an out-of-order run when applied status is unknown Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: update ee-repo-ref to 57f4b973e2369f54a89817ff23dc44047ba7b9e4 This commit updates the EE repository reference after PR #880 was merged in windmill-ee-private. Previous ee-repo-ref: abb876d6234d5dfe7c644fdf12cf55f7bc1db1cc New ee-repo-ref: 57f4b973e2369f54a89817ff23dc44047ba7b9e4 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
770 lines
23 KiB
Svelte
770 lines
23 KiB
Svelte
<script lang="ts">
|
|
import { Badge, Button } from '../common'
|
|
import Modal2 from '../common/modal/Modal2.svelte'
|
|
import Tabs from '../common/tabs/Tabs.svelte'
|
|
import Tab from '../common/tabs/Tab.svelte'
|
|
import TabContent from '../common/tabs/TabContent.svelte'
|
|
import SimpleEditor from '../SimpleEditor.svelte'
|
|
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
|
|
import { createAsyncConfirmationModal } from '../common/confirmationModal/asyncConfirmationModal.svelte'
|
|
import {
|
|
ChevronDown,
|
|
Play,
|
|
Trash2,
|
|
Plus,
|
|
Undo2,
|
|
Loader2,
|
|
Camera,
|
|
Settings,
|
|
Power,
|
|
PowerOff
|
|
} from 'lucide-svelte'
|
|
import { WorkspaceService, type DatatableMigrationWithStatus } from '$lib/gen'
|
|
import { sendUserToast } from '$lib/toast'
|
|
import NewDataTableMigrationModal from './NewDataTableMigrationModal.svelte'
|
|
import Tooltip from '../Tooltip.svelte'
|
|
import Portal from '$lib/components/Portal.svelte'
|
|
import DropdownV2 from '../DropdownV2.svelte'
|
|
import MissingWorkerTagAlert from '../jobs/MissingWorkerTagAlert.svelte'
|
|
import { superadmin } from '$lib/stores'
|
|
import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte'
|
|
import { parseMigrationRole, withMigrationRole } from '../datatableMigrationRole'
|
|
import { scopesOverlap } from '../datatableMigrationSchemas'
|
|
|
|
const operatingUser = useOperatingUser()
|
|
const actingUser = $derived(operatingUser.current)
|
|
|
|
let {
|
|
workspace,
|
|
datatable,
|
|
disabled = false,
|
|
hideTrigger = false,
|
|
onSchemaChanged
|
|
}: {
|
|
workspace: string
|
|
datatable: string
|
|
disabled?: boolean
|
|
/** Mount the modals without the "Migrations" trigger button, so a caller can
|
|
* drive them programmatically via `openMigration` (e.g. the DDL guard). */
|
|
hideTrigger?: boolean
|
|
/** Called after a migration run/revert actually changes the data table
|
|
* schema, so an open viewer (e.g. the database manager) can refresh. */
|
|
onSchemaChanged?: () => void
|
|
} = $props()
|
|
|
|
let listOpen = $state(false)
|
|
let migrations = $state<DatatableMigrationWithStatus[]>([])
|
|
let enabled = $state(true)
|
|
let loadError = $state<string | undefined>(undefined)
|
|
let loading = $state(false)
|
|
let busy = $state(false)
|
|
// pg_dump can take a while, so the snapshot gets its own flag to spin its button.
|
|
let generatingInitial = $state(false)
|
|
|
|
// Only workspace admins and super admins can opt a data table in or out.
|
|
const canManage = $derived(!!actingUser?.is_admin || !!$superadmin)
|
|
|
|
let newMigrationModal = $state<NewDataTableMigrationModal | undefined>(undefined)
|
|
let newMigrationOpen = $state(false)
|
|
|
|
let viewOpen = $state(false)
|
|
let viewMigration = $state<DatatableMigrationWithStatus | undefined>(undefined)
|
|
let viewTab = $state('up')
|
|
let settingsDropdownOpen = $state(false)
|
|
|
|
let deleteOpen = $state(false)
|
|
let deleteTarget = $state<DatatableMigrationWithStatus | undefined>(undefined)
|
|
|
|
let addingDown = $state(false)
|
|
let downDraft = $state('')
|
|
let savingDown = $state(false)
|
|
|
|
function openView(m: DatatableMigrationWithStatus) {
|
|
viewMigration = m
|
|
viewTab = 'up'
|
|
addingDown = false
|
|
viewOpen = true
|
|
}
|
|
|
|
const DOWN_TEMPLATE = 'BEGIN;\n\n-- Add your down migration here\n\nEND;'
|
|
|
|
function startAddDownMigration() {
|
|
// Same transaction frame the new-migration modal starts from, so the down
|
|
// applies atomically. It rolls back as the role the up ran as.
|
|
const upRole = viewMigration ? parseMigrationRole(viewMigration.code_up) : undefined
|
|
downDraft = withMigrationRole(DOWN_TEMPLATE, upRole?.kind === 'role' ? upRole.role : undefined)
|
|
addingDown = true
|
|
}
|
|
|
|
// Saving the bare template would attach a down that reverts nothing, and a
|
|
// down can only be attached once — the upsert refuses to correct it later on a
|
|
// migration that has already run. So require an actual statement inside the
|
|
// transaction frame, not just comments.
|
|
function hasStatement(sql: string): boolean {
|
|
return (
|
|
sql
|
|
.replace(/--[^\n]*/g, '')
|
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
.replace(/\b(BEGIN|START\s+TRANSACTION|COMMIT|END)\b/gi, '')
|
|
.replace(/;/g, '')
|
|
.trim() !== ''
|
|
)
|
|
}
|
|
|
|
// Attach a down migration to one that has none. Only the down is sent back;
|
|
// the name and up SQL are echoed unchanged so the upsert doesn't count as a
|
|
// rewrite of a migration that may already be applied.
|
|
async function saveDownMigration() {
|
|
const m = viewMigration
|
|
if (!m) return
|
|
savingDown = true
|
|
try {
|
|
await WorkspaceService.upsertDatatableMigration({
|
|
workspace,
|
|
datatableName: datatable,
|
|
requestBody: {
|
|
timestamp: m.timestamp,
|
|
name: m.name,
|
|
code_up: m.code_up,
|
|
code_down: downDraft
|
|
}
|
|
})
|
|
sendUserToast('Down migration saved')
|
|
addingDown = false
|
|
// Show the saved down straight away: `loadMigrations` swallows its own
|
|
// errors, so re-reading the migration from the refreshed list could hand
|
|
// back the pre-save entry and bounce the tab to its empty state.
|
|
viewMigration = { ...m, code_down: downDraft }
|
|
await loadMigrations()
|
|
} catch (e: any) {
|
|
sendUserToast(`Failed to save down migration: ${e?.body ?? e?.message ?? e}`, true)
|
|
} finally {
|
|
savingDown = false
|
|
}
|
|
}
|
|
|
|
const confirmationModal = createAsyncConfirmationModal()
|
|
|
|
const hasPending = $derived(migrations.some((m) => m.status !== 'ran'))
|
|
|
|
async function loadMigrations() {
|
|
// Only show the full-list spinner on the initial load. Refreshes after an
|
|
// action (run/revert/delete) update the keyed list in place to avoid a
|
|
// flicker, with the buttons already gated by `busy`.
|
|
if (migrations.length === 0) {
|
|
loading = true
|
|
}
|
|
try {
|
|
const res = await WorkspaceService.getDatatableMigrationsStatus({
|
|
workspace,
|
|
datatableName: datatable
|
|
})
|
|
enabled = res.enabled
|
|
migrations = res.migrations
|
|
loadError = res.error
|
|
} catch (e: any) {
|
|
sendUserToast(`Failed to load migrations: ${e?.body ?? e?.message ?? e}`, true)
|
|
} finally {
|
|
loading = false
|
|
}
|
|
}
|
|
|
|
function openList() {
|
|
listOpen = true
|
|
loadMigrations()
|
|
}
|
|
|
|
export function open() {
|
|
openList()
|
|
}
|
|
|
|
// Open the list modal and the detail view for a specific migration. Used to
|
|
// jump to a just-created migration from the "See migration" toast action.
|
|
export async function openMigration(timestamp: number) {
|
|
listOpen = true
|
|
await loadMigrations()
|
|
const m = migrations.find((x) => x.timestamp === timestamp)
|
|
if (m) openView(m)
|
|
}
|
|
|
|
async function runUpTo(upTo: number | undefined) {
|
|
busy = true
|
|
try {
|
|
const res = await WorkspaceService.runDatatableMigrations({
|
|
workspace,
|
|
datatableName: datatable,
|
|
upTo
|
|
})
|
|
sendUserToast(
|
|
res.applied.length > 0
|
|
? `Applied ${res.applied.length} migration(s)`
|
|
: 'No pending migrations to run'
|
|
)
|
|
await loadMigrations()
|
|
if (res.applied.length > 0) onSchemaChanged?.()
|
|
} catch (e: any) {
|
|
sendUserToast(`Failed to run migrations: ${e?.body ?? e?.message ?? e}`, true)
|
|
} finally {
|
|
busy = false
|
|
}
|
|
}
|
|
|
|
async function runOnly(version: number) {
|
|
busy = true
|
|
try {
|
|
const res = await WorkspaceService.runDatatableMigrations({
|
|
workspace,
|
|
datatableName: datatable,
|
|
only: version
|
|
})
|
|
sendUserToast(
|
|
res.applied.length > 0 ? `Applied ${res.applied[0].name}` : 'Migration already applied'
|
|
)
|
|
await loadMigrations()
|
|
if (res.applied.length > 0) onSchemaChanged?.()
|
|
} catch (e: any) {
|
|
sendUserToast(`Failed to run migration: ${e?.body ?? e?.message ?? e}`, true)
|
|
} finally {
|
|
busy = false
|
|
}
|
|
}
|
|
|
|
async function runForSchema(schema: string) {
|
|
busy = true
|
|
try {
|
|
const res = await WorkspaceService.runDatatableMigrations({
|
|
workspace,
|
|
datatableName: datatable,
|
|
schema
|
|
})
|
|
sendUserToast(
|
|
res.applied.length > 0
|
|
? `Applied ${res.applied.length} migration(s) on ${schema}`
|
|
: `No pending migrations on ${schema}`
|
|
)
|
|
await loadMigrations()
|
|
if (res.applied.length > 0) onSchemaChanged?.()
|
|
} catch (e: any) {
|
|
sendUserToast(`Failed to run migrations: ${e?.body ?? e?.message ?? e}`, true)
|
|
} finally {
|
|
busy = false
|
|
}
|
|
}
|
|
|
|
// The schemas that have pending migrations declaring them, each runnable on its own.
|
|
const pendingSchemas = $derived([
|
|
...new Set(migrations.filter((m) => m.status !== 'ran').flatMap((m) => m.schemas ?? []))
|
|
])
|
|
|
|
// What makes two migrations depend on each other, for the out-of-order warnings.
|
|
function overlap(a: DatatableMigrationWithStatus, b: DatatableMigrationWithStatus): string {
|
|
if (!b.schemas) return 'it applies to the whole database'
|
|
if (!a.schemas) return `"${a.name}" applies to the whole database`
|
|
return `both touch schema ${a.schemas.filter((s) => b.schemas?.includes(s)).join(', ')}`
|
|
}
|
|
|
|
// Run a single migration. Warn first if an earlier migration touching the same schemas hasn't
|
|
// run, since this one might depend on it.
|
|
async function runMigration(m: DatatableMigrationWithStatus) {
|
|
// `blocked_by` is only known when the applied versions could be read: when they could not,
|
|
// an earlier overlapping migration of unknown status may still be pending.
|
|
const blocker =
|
|
migrations.find((x) => x.timestamp === m.blocked_by) ??
|
|
migrations.find(
|
|
(x) =>
|
|
x.timestamp < m.timestamp && x.status === 'unknown' && scopesOverlap(x.schemas, m.schemas)
|
|
)
|
|
if (blocker) {
|
|
const confirmed = await confirmationModal.ask({
|
|
title: 'Run migration out of order',
|
|
confirmationText: 'Run anyway',
|
|
children: `"${blocker.name}" ${blocker.status === 'unknown' ? 'may not have been run yet' : 'has not been run yet'} and ${overlap(m, blocker)}, so "${m.name}" might depend on it. Run it anyway?`
|
|
})
|
|
if (!confirmed) return
|
|
}
|
|
await runOnly(m.timestamp)
|
|
}
|
|
|
|
async function revertOnly(version: number) {
|
|
busy = true
|
|
try {
|
|
const res = await WorkspaceService.rollbackDatatableMigrations({
|
|
workspace,
|
|
datatableName: datatable,
|
|
only: version
|
|
})
|
|
sendUserToast(
|
|
res.rolled_back.length > 0
|
|
? `Reverted ${res.rolled_back[0].name}`
|
|
: 'Migration was not applied'
|
|
)
|
|
await loadMigrations()
|
|
if (res.rolled_back.length > 0) onSchemaChanged?.()
|
|
} catch (e: any) {
|
|
sendUserToast(`Failed to revert migration: ${e?.body ?? e?.message ?? e}`, true)
|
|
} finally {
|
|
busy = false
|
|
}
|
|
}
|
|
|
|
// Revert a single migration. Warn first if a later migration touching the same schemas has
|
|
// run, since it might depend on this one.
|
|
async function revertMigration(m: DatatableMigrationWithStatus) {
|
|
const blocker = migrations.find((x) => x.timestamp === m.blocked_by)
|
|
if (blocker) {
|
|
const confirmed = await confirmationModal.ask({
|
|
title: 'Revert migration out of order',
|
|
confirmationText: 'Revert anyway',
|
|
children: `"${blocker.name}" has already been run and ${overlap(m, blocker)}, so it might depend on "${m.name}". Reverting it may break it. Revert anyway?`
|
|
})
|
|
if (!confirmed) return
|
|
}
|
|
await revertOnly(m.timestamp)
|
|
}
|
|
|
|
async function generateInitial() {
|
|
const confirmed = await confirmationModal.ask({
|
|
title: 'Generate initial migration',
|
|
confirmationText: 'Generate',
|
|
children: `This snapshots the current schema of "${datatable}" with pg_dump as an "initial" migration and marks it as already applied, so it is never re-run on this data table. It has no down migration. Use this to start tracking migrations on an existing data table.`
|
|
})
|
|
if (!confirmed) return
|
|
busy = true
|
|
generatingInitial = true
|
|
try {
|
|
await WorkspaceService.generateInitialDatatableMigration({
|
|
workspace,
|
|
datatableName: datatable
|
|
})
|
|
sendUserToast('Initial migration generated')
|
|
await loadMigrations()
|
|
} catch (e: any) {
|
|
sendUserToast(`Failed to generate initial migration: ${e?.body ?? e?.message ?? e}`, true)
|
|
} finally {
|
|
busy = false
|
|
generatingInitial = false
|
|
}
|
|
}
|
|
|
|
async function enableMigrations() {
|
|
busy = true
|
|
try {
|
|
await WorkspaceService.enableDatatableMigrations({ workspace, datatableName: datatable })
|
|
sendUserToast('Migrations enabled')
|
|
await loadMigrations()
|
|
} catch (e: any) {
|
|
sendUserToast(`Failed to enable migrations: ${e?.body ?? e?.message ?? e}`, true)
|
|
return
|
|
} finally {
|
|
busy = false
|
|
}
|
|
// Right after enabling, offer to snapshot the current schema as the initial migration.
|
|
await generateInitial()
|
|
}
|
|
|
|
async function disableMigrations() {
|
|
const confirmed = await confirmationModal.ask({
|
|
title: 'Disable migrations',
|
|
confirmationText: 'Disable and delete',
|
|
children: `Disabling migrations for "${datatable}" will delete ALL of its migrations. This cannot be undone. The data table's data and schema are not affected. Continue?`
|
|
})
|
|
if (!confirmed) return
|
|
busy = true
|
|
try {
|
|
await WorkspaceService.disableDatatableMigrations({ workspace, datatableName: datatable })
|
|
sendUserToast('Migrations disabled')
|
|
await loadMigrations()
|
|
} catch (e: any) {
|
|
sendUserToast(`Failed to disable migrations: ${e?.body ?? e?.message ?? e}`, true)
|
|
} finally {
|
|
busy = false
|
|
}
|
|
}
|
|
|
|
// Delete a migration's definition. Does not touch the data table's schema or
|
|
// its `_wm_migrations` bookkeeping.
|
|
async function performDelete(m: DatatableMigrationWithStatus) {
|
|
busy = true
|
|
try {
|
|
await WorkspaceService.deleteDatatableMigration({
|
|
workspace,
|
|
datatableName: datatable,
|
|
timestamp: m.timestamp
|
|
})
|
|
await loadMigrations()
|
|
} catch (e: any) {
|
|
sendUserToast(`Failed to delete migration: ${e?.body ?? e?.message ?? e}`, true)
|
|
} finally {
|
|
busy = false
|
|
}
|
|
}
|
|
|
|
// Revert an installed migration (run its down) then delete its definition.
|
|
// The delete is skipped if the revert fails, so we never drop the definition
|
|
// of a migration that is still installed on the data table.
|
|
async function revertAndDelete(m: DatatableMigrationWithStatus) {
|
|
busy = true
|
|
try {
|
|
await WorkspaceService.rollbackDatatableMigrations({
|
|
workspace,
|
|
datatableName: datatable,
|
|
only: m.timestamp
|
|
})
|
|
await WorkspaceService.deleteDatatableMigration({
|
|
workspace,
|
|
datatableName: datatable,
|
|
timestamp: m.timestamp
|
|
})
|
|
await loadMigrations()
|
|
onSchemaChanged?.()
|
|
sendUserToast(`Reverted and deleted "${m.name}"`)
|
|
} catch (e: any) {
|
|
sendUserToast(`Failed to revert and delete migration: ${e?.body ?? e?.message ?? e}`, true)
|
|
await loadMigrations()
|
|
} finally {
|
|
busy = false
|
|
}
|
|
}
|
|
|
|
async function deleteMigration(m: DatatableMigrationWithStatus) {
|
|
// An installed migration can be reverted first, so offer that as a third
|
|
// choice instead of a plain confirm.
|
|
if (m.status === 'ran') {
|
|
deleteTarget = m
|
|
deleteOpen = true
|
|
return
|
|
}
|
|
const body =
|
|
m.status === 'unknown'
|
|
? `The applied status of "${m.name}" is unknown. If it is installed on the data table, deleting it may leave the data table in a broken state. Delete anyway?`
|
|
: `Delete the definition of "${m.name}"? It has not been run, so this only removes the migration definition.`
|
|
const confirmed = await confirmationModal.ask({
|
|
title: 'Delete migration',
|
|
confirmationText: 'Delete',
|
|
children: body
|
|
})
|
|
if (!confirmed) return
|
|
await performDelete(m)
|
|
}
|
|
|
|
const statusColor = {
|
|
ran: 'bg-green-500',
|
|
not_run: 'bg-orange-500',
|
|
unknown: 'bg-gray-400'
|
|
}
|
|
const statusTitle = {
|
|
ran: 'Already ran',
|
|
not_run: 'Not run',
|
|
unknown: 'Status unknown'
|
|
}
|
|
</script>
|
|
|
|
{#if !hideTrigger}
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
{disabled}
|
|
endIcon={{ icon: ChevronDown }}
|
|
on:click={openList}
|
|
>
|
|
Migrations
|
|
</Button>
|
|
{/if}
|
|
|
|
<Modal2
|
|
bind:isOpen={listOpen}
|
|
title="Migrations — {datatable}"
|
|
fixedWidth="md"
|
|
fixedHeight="lg"
|
|
closeOnOutsideClick={!newMigrationOpen &&
|
|
!viewOpen &&
|
|
!deleteOpen &&
|
|
!confirmationModal.props.open &&
|
|
!settingsDropdownOpen}
|
|
>
|
|
{#snippet headerLeft()}
|
|
<Tooltip>
|
|
Each schema edit made in the database manager is captured here as a migration. Tracking schema
|
|
changes as migrations makes it easy to export data tables to other workspaces, and to
|
|
reproduce their schema when forking a workspace.
|
|
</Tooltip>
|
|
{/snippet}
|
|
{#snippet headerRight()}
|
|
{#if enabled && canManage}
|
|
<DropdownV2
|
|
bind:open={settingsDropdownOpen}
|
|
items={[
|
|
{
|
|
displayName: 'Disable migrations',
|
|
icon: PowerOff,
|
|
type: 'delete',
|
|
action: () => disableMigrations()
|
|
}
|
|
]}
|
|
>
|
|
{#snippet buttonReplacement()}
|
|
<Button
|
|
variant="subtle"
|
|
size="xs"
|
|
iconOnly
|
|
startIcon={{ icon: Settings }}
|
|
title="Migration settings"
|
|
disabled={busy}
|
|
/>
|
|
{/snippet}
|
|
</DropdownV2>
|
|
{/if}
|
|
{/snippet}
|
|
<div class="flex flex-col gap-2 w-full grow min-h-0">
|
|
{#if loading}
|
|
<div class="flex items-center justify-center grow text-tertiary">
|
|
<Loader2 size={18} class="animate-spin" />
|
|
</div>
|
|
{:else if !enabled}
|
|
<div class="flex flex-col items-center justify-center gap-3 grow text-sm text-tertiary">
|
|
<span>Migrations are disabled for this data table.</span>
|
|
{#if canManage}
|
|
<Button
|
|
variant="subtle"
|
|
size="sm"
|
|
startIcon={{ icon: Power }}
|
|
disabled={busy}
|
|
on:click={enableMigrations}
|
|
>
|
|
Enable migrations
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<MissingWorkerTagAlert tag="postgresql" subject="Migrations" {workspace} class="mb-2" />
|
|
{#if loadError}
|
|
<div class="text-xs text-red-500">
|
|
Could not read applied status from the data table: {loadError}
|
|
</div>
|
|
{/if}
|
|
<div class="flex flex-col grow min-h-0 overflow-auto border rounded-md divide-y">
|
|
{#if migrations.length === 0}
|
|
<div class="flex flex-col items-center gap-3 p-6 text-sm text-tertiary">
|
|
<span>{generatingInitial ? 'Snapshotting current schema…' : 'No migrations yet'}</span>
|
|
<Button
|
|
variant="subtle"
|
|
size="xs"
|
|
startIcon={{ icon: Camera }}
|
|
disabled={busy}
|
|
loading={generatingInitial}
|
|
on:click={generateInitial}
|
|
>
|
|
Generate initial migration
|
|
</Button>
|
|
</div>
|
|
{:else}
|
|
{#each migrations as m (m.timestamp)}
|
|
<div class="flex items-center gap-3 px-3 py-2">
|
|
<div
|
|
class="shrink-0 w-2.5 h-2.5 rounded-full {statusColor[m.status]}"
|
|
title={statusTitle[m.status]}
|
|
></div>
|
|
<button
|
|
type="button"
|
|
class="flex flex-col min-w-0 grow text-left rounded -mx-1 px-1 py-0.5 hover:bg-surface-hover"
|
|
title="View migration"
|
|
onclick={() => openView(m)}
|
|
>
|
|
<span class="text-sm text-primary truncate">{m.name}</span>
|
|
<span class="text-xs text-hint">{m.timestamp}</span>
|
|
</button>
|
|
<div class="flex flex-wrap justify-end gap-1 shrink-0 max-w-48">
|
|
{#if m.schemas}
|
|
{#each m.schemas as s (s)}
|
|
<Badge small>{s}</Badge>
|
|
{/each}
|
|
{:else}
|
|
<Badge
|
|
small
|
|
color="yellow"
|
|
title="Declares no schemas: every later migration waits for it"
|
|
>
|
|
whole database
|
|
</Badge>
|
|
{/if}
|
|
</div>
|
|
<Button
|
|
variant="subtle"
|
|
size="xs"
|
|
iconOnly
|
|
startIcon={{ icon: Play }}
|
|
title="Run this migration"
|
|
disabled={busy || m.status === 'ran'}
|
|
on:click={() => runMigration(m)}
|
|
/>
|
|
<Button
|
|
variant="subtle"
|
|
size="xs"
|
|
iconOnly
|
|
startIcon={{ icon: Undo2 }}
|
|
title="Revert this migration"
|
|
disabled={busy || m.status !== 'ran'}
|
|
on:click={() => revertMigration(m)}
|
|
/>
|
|
<Button
|
|
variant="subtle"
|
|
size="xs"
|
|
iconOnly
|
|
color="red"
|
|
startIcon={{ icon: Trash2 }}
|
|
title="Delete migration"
|
|
disabled={busy}
|
|
on:click={() => deleteMigration(m)}
|
|
/>
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
<div class="flex justify-between gap-2 pt-2">
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
startIcon={{ icon: Plus }}
|
|
on:click={() => {
|
|
newMigrationOpen = true
|
|
newMigrationModal?.open()
|
|
}}>New</Button
|
|
>
|
|
<Button
|
|
variant="accent"
|
|
unifiedSize="sm"
|
|
startIcon={{ icon: Play }}
|
|
disabled={busy || !hasPending}
|
|
on:click={() => runUpTo(undefined)}
|
|
dropdownItems={pendingSchemas.length > 0
|
|
? pendingSchemas.map((s) => ({
|
|
label: `Run pending on ${s} only`,
|
|
onClick: () => runForSchema(s)
|
|
}))
|
|
: undefined}
|
|
>
|
|
Run all
|
|
</Button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</Modal2>
|
|
|
|
<NewDataTableMigrationModal
|
|
bind:this={newMigrationModal}
|
|
{workspace}
|
|
{datatable}
|
|
onCreated={loadMigrations}
|
|
onClose={() => (newMigrationOpen = false)}
|
|
onSeeMigration={(m) => openMigration(m.timestamp)}
|
|
/>
|
|
|
|
<Modal2
|
|
bind:isOpen={viewOpen}
|
|
title="Migration — {viewMigration?.name ?? ''}"
|
|
fixedWidth="md"
|
|
fixedHeight="lg"
|
|
>
|
|
{#if viewMigration}
|
|
<div class="flex flex-col gap-3 w-full grow min-h-0">
|
|
<span class="text-xs text-hint">{viewMigration.timestamp}</span>
|
|
<Tabs bind:selected={viewTab} class="grow min-h-0">
|
|
<Tab value="up" label="Up" />
|
|
<Tab value="down" label="Down" />
|
|
{#snippet content()}
|
|
<TabContent value="up" class="h-80 border rounded-md overflow-hidden">
|
|
<SimpleEditor class="h-full" lang="sql" code={viewMigration?.code_up ?? ''} readOnly />
|
|
</TabContent>
|
|
<TabContent value="down" class="flex flex-col gap-2 h-80">
|
|
{#if viewMigration?.code_down}
|
|
<div class="grow min-h-0 border rounded-md overflow-hidden">
|
|
<SimpleEditor class="h-full" lang="sql" code={viewMigration.code_down} readOnly />
|
|
</div>
|
|
{:else if addingDown}
|
|
<div class="grow min-h-0 border rounded-md overflow-hidden">
|
|
<SimpleEditor class="h-full" lang="sql" bind:code={downDraft} leadingChangeSync />
|
|
</div>
|
|
<div class="flex justify-end gap-2">
|
|
<Button
|
|
variant="default"
|
|
unifiedSize="sm"
|
|
disabled={savingDown}
|
|
on:click={() => (addingDown = false)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="accent"
|
|
unifiedSize="sm"
|
|
disabled={savingDown || !hasStatement(downDraft)}
|
|
on:click={saveDownMigration}
|
|
>
|
|
Save
|
|
</Button>
|
|
</div>
|
|
{:else}
|
|
<div
|
|
class="flex flex-col items-center justify-center gap-3 grow border rounded-md text-sm text-tertiary"
|
|
>
|
|
<span>No down migration</span>
|
|
<Button
|
|
variant="subtle"
|
|
unifiedSize="xs"
|
|
startIcon={{ icon: Plus }}
|
|
on:click={startAddDownMigration}
|
|
>
|
|
Add a down migration
|
|
</Button>
|
|
</div>
|
|
{/if}
|
|
</TabContent>
|
|
{/snippet}
|
|
</Tabs>
|
|
</div>
|
|
{/if}
|
|
</Modal2>
|
|
|
|
<Modal2 bind:isOpen={deleteOpen} title="Delete migration" fixedWidth="sm" fixedHeight="adaptive">
|
|
<div class="flex flex-col gap-3 w-full">
|
|
<p class="text-sm text-secondary">
|
|
"{deleteTarget?.name}" is installed on the data table. Revert it first to undo its schema
|
|
change, or delete only the definition and leave the schema as-is — deleting without reverting
|
|
means it can no longer be reverted.
|
|
</p>
|
|
<div class="flex justify-end gap-2 pt-2">
|
|
<Button variant="default" size="sm" disabled={busy} on:click={() => (deleteOpen = false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="default"
|
|
color="red"
|
|
size="sm"
|
|
disabled={busy}
|
|
on:click={() => {
|
|
const m = deleteTarget
|
|
deleteOpen = false
|
|
if (m) performDelete(m)
|
|
}}
|
|
>
|
|
Delete
|
|
</Button>
|
|
<Button
|
|
variant="accent"
|
|
size="sm"
|
|
disabled={busy}
|
|
on:click={() => {
|
|
const m = deleteTarget
|
|
deleteOpen = false
|
|
if (m) revertAndDelete(m)
|
|
}}
|
|
>
|
|
Revert and delete
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Modal2>
|
|
|
|
<Portal>
|
|
<ConfirmationModal {...confirmationModal.props} />
|
|
</Portal>
|