fix(frontend): keep a failed setup's claims across the redirect and rollback

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-08-14 19:07:36 +02:00
co-authored by Claude Opus 5
parent 9fb257b2a9
commit a536efe68a
4 changed files with 82 additions and 4 deletions
@@ -338,7 +338,11 @@
parkWizard({
name: wiz.review.name,
region: wiz.supabase.region,
projectName: wiz.supabase.projectName
projectName: wiz.supabase.projectName,
resourcePath,
claimedPath,
createdProjectName,
createdProjectPath
})
})
@@ -365,6 +369,17 @@
poolerUnavailable = undefined
if (resume) {
wiz.provider = 'supabase'
// The clears above are for a fresh run. This one is the same run coming back from the
// redirect, so what it had already created is still its own to write over.
claimedPath = resume.claimedPath
createdProjectName = resume.createdProjectName
createdProjectPath = resume.createdProjectPath
leftBehind = !!(resume.claimedPath || resume.createdProjectPath)
const cut = resume.resourcePath?.lastIndexOf('/') ?? -1
if (resume.resourcePath && cut > 0) {
wiz.review.folder = resume.resourcePath.slice(0, cut)
wiz.review.resourceName = resume.resourcePath.slice(cut + 1)
}
enterStep(2)
}
}
@@ -17,6 +17,10 @@ vi.mock('./supabaseProvisioning', async (importOriginal) => ({
const existsVariableMock = vi.fn()
const createVariableMock = vi.fn()
const getSettingsMock = vi.fn()
const editDataTableConfigMock = vi.fn()
const testDataTableConnectionMock = vi.fn()
const setupCustomInstanceDbMock = vi.fn()
vi.mock('$lib/gen', () => ({
VariableService: {
existsVariable: (...a: any[]) => existsVariableMock(...a),
@@ -24,8 +28,12 @@ vi.mock('$lib/gen', () => ({
updateVariable: vi.fn()
},
ResourceService: { existsResource: vi.fn(), createResource: vi.fn(), updateResource: vi.fn() },
SettingService: { setupCustomInstanceDb: vi.fn() },
WorkspaceService: { getSettings: vi.fn(), editDataTableConfig: vi.fn() }
SettingService: { setupCustomInstanceDb: (...a: any[]) => setupCustomInstanceDbMock(...a) },
WorkspaceService: {
getSettings: (...a: any[]) => getSettingsMock(...a),
editDataTableConfig: (...a: any[]) => editDataTableConfigMock(...a),
testDataTableConnection: (...a: any[]) => testDataTableConnectionMock(...a)
}
}))
import { newWizardState, runSetup, type WizardState } from './addDataTableModel'
@@ -114,3 +122,37 @@ describe('runSetup refusing to mint over a project it already created', () => {
expect(createSupabaseProjectMock).not.toHaveBeenCalled()
})
})
// The instance branch is the one that has to write its row before it can probe it, since the
// probe is by data table name. A database Windmill cannot store data in must not stay in the
// config -- and a probe that throws leaves exactly the same unusable row as one that says no.
describe('runSetup rolling the instance row back', () => {
function usingInstanceDb(): WizardState {
const state = newWizardState({ name: 'main', projectName: 'x', folder: 'f/team' })
state.provider = 'instance'
state.instance = { mode: 'existing', dbName: 'shared' }
return state
}
beforeEach(() => {
vi.clearAllMocks()
getSettingsMock.mockResolvedValue({ datatable: { datatables: {} } })
editDataTableConfigMock.mockResolvedValue(undefined)
setupCustomInstanceDbMock.mockResolvedValue({ success: true, logs: {} })
})
it('takes the row back out when the probe never answers', async () => {
testDataTableConnectionMock.mockRejectedValue(new Error('connection refused'))
const result = await runSetup(usingInstanceDb(), {
workspace: 'w',
supabaseToken: undefined,
onProgress: () => {}
} as any)
expect(result.ok).toBe(false)
expect(result.error).toContain('connection refused')
expect(result.rowRolledBack).toBe(true)
expect(result.rowWritten).toBe(false)
const lastWrite = editDataTableConfigMock.mock.calls.at(-1)?.[0]
expect(lastWrite.requestBody.settings.datatables).not.toHaveProperty('main')
})
})
@@ -680,6 +680,13 @@ export async function runSetup(state: WizardState, deps: RunDeps): Promise<RunRe
const report = await WorkspaceService.testDataTableConnection({
workspace: deps.workspace,
datatableName: name
}).catch(async (err) => {
// A probe that never answered leaves the same unusable row behind as one that
// answered no -- an unreachable database or a timeout lands here -- so it takes
// the same way out rather than the bare outer catch.
rowRolledBack = await removeRow(deps, name)
rowWritten = !rowRolledBack
throw err
})
if (!report.can_create_table) {
rowRolledBack = await removeRow(deps, name)
@@ -9,7 +9,21 @@
const RESUME_KEY = 'datatable_wizard_resume'
export type WizardResume = { name: string; region: string; projectName: string }
export type WizardResume = {
name: string
region: string
projectName: string
/**
* What the interrupted run had already created. Without these the resumed run meets its
* own secret variable and resource as somebody else's and refuses to write over them,
* which strands the Supabase project it just paid for. No secret is parked -- these are
* paths, and the password they name is already in the workspace.
*/
resourcePath?: string
claimedPath?: string
createdProjectName?: string
createdProjectPath?: string
}
/** True while a wizard run is waiting on the Supabase redirect to come back. */
export function hasParkedWizard(): boolean {