diff --git a/frontend/src/lib/components/ImportSetupStep.svelte b/frontend/src/lib/components/ImportSetupStep.svelte index 1a7cd5309e..75edbcbb37 100644 --- a/frontend/src/lib/components/ImportSetupStep.svelte +++ b/frontend/src/lib/components/ImportSetupStep.svelte @@ -18,6 +18,7 @@ import { registryCcCapableFor } from '$lib/components/oauthRegistry' import { resourceTypeDisplayName } from '$lib/components/resourceTypeDisplay' import { applyOneMigration } from '$lib/components/workspaceSettings/projectInstall' + import { probeMigrationApplied } from '$lib/importWizard/probe' import { retargetProjectExport, type ProjectExport, @@ -133,6 +134,32 @@ return `dt${n}` } + /** + * What a row's state actually is, asked of the destination rather than inferred. + * + * The data table existing is not the question — the wizard creates it and the migrations + * run afterwards, so a table can be there with none of the project's tables inside it. + * That gap is invisible in memory after a reload, which rebuilds every row from scratch; + * reading it as "done" would let the step say "You're all set" over a project whose apps + * all fail on open. + * + * `probeMigrationApplied` answers `undefined` when it cannot tell (unreadable schema, or + * SQL it cannot resolve to table names). That is not evidence of failure, so it keeps + * whatever the row already said instead of manufacturing an outstanding row nobody can + * clear. + */ + async function settle( + ms: ProjectMigration[], + absent: boolean, + prev: Row | undefined + ): Promise { + if (absent) return 'unconfigured' + const applied = await Promise.all(ms.map((m) => probeMigrationApplied(workspace, m))) + if (applied.every((a) => a === true)) return 'done' + if (applied.some((a) => a === false)) return prev?.status === 'failed' ? 'failed' : 'unconfigured' + return prev?.status === 'failed' ? 'failed' : 'done' + } + /** Which data tables the project needs that the destination does not have yet. */ async function load() { loading = true @@ -152,19 +179,17 @@ const missing = [...new Set(enabled.map((m) => m.datatable_name))].filter( (n) => !present.has(n) ) - // Rows already configured in an earlier pass keep their state; the wizard only - // ever adds data tables, so a name that has left `missing` is done. const previous = new Map(rows.map((r) => [r.name, r])) - rows = [...new Set(enabled.map((m) => m.datatable_name))].map((name) => { - const prev = previous.get(name) - if (prev && !missing.includes(name)) return { ...prev, status: 'done' as const } - return { - name, - migrations: enabled.filter((m) => m.datatable_name === name), - status: (missing.includes(name) ? 'unconfigured' : 'done') as Row['status'], - justSaved: false - } - }) + rows = await Promise.all( + [...new Set(enabled.map((m) => m.datatable_name))].map(async (name) => { + const ms = enabled.filter((m) => m.datatable_name === name) + const prev = previous.get(name) + const status = await settle(ms, missing.includes(name), prev) + return prev + ? { ...prev, migrations: ms, status, error: status === 'done' ? undefined : prev.error } + : { name, migrations: ms, status, justSaved: false } + }) + ) // Retargeted the same way the import was, so these are where the stubs actually // landed. `retargetProjectExport` is a no-op when the folder is the slug, which is // every new-workspace import. @@ -597,6 +622,7 @@ bind:opened={wizardOpen} initialName={wizardFor} modalTarget="body" + {workspace} finishAlso="run migrations" onFinishAlso={() => runMigrationsFor(wizardFor ?? '')} existingNames={configuredNames.map((c) => c.name)} diff --git a/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte b/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte index b3570108bf..bc937d5145 100644 --- a/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte +++ b/frontend/src/lib/components/workspaceSettings/AddDataTableWizard.svelte @@ -102,6 +102,16 @@ * where the rest of the run does instead of starting after the dialog closes. * Throwing marks that step failed; the table itself is already made either way. */ onFinishAlso?: () => Promise + /** The workspace everything here is created in and checked against. + * + * Defaults to `$workspaceStore`, which is right for the settings page — it is the + * workspace being looked at. The import wizard is the exception: its page is + * reparented out of `(logged)`, so nothing re-runs the layout's workspace + * persistence, and after a reload the store still names whatever workspace the + * user came from while the plan in the URL names the destination. Left ambient, + * this would create the data table in one workspace and run the project's + * migrations in the other. */ + workspace?: string } let { @@ -116,9 +126,13 @@ initialName, modalTarget = '#content', finishAlso, - onFinishAlso + onFinishAlso, + workspace: workspaceProp }: Props = $props() + /** Every write and every check goes through this, never `$workspaceStore` directly. */ + const targetWorkspace = $derived(workspaceProp ?? $workspaceStore ?? '') + const STEPS = ['Choose a database', 'Set it up', 'Review'] let wiz: WizardState = $state( @@ -167,7 +181,7 @@ } clearTimeout(variableCheck) variableCheck = setTimeout(async () => { - const taken = await VariableService.existsVariable({ workspace: $workspaceStore!, path }) + const taken = await VariableService.existsVariable({ workspace: targetWorkspace, path }) // Two checks can be in flight at once and resolve out of order. A `false` for a path // nobody is on any more would clear the error guarding the one about to be written; // a `true` would disable Finish over a path this run stopped caring about. @@ -184,7 +198,7 @@ * in flight when Finish is pressed. */ async function pathConflictMessage(path: string): Promise { - const workspace = $workspaceStore! + const workspace = targetWorkspace // Each namespace answers to its own claim. Holding the secret says nothing about who owns // the resource beside it, so one claim must not wave the other's check through. const [variable, resource] = await Promise.all([ @@ -199,14 +213,14 @@ let maxStep = $state(1) function defaultProjectName(): string { - return `windmill-${$workspaceStore ?? 'workspace'}` + return `windmill-${targetWorkspace || 'workspace'}` } function defaultTableName(): string { // A caller that needs a specific name wins over the usual "main, unless taken": // the import wizard's migrations only apply to a table of the name they target. if (initialName) return initialName - return existingNames.includes('main') ? `${$workspaceStore ?? 'data'}_datatable` : 'main' + return existingNames.includes('main') ? `${targetWorkspace || 'data'}_datatable` : 'main' } // Takes the list rather than reading it, so the fetch that loads it can seed off its own @@ -260,7 +274,7 @@ ) const pgResources = resource( - () => (opened && wiz.provider === 'resource' ? ($workspaceStore ?? '') : ''), + () => (opened && wiz.provider === 'resource' ? targetWorkspace : ''), async (workspace) => { if (!workspace) return undefined const list = await ResourceService.listResource({ workspace, resourceType: 'postgresql' }) @@ -354,7 +368,7 @@ ) const folderNames = resource( - () => (opened ? ($workspaceStore ?? '') : ''), + () => (opened ? targetWorkspace : ''), async (workspace) => { if (!workspace) return [] const all = await FolderService.listFolderNames({ workspace }) @@ -556,7 +570,7 @@ settle({ checking: false, report: undefined, error: undefined }) return } - const report = await probeDatatableConnection($workspaceStore!, database) + const report = await probeDatatableConnection(targetWorkspace, database) settle({ checking: false, report, error: undefined }) } catch (err: any) { settle({ @@ -667,7 +681,7 @@ const name = wiz.review.name.trim() try { if (claimedName !== name) { - const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! }) + const settings = await WorkspaceService.getSettings({ workspace: targetWorkspace }) if (settings.datatable?.datatables?.[name]) { nameConflictFor = { name, @@ -712,7 +726,7 @@ let result: RunResult | undefined = undefined try { result = await runSetup(wiz, { - workspace: $workspaceStore!, + workspace: targetWorkspace, supabaseToken: supaOauth.token, onInstanceDbsChanged: async () => { await customInstanceDbs.refetch() @@ -1128,7 +1142,7 @@ {#if wiz.instance.mode === 'existing'} {@const shared = ( customInstanceDbs.current?.[wiz.instance.dbName ?? '']?.used_by_workspaces ?? [] - ).filter((w) => w !== $workspaceStore)} + ).filter((w) => w !== targetWorkspace)} {#if shared.length} @@ -1141,7 +1155,7 @@
{#each instanceDbs as { name, db } (name)} {@const selected = wiz.instance.dbName === name} - {@const others = (db.used_by_workspaces ?? []).filter((w) => w !== $workspaceStore)} + {@const others = (db.used_by_workspaces ?? []).filter((w) => w !== targetWorkspace)}