feat(frontend): rebuild data table setup around a read-only row

The wizard gathers intent over two steps, reviews it on a third and writes nothing
until Finish, so a billable Supabase project is created only once the user has seen
what will happen. runSetup is also the retry: every step probes for its own result
before doing anything, so running it again on a half-finished data table resumes
instead of duplicating. Its steps are keyed rather than dispatched on their titles,
where rewording one changed what it did.

The settings row stops being an editable form with a dirty/save cycle. It carries the
name, where the database came from, a health dot and two actions; everything rare
moved into the gear panel, which also offers Finish setup for a data table whose
wizard never completed. Manage is ExploreAssetButton, the control the ducklake list
already uses, and the row and panel both link out to the underlying resource.

supabaseResourceValue no longer assembles the pooler host from the region.
aws-0-<region>.pooler.supabase.com is wrong for any project Supabase allocated
elsewhere, so the host, user and port come from the pooler config endpoint.

Two data tables sharing one database also share _wm_migrations, which is probed
unqualified, so the review step warns when the database being connected is already
behind another data table.

SupabaseConnect is deleted. The resource drawer uses the shared project step
restricted to existing projects: creating one is a billed action and belongs in the
wizard, which has somewhere to report what it did. The kitchen_sink checklist
playground goes with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-08-11 10:15:15 +02:00
parent 3881e4d8ea
commit cd4ecbe52a
16 changed files with 2294 additions and 1505 deletions
@@ -17,12 +17,16 @@
import ResourceGen from './copilot/ResourceGen.svelte'
import SyncResourceTypes from './SyncResourceTypes.svelte'
import Modal2 from './common/modal/Modal2.svelte'
import SupabaseProjectStep, {
type SupabasePick
} from './workspaceSettings/SupabaseProjectStep.svelte'
import { supabaseResourceValue } from './workspaceSettings/supabaseProvisioning'
import SupabaseProjectStep from './workspaceSettings/SupabaseProjectStep.svelte'
import { newWizardState } from './workspaceSettings/addDataTableModel'
import {
getSupabasePooler,
projectRef,
supabaseResourceValue
} from './workspaceSettings/supabaseProvisioning'
import { useSupabaseOauth } from './workspaceSettings/supabaseOauth.svelte'
import { sendUserToast } from '$lib/toast'
import { parsePostgresConnectionString } from '$lib/utils/postgresConnectionString'
interface Props {
resourceType: string
@@ -104,38 +108,38 @@
let connectionString = $state('')
let validConnectionString = $state(true)
function parseConnectionString(close: (_: any) => void) {
const regex =
/postgres(?:ql)?:\/\/(?<user>[^:@]+)(?::(?<password>[^@]+))?@(?<host>[^:\/?]+)(?::(?<port>\d+))?\/(?<dbname>[^\?]+)?(?:\?.*sslmode=(?<sslmode>[^&]+))?/
const match = connectionString.match(regex)
if (match) {
validConnectionString = true
const { user, password, host, port, dbname, sslmode } = match.groups!
rawCode = JSON.stringify(
{
...args,
user,
password: password || args?.password,
host,
port: (port ? Number(port) : undefined) || args?.port,
dbname: dbname || args?.dbname,
sslmode: sslmode || args?.sslmode
},
null,
2
)
rawCodeEditor?.setCode(rawCode)
close(null)
} else {
const parts = parsePostgresConnectionString(connectionString)
if (!parts) {
validConnectionString = false
return
}
validConnectionString = true
rawCode = JSON.stringify(
{
...args,
user: parts.user,
password: parts.password || args?.password,
host: parts.host,
port: parts.port || args?.port,
dbname: parts.dbname || args?.dbname,
sslmode: parts.sslmode || args?.sslmode
},
null,
2
)
rawCodeEditor?.setCode(rawCode)
close(null)
}
let rawCodeEditor: { setCode: (code: string) => void } | undefined = $state(undefined)
let textFileContent: string | undefined = $state(undefined)
let supabaseOpen = $state(false)
let supaStep: ReturnType<typeof SupabaseProjectStep> | undefined = $state(undefined)
let supaResult: SupabasePick | undefined = $state(undefined)
let supaBusy = $state(false)
// Only the intent this form can act on. Creating a project is a billed action and belongs
// in the data table wizard, which can show what it is provisioning and record the result;
// a resource form has nowhere to put either.
let supaIntent = $state(newWizardState({ name: '', projectName: '', folder: '' }).supabase)
// Authorizing is not something to present a dialog about first: the button goes straight
// to the popup, and the dialog opens on the way back, already holding the projects.
@@ -161,23 +165,34 @@
// The resource is being edited here rather than created for us, so the project's password
// goes straight into the form as a value. The user can link it to a secret variable with
// the same affordance every other password field has.
function applySupabasePick(pick: SupabasePick) {
args = {
...(args ?? {}),
...supabaseResourceValue(pick.project, ''),
password: pick.password
async function applySupabasePick() {
const project = supaIntent.project
if (!project || !supaIntent.password) return
supaBusy = true
try {
const pooler =
supaIntent.connectionMode === 'session'
? await getSupabasePooler(supaOauth.token!, projectRef(project))
: undefined
args = {
...(args ?? {}),
...supabaseResourceValue(project, '', {
mode: supaIntent.connectionMode,
pooler
}),
password: supaIntent.password
}
rawCode = JSON.stringify(args, null, 2)
rawCodeEditor?.setCode(rawCode)
supabaseOpen = false
sendUserToast(`Filled in the connection for ${project.name}`)
} catch (err) {
sendUserToast(String(err), true)
} finally {
supaBusy = false
}
rawCode = JSON.stringify(args, null, 2)
rawCodeEditor?.setCode(rawCode)
supabaseOpen = false
supaResult = undefined
sendUserToast(`Filled in the connection for ${pick.project.name}`)
}
$effect(() => {
if (supaResult) applySupabasePick(supaResult)
})
function parseTextFileContent() {
args = {
content: textFileContent
@@ -345,30 +360,24 @@
title="Connect Supabase"
contentClasses="flex flex-col"
fixedWidth="md"
fixedHeight="md"
fixedHeight="lg"
>
<div class="flex h-full flex-col gap-3">
<div class="flex-1 flex flex-col gap-3 min-h-0">
<SupabaseProjectStep
bind:this={supaStep}
bind:result={supaResult}
defaultProjectName={`windmill-${$workspaceStore ?? 'workspace'}`}
continueLabel="Use this project"
/>
{#if supaOauth.token}
<SupabaseProjectStep bind:intent={supaIntent} token={supaOauth.token} existingOnly />
{/if}
</div>
<div class="flex justify-end pt-3">
<Button
size="sm"
variant="accent"
disabled={!supaIntent.project || !supaIntent.password}
loading={supaBusy}
onClick={applySupabasePick}
>
Use this project
</Button>
</div>
{#if supaStep}
{@const action = supaStep.getAction()}
<div class="flex justify-end pt-3">
<Button
size="sm"
variant="accent"
disabled={action.disabled}
loading={action.busy}
onClick={() => action.act?.()}
>
{action.label}
</Button>
</div>
{/if}
</div>
</Modal2>
@@ -1,282 +0,0 @@
<script lang="ts">
import { run } from 'svelte/legacy'
import { Loader2, RotateCwIcon } from 'lucide-svelte'
import { Button, DrawerContent } from './common'
import Select from './select/Select.svelte'
import TextInput from './text_input/TextInput.svelte'
import Drawer from './common/drawer/Drawer.svelte'
import Path from './Path.svelte'
import { sendUserToast } from '$lib/toast'
import { Highlight } from 'svelte-highlight'
import { json } from 'svelte-highlight/languages'
import autosize from '$lib/autosize'
import { ResourceService, VariableService } from '$lib/gen'
import { oauthStore, workspaceStore } from '$lib/stores'
import Password from './Password.svelte'
import { createEventDispatcher } from 'svelte'
import {
DEFAULT_SUPABASE_REGION,
SUPABASE_REGIONS,
createSupabaseProject,
generateDbPassword,
listSupabaseOrgs,
orgSlug,
supabaseResourceValue,
waitUntilSupabaseHealthy,
type SupabaseOrg
} from './workspaceSettings/supabaseProvisioning'
import HighlightTheme from './HighlightTheme.svelte'
let drawer: Drawer | undefined = $state()
let token: undefined | string = $state(undefined)
export async function open() {
token = $oauthStore?.access_token ?? ''
drawer?.openDrawer?.()
step = 'init'
description = ''
}
let step: 'init' | 'resource' = $state('init')
async function listDatabases() {
if (!token) return
databases = undefined
const res = await fetch('/api/oauth/list_supabase', {
headers: {
'Content-Type': 'application/json',
'X-Supabase-Token': token
}
})
databases = await res.json()
}
type Database = {
name: string
database?: { host: string }
region: string
id: string
status?: string
}
let databases: undefined | Database[] = $state(undefined)
let orgs: undefined | SupabaseOrg[] = $state(undefined)
let selectedOrgSlug: string | undefined = $state(undefined)
let newProjectName = $state('')
let creating = $state(false)
let createStatus = $state('')
let selectedRegion: string = $state(DEFAULT_SUPABASE_REGION)
async function listOrgs() {
if (!token) return
try {
orgs = await listSupabaseOrgs(token)
if (orgs?.length === 1) selectedOrgSlug = orgSlug(orgs[0])
} catch (err) {
sendUserToast(String(err), true)
}
}
async function createProject() {
if (!token || !selectedOrgSlug || !newProjectName) return
creating = true
createStatus = 'Creating the project...'
try {
const db_pass = generateDbPassword()
// Surface the password before waiting: Supabase never hands it back, so if the poll
// below fails the project would otherwise exist with a password nobody holds.
password = db_pass
const created = await createSupabaseProject(token, {
name: newProjectName,
organizationSlug: selectedOrgSlug,
region: selectedRegion,
dbPass: db_pass
})
selectedDatabase = created as any
step = 'resource'
try {
selectedDatabase = (await waitUntilSupabaseHealthy(
token,
created.id ?? (created as any).ref,
(st) =>
(createStatus = `Waiting for Supabase to finish provisioning${st ? ` (${st})` : ''}...`)
)) as any
} catch (err) {
sendUserToast(
`${created.name} was created but is not reachable yet (${err}). Its password is filled in below - save the resource and retry the connection once Supabase reports it ready.`,
true
)
}
await listDatabases()
} catch (err) {
sendUserToast(`Could not create the Supabase project: ${err}`, true)
} finally {
creating = false
createStatus = ''
}
}
run(() => {
token != undefined && listDatabases()
})
run(() => {
token != undefined && listOrgs()
})
let selectedDatabase: undefined | Database = $state(undefined)
let description = $state('')
let pathError = $state('')
let password = $state('')
let path: string | undefined = $state(undefined)
let resourceValue = $derived(
selectedDatabase ? supabaseResourceValue(selectedDatabase, path ?? '') : undefined
)
let disabled = $derived(path == undefined || pathError != '' || path == '')
const dispatch = createEventDispatcher()
async function save() {
if (!path) return
await VariableService.createVariable({
workspace: $workspaceStore!,
requestBody: {
path,
value: password,
is_secret: true,
description: 'Password for supabase postgres database',
is_oauth: false
}
})
await ResourceService.createResource({
workspace: $workspaceStore!,
requestBody: {
resource_type: 'postgresql',
path,
value: resourceValue,
description
}
})
sendUserToast('Saved postgres resource')
dispatch('refresh')
drawer?.closeDrawer?.()
}
</script>
<HighlightTheme />
<Drawer bind:this={drawer} size="800px">
<DrawerContent title="Add a Supabase Database" on:close={drawer.closeDrawer}>
{#if step === 'init' || selectedDatabase == undefined}
<h2
>Connect an existing database <div class="inline-block ml-2"
><Button variant="default" wrapperClasses="self-stretch" on:click={listDatabases}
><RotateCwIcon size={12} /></Button
></div
>
</h2>
<div class="mt-6"></div>
{#if databases == undefined}
<Loader2 class="animate-spin" />
{:else}
<div class=" flex flex-col gap-y-2"></div>
{#each databases as database}
<button
class="btn btn-outline-primary mt-2 border p-2 w-full border-secondary-inverse hover:border-secondary rounded"
onclick={() => {
selectedDatabase = database
step = 'resource'
}}
>
<div class="flex flex-row items-center">
<div class="flex-grow">
<h3 class="text-lg font-semibold">{database.name}</h3>
<p class="text-sm text-secondary">id: {database.id} - region: {database.region}</p>
</div>
</div>
</button>
{/each}
{/if}
<h3 class="mt-8 mb-2">Create a new database</h3>
<p class="text-sm text-secondary mb-3">
Windmill creates the project in your Supabase organization and generates its database
password, so you never have to retrieve it from the Supabase dashboard.
</p>
<div class="flex flex-col gap-2 max-w-lg">
<Select
items={(orgs ?? []).map((o) => ({ label: o.name, value: o.slug ?? o.id }))}
bind:value={selectedOrgSlug}
placeholder={orgs === undefined ? 'Loading organizations...' : 'Select an organization'}
disabled={creating}
/>
<TextInput
bind:value={newProjectName}
inputProps={{ placeholder: 'Project name', disabled: creating }}
/>
<Select
items={SUPABASE_REGIONS.map((r) => ({ label: r, value: r }))}
bind:value={selectedRegion}
placeholder="Region"
disabled={creating}
/>
<Button
variant="accent"
disabled={!selectedOrgSlug || !newProjectName || creating}
loading={creating}
on:click={createProject}
>
Create project
</Button>
{#if createStatus}
<p class="text-sm text-secondary">{createStatus}</p>
{/if}
</div>
{:else if step === 'resource'}
<Path
bind:error={pathError}
bind:path
initialPath=""
fullNamePlaceholder={'supabase_' +
selectedDatabase?.name?.replace(/\s+/g, '').replace(/[^\w\s]/gi, '')}
kind="resource"
/>
<h2 class="mt-8 mb-2">Database Password</h2>
<p class="text-sm text-secondary mb-1"
>For security reasons from supabase, the password of the database cannot be retrieved
automatically. In a future update, a dedicated role for windmill will be created and the
password for it will be generated automatically. The password of the database is shown
during the project creation.</p
>
<Password required bind:password />
<h3 class="mt-6 mb-2">Description</h3>
<textarea autocomplete="off" use:autosize bind:value={description}></textarea>
<div class="mt-12"></div>
<p class="my-1 text-sm text-secondary"
>A resource and a variable will be created at path: {path}. The content of the resource will
be:</p
>
<Highlight language={json} code={JSON.stringify(resourceValue, null, 4)} />
{/if}
{#snippet actions()}
<div class="flex gap-1">
{#if step == 'resource' && selectedDatabase != undefined}
<Button variant="default" on:click={() => (step = 'init')}>Back</Button>
<Button {disabled} on:click={save} variant="accent">Save</Button>
{/if}
</div>
{/snippet}
</DrawerContent>
</Drawer>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,77 @@
<script lang="ts">
import Alert from '../common/alert/Alert.svelte'
import type { TestDataTableConnectionResponse } from '$lib/gen'
type Props = {
/** What the report is about: a data table, a Supabase project, a database name. */
name: string
report?: TestDataTableConnectionResponse | undefined
error?: string | undefined
bgClass?: string
class?: string
}
let { name, report, error, bgClass, class: className }: Props = $props()
let fullyPrivileged = $derived(!!report?.can_create_table && !!report?.can_create_schema)
</script>
{#if error}
<Alert type="error" title="Could not connect to {name}" size="xs" {bgClass} class={className}>
{error}
</Alert>
{:else if report}
<Alert
type={fullyPrivileged ? 'success' : 'warning'}
title={fullyPrivileged
? `${name} is reachable and its user can create tables and schemas`
: `${name} is reachable but its user is missing privileges`}
size="xs"
{bgClass}
class={className}
>
<div class="flex flex-col gap-2">
<div>
Connects as <span class="font-mono">{report.user}</span>{#if report.schema}, resolving
unqualified statements to schema <span class="font-mono">{report.schema}</span>{/if}.
</div>
{#if report.suggested_search_path}
<div>
Its search_path resolves to no schema, so unqualified statements fail with
<span class="font-mono">no schema has been selected to create in</span> whatever
privileges the role holds. Point it at one, e.g.
<span class="font-mono select-all">{report.suggested_search_path}</span>.
</div>
{/if}
<ul class="list-disc list-inside">
<li>
Create tables{report.schema ? ` in ${report.schema}` : ''}:
<span class="font-semibold">{report.can_create_table ? 'yes' : 'no'}</span>
</li>
<li>
Create schemas:
<span class="font-semibold">{report.can_create_schema ? 'yes' : 'no'}</span>
</li>
<li>
Migration bookkeeping table exists:
<span class="font-semibold">{report.migrations_table_exists ? 'yes' : 'no'}</span>
</li>
</ul>
{#if report.suggested_grants.length > 0}
<div>
Windmill connects as the role that lacks these privileges, so it cannot grant them itself.
Run as a schema owner or superuser on that database:
</div>
<pre class="whitespace-pre-wrap select-all text-xs"
>{report.suggested_grants.map((g) => `${g};`).join('\n')}</pre
>
{#if report.schema && !report.can_create_table && !report.migrations_table_exists}
<div>
Alternatively, create the <span class="font-mono">_wm_migrations</span> bookkeeping table
yourself and grant only SELECT, INSERT, UPDATE, DELETE on it.
</div>
{/if}
{/if}
</div>
</Alert>
{/if}
@@ -12,6 +12,8 @@
resource_type: 'postgresql' | 'instance'
resource_path?: string | undefined
}
origin?: DataTableOrigin | undefined
setup_incomplete?: boolean | undefined
}[]
}
@@ -30,69 +32,43 @@
}
return s
}
export function convertDataTableSettingsToBackend(
settings: DataTableSettingsType
): NonNullable<GetSettingsResponse['datatable']> {
const s: GetSettingsResponse['datatable'] = { datatables: {} }
for (const dataTable of settings.dataTables) {
const database = dataTable.database
if (dataTable.name in s.datatables)
throw 'Settings contain duplicate dataTable name: ' + dataTable.name
if (!database.resource_path) throw 'No resource selected for ' + dataTable.name
if (database.resource_type === 'instance' && database.resource_path === 'windmill')
throw dataTable.name + ' database cannot be called "windmill"'
s.datatables[dataTable.name] = {
database: dataTable.database
}
}
return s
}
</script>
<script lang="ts">
import { Plus, PlugZap } from 'lucide-svelte'
import { Plus, Settings, Loader2 } from 'lucide-svelte'
import { base } from '$lib/base'
import Button from '../common/button/Button.svelte'
import CloseButton from '../common/CloseButton.svelte'
import ResourcePicker from '../ResourcePicker.svelte'
import SettingsPageHeader from '../settings/SettingsPageHeader.svelte'
import Select from '../select/Select.svelte'
import Cell from '../table/Cell.svelte'
import DataTable from '../table/DataTable.svelte'
import Head from '../table/Head.svelte'
import Row from '../table/Row.svelte'
import TextInput from '../text_input/TextInput.svelte'
import Tooltip from '../Tooltip.svelte'
import { isCustomInstanceDbEnabled, getUnusedInstanceDbName } from './utils.svelte'
import { sendUserToast } from '$lib/toast'
import { getUnusedInstanceDbName } from './utils.svelte'
import {
SettingService,
WorkspaceService,
type GetSettingsResponse,
type TestDataTableConnectionResponse
type DataTableOrigin,
type GetSettingsResponse
} from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { globalDbManagerDrawer, workspaceStore } from '$lib/stores'
import { createAsyncConfirmationModal } from '../common/confirmationModal/asyncConfirmationModal.svelte'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
import { resource } from 'runed'
import CustomInstanceDbSelect from './CustomInstanceDbSelect.svelte'
import { Popover } from '../meltComponents'
import ExploreAssetButton from '../ExploreAssetButton.svelte'
import DataTableMigrationsButton from './DataTableMigrationsButton.svelte'
import { deepEqual } from 'fast-equals'
import { clone } from '$lib/utils'
import SettingsFooter from './SettingsFooter.svelte'
import Alert from '../common/alert/Alert.svelte'
import MissingWorkerTagAlert from '../jobs/MissingWorkerTagAlert.svelte'
import { isCloudHosted } from '$lib/cloud'
import SupabaseIcon from '../icons/SupabaseIcon.svelte'
import { Database } from 'lucide-svelte'
import AddDataTableWizard, {
takeParkedWizard,
type WizardResume
} from './AddDataTableWizard.svelte'
import { Database } from 'lucide-svelte'
import DataTableSettingsPanel from './DataTableSettingsPanel.svelte'
import ExploreAssetButton from '../ExploreAssetButton.svelte'
import { dataTableProvider, dataTableSubtitle } from './dataTableOrigin'
import { useDataTableHealth } from './dataTableHealth.svelte'
import { onMount } from 'svelte'
type Props = {
@@ -101,110 +77,30 @@
let { dataTableSettings = $bindable() }: Props = $props()
// Result of the last "Test connection", shown under the table: the grant
// statements have to stay selectable, which rules out a toast.
let connectionCheck = $state<
| {
name: string
loading: boolean
report?: TestDataTableConnectionResponse
error?: string
}
| undefined
>(undefined)
const customInstanceDbs = resource([() => $workspaceStore], SettingService.listCustomInstanceDbs)
const health = useDataTableHealth(() => $workspaceStore)
// Identifies the request the single result slot is waiting on. The data table
// name is not enough: A -> B -> A leaves two A requests in flight, and the
// first to be issued can be the last to land.
let latestCheck = 0
let confirmationModal = createAsyncConfirmationModal()
let panel: DataTableSettingsPanel | undefined = $state(undefined)
let wizardOpen = $state(false)
let wizardResume: WizardResume | undefined = $state(undefined)
async function testConnection(name: string) {
const check = ++latestCheck
connectionCheck = { name, loading: true }
try {
const report = await WorkspaceService.testDataTableConnection({
workspace: $workspaceStore ?? '',
datatableName: name
})
if (check !== latestCheck) return
connectionCheck = { name, loading: false, report }
} catch (err) {
if (check !== latestCheck) return
connectionCheck = { name, loading: false, error: err?.body ?? err?.message ?? String(err) }
}
}
let tableHeadNames = ['Name', 'Database', '', ''] as const
let tableHeadNames = ['Name', 'Database', 'Status', ''] as const
let tableHeadTooltips: Partial<Record<(typeof tableHeadNames)[number], string | undefined>> = {
Name: 'Data tables are referenced by their name. main is a special name that can be used as the default data table.',
Database: 'The database where the data is stored.'
}
let tempSettings: DataTableSettingsType = $derived.by(() => {
let s = $state($state.snapshot(dataTableSettings))
return s
})
function removeDataTable(index: number) {
tempSettings.dataTables.splice(index, 1)
}
const customInstanceDbs = resource([() => $workspaceStore], SettingService.listCustomInstanceDbs)
function defaultInstanceDbName(): string {
const usedNames = [
...Object.keys(customInstanceDbs.current ?? {}),
...tempSettings.dataTables
...dataTableSettings.dataTables
.filter((d) => d.database.resource_type === 'instance' && d.database.resource_path)
.map((d) => d.database.resource_path!)
]
return getUnusedInstanceDbName('dt', $workspaceStore ?? '', usedNames)
}
async function onSave() {
try {
if (
$isCustomInstanceDbEnabled &&
tempSettings.dataTables.some(
(d) =>
d.database.resource_type === 'instance' &&
!customInstanceDbs.current?.[d.database.resource_path ?? '']?.success
)
) {
let confirm = await confirmationModal.ask({
title: 'Some databases are not setup',
children: 'Are you sure you want to save without setting them up ?',
confirmationText: 'Save anyway'
})
if (!confirm) return
}
const settings = convertDataTableSettingsToBackend(tempSettings)
// Track renames/deletions by stable id (against the saved baseline) so
// the backend can cascade or delete each data table's migrations.
const savedById = new Map(dataTableSettings.dataTables.map((d) => [d.id, d.name]))
const tempIds = new Set(tempSettings.dataTables.map((d) => d.id))
const renames = tempSettings.dataTables
.filter((d) => savedById.has(d.id) && savedById.get(d.id) !== d.name)
.map((d) => ({ from: savedById.get(d.id)!, to: d.name }))
const deleted_datatables = dataTableSettings.dataTables
.filter((d) => !tempIds.has(d.id))
.map((d) => d.name)
await WorkspaceService.editDataTableConfig({
workspace: $workspaceStore!,
requestBody: { settings, renames, deleted_datatables }
})
dataTableSettings = clone(tempSettings)
sendUserToast('Data table settings saved successfully')
} catch (e) {
sendUserToast(e, true)
console.error('Error saving data table settings', e)
throw e
}
}
let wizardOpen = $state(false)
let wizardResume: WizardResume | undefined = $state(undefined)
// Supabase sends the user back here after authorizing; pick the wizard back up where it
// was rather than making them start again.
onMount(() => {
@@ -215,54 +111,35 @@
}
})
async function reloadAfterWizard() {
/** Every write in this tab lands immediately, so there is one way back to the truth. */
async function reload() {
const s = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
dataTableSettings = convertDataTableSettingsFromBackend(s.datatable)
wizardResume = undefined
await health.refetch()
}
let confirmationModal = createAsyncConfirmationModal()
let dirtyMap = $derived.by(() => {
const map: Record<string, boolean> = {}
for (let i = 0; i < tempSettings.dataTables.length; i++) {
let temp = tempSettings.dataTables[i]
let dt = dataTableSettings.dataTables.find((d) => d.id === temp.id)
map[temp.name] = !deepEqual(dt, temp)
}
return map
})
function onDiscard() {
tempSettings.dataTables = $state.snapshot(dataTableSettings.dataTables)
}
export function discard() {
onDiscard()
}
// The tab writes through, so it never holds unsaved work. Kept because the settings page
// asks every tab for one before navigating away.
export function discard() {}
export function unsavedChanges(): { savedValue: any; modifiedValue: any } {
return { savedValue: dataTableSettings, modifiedValue: tempSettings }
return { savedValue: {}, modifiedValue: {} }
}
let hasUnsavedChanges = $derived.by(() => {
return !deepEqual(dataTableSettings, tempSettings)
})
function openManager(name: string) {
globalDbManagerDrawer.val?.openDrawer(
{ type: 'database', resourceType: 'postgresql', resourcePath: `datatable://${name}` },
$workspaceStore
)
}
</script>
<SettingsPageHeader
title="Data tables"
description="Store relational data out of the box. Interact with a fully managed PostgreSQL database directly from the Windmill SDK."
description="Relational storage the whole workspace shares under one name. Scripts, flows and apps address it as <span class='font-mono'>datatable://main</span> instead of picking a PostgreSQL resource, so nobody needs access to the credentials to query it, and you can point that name at another database without touching a line of code. Browse and edit tables, and version schema changes as migrations, from here."
link="https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables"
/>
{#if isCloudHosted()}
<Alert type="info" title="Instance database not available on cloud" class="mb-4" size="xs">
On Windmill Cloud, data tables cannot use the Windmill instance database. Select
<span class="font-semibold">PostgreSQL</span> and provide an external PostgreSQL resource (e.g. Supabase
or Neon) instead.
</Alert>
{/if}
<MissingWorkerTagAlert tag="postgresql" subject="Browsing and querying data tables" class="mb-4" />
<DataTable>
@@ -281,17 +158,15 @@
</tr>
</Head>
<tbody class="divide-y bg-surface-tertiary">
{#if tempSettings.dataTables.length == 0}
{#if dataTableSettings.dataTables.length == 0}
<Row>
<Cell colspan={tableHeadNames.length} class="py-8">
<div class="flex flex-col items-center gap-3 text-center">
<div class="w-9 h-9 rounded-lg bg-surface-secondary border grid place-items-center">
<Database size={18} class="text-secondary" />
</div>
<Database size={24} class="text-secondary" />
<div class="flex flex-col gap-1 items-center">
<span class="font-semibold text-sm">No data table yet</span>
<span class="font-semibold text-sm">No database yet</span>
<p class="text-xs text-secondary max-w-sm">
Give your scripts a database to store and query data.
A data table stores relational data. Give it a database to run on.
{#if isCloudHosted()}
Set one up free in about a minute.
{:else}
@@ -306,112 +181,90 @@
</Cell>
</Row>
{/if}
{#each tempSettings.dataTables as dataTable, dataTableIndex (dataTable.id)}
{#each dataTableSettings.dataTables as dataTable (dataTable.id)}
{@const provider = dataTableProvider(dataTable.database, dataTable.origin)}
{@const status = health.current?.[dataTable.name]}
<!-- Instance data tables have no resource to open: Windmill holds those credentials. -->
{@const resourceHref =
dataTable.database.resource_type === 'postgresql' && dataTable.database.resource_path
? `${base}/resources?workspace=${$workspaceStore}#/resource/${
dataTable.database.resource_path
}`
: undefined}
<Row>
<Cell first class="w-48 relative">
<TextInput bind:value={dataTable.name} inputProps={{ placeholder: 'Name', id: 'name' }} />
<Cell first class="w-48">
<!-- Managing the data is the daily action, so it is the row's own click.
Connection settings are rare and sit behind the gear. A data table that is not
usable yet opens the panel instead: there is nothing to manage. -->
<button
class="text-left font-medium text-xs hover:text-blue-500"
onclick={() =>
dataTable.setup_incomplete ? panel?.open(dataTable) : openManager(dataTable.name)}
>
{dataTable.name}
</button>
</Cell>
<Cell>
<div class="flex gap-2">
<div class="relative">
{#if dataTable.database.resource_type === 'instance'}
<Tooltip wrapperClass="absolute mt-[0.6rem] right-2 z-20" placement="bottom-start">
Use Windmill's PostgreSQL instance
</Tooltip>
{/if}
<Select
items={[
{ value: 'postgresql', label: 'PostgreSQL' },
{
value: 'instance',
label: 'Instance',
disabled: isCloudHosted(),
subtitle: $isCustomInstanceDbEnabled
? undefined
: isCloudHosted()
? 'Not available on cloud'
: 'Superadmin only'
}
]}
bind:value={
() => dataTable.database.resource_type,
(resource_type) => {
dataTable.database = {
resource_type,
resource_path:
resource_type === 'instance' ? defaultInstanceDbName() : undefined
}
}
}
id="database-type-select"
class="w-28"
/>
</div>
<div class="flex items-center gap-1 w-80 relative">
{#if dataTable.database.resource_type !== 'instance'}
<ResourcePicker
class="flex-1"
bind:value={dataTable.database.resource_path}
resourceType={dataTable.database.resource_type}
/>
{:else}
<CustomInstanceDbSelect
class="flex-1"
{confirmationModal}
{customInstanceDbs}
bind:value={dataTable.database.resource_path}
tag="datatable"
/>
{/if}
</div>
</div>
</Cell>
<Cell class="whitespace-nowrap">
<div class="flex gap-2">
<DataTableMigrationsButton
workspace={$workspaceStore ?? ''}
datatable={dataTable.name}
disabled={!!dirtyMap[dataTable.name]}
/>
<Button
size="xs"
color="light"
variant="border"
startIcon={{ icon: PlugZap }}
iconOnly
disabled={!!dirtyMap[dataTable.name]}
loading={connectionCheck?.name === dataTable.name && connectionCheck.loading}
title="Test connection: check the database is reachable and its user can create tables"
on:click={() => testConnection(dataTable.name)}
/>
{#if dirtyMap[dataTable.name]}
<Popover
openOnHover
contentClasses="p-2 text-sm text-secondary italic"
class="cursor-not-allowed"
>
{#snippet trigger()}
<ExploreAssetButton
asset={{ kind: 'datatable', path: dataTable.name }}
disabled
/>
{/snippet}
{#snippet content()}
Please save settings first
{/snippet}
</Popover>
<div class="flex items-center gap-2 min-w-0 text-xs text-secondary">
{#if provider === 'supabase'}
<SupabaseIcon height="14px" width="14px" />
{:else}
<ExploreAssetButton asset={{ kind: 'datatable', path: dataTable.name }} />
<Database size={14} class="text-secondary shrink-0" />
{/if}
{#if resourceHref}
<a
href={resourceHref}
target="_blank"
rel="noreferrer"
class="truncate font-mono hover:text-blue-500 hover:underline"
>
{dataTableSubtitle(dataTable.database, dataTable.origin)}
</a>
{:else}
<span class="truncate font-mono">
{dataTableSubtitle(dataTable.database, dataTable.origin)}
</span>
{/if}
</div>
</Cell>
<Cell class="w-12">
<CloseButton small on:close={() => removeDataTable(dataTableIndex)} />
<Cell class="whitespace-nowrap">
{#if dataTable.setup_incomplete}
<span class="inline-flex items-center gap-2 text-xs text-yellow-600">
<span class="w-2 h-2 rounded-full bg-yellow-500 shrink-0"></span> Setup incomplete
</span>
{:else if health.loading && !status}
<span class="inline-flex items-center gap-2 text-xs text-secondary">
<Loader2 size={14} class="animate-spin" /> Checking
</span>
{:else if status?.ok}
<span class="inline-flex items-center gap-2 text-xs text-green-600">
<span class="w-2 h-2 rounded-full bg-green-500 shrink-0"></span> Connected
</span>
{:else if status}
<span class="inline-flex items-center gap-2 text-xs text-red-500">
<span class="w-2 h-2 rounded-full bg-red-500 shrink-0"></span> Connection failed
</span>
{/if}
</Cell>
<Cell class="whitespace-nowrap">
<div class="flex items-center justify-end gap-2">
<ExploreAssetButton
asset={{ kind: 'datatable', path: dataTable.name }}
disabled={dataTable.setup_incomplete}
/>
<Button
unifiedSize="md"
variant="default"
startIcon={{ icon: Settings }}
iconOnly
title="Connection settings"
on:click={() => panel?.open(dataTable)}
/>
</div>
</Cell>
</Row>
{/each}
{#if tempSettings.dataTables.length > 0}
{#if dataTableSettings.dataTables.length > 0}
<Row class="!border-0">
<Cell colspan={tableHeadNames.length} class="pt-0 pb-2">
<div class="flex justify-center">
@@ -430,79 +283,23 @@
</tbody>
</DataTable>
{#if connectionCheck && !connectionCheck.loading}
{@const report = connectionCheck.report}
{#if connectionCheck.error}
<Alert type="error" title="Could not connect to {connectionCheck.name}" class="mt-4" size="xs">
{connectionCheck.error}
</Alert>
{:else if report}
{@const fullyPrivileged = report.can_create_table && report.can_create_schema}
<Alert
type={fullyPrivileged ? 'success' : 'warning'}
title={fullyPrivileged
? `${connectionCheck.name} is reachable and its user can create tables and schemas`
: `${connectionCheck.name} is reachable but its user is missing privileges`}
class="mt-4"
size="xs"
>
<div class="flex flex-col gap-2">
<div>
Connects as <span class="font-mono">{report.user}</span>{#if report.schema}, resolving
unqualified statements to schema <span class="font-mono">{report.schema}</span>{/if}.
</div>
{#if report.suggested_search_path}
<div>
Its search_path resolves to no schema, so unqualified statements fail with
<span class="font-mono">no schema has been selected to create in</span> whatever
privileges the role holds. Point it at one, e.g.
<span class="font-mono select-all">{report.suggested_search_path}</span>.
</div>
{/if}
<ul class="list-disc list-inside">
<li>
Create tables{report.schema ? ` in ${report.schema}` : ''}:
<span class="font-semibold">{report.can_create_table ? 'yes' : 'no'}</span>
</li>
<li>
Create schemas:
<span class="font-semibold">{report.can_create_schema ? 'yes' : 'no'}</span>
</li>
<li>
Migration bookkeeping table exists:
<span class="font-semibold">{report.migrations_table_exists ? 'yes' : 'no'}</span>
</li>
</ul>
{#if report.suggested_grants.length > 0}
<div>
Windmill connects as the role that lacks these privileges, so it cannot grant them
itself. Run as a schema owner or superuser on that database:
</div>
<pre class="whitespace-pre-wrap select-all text-xs"
>{report.suggested_grants.map((g) => `${g};`).join('\n')}</pre
>
{#if report.schema && !report.can_create_table && !report.migrations_table_exists}
<div>
Alternatively, create the <span class="font-mono">_wm_migrations</span> bookkeeping table
yourself and grant only SELECT, INSERT, UPDATE, DELETE on it.
</div>
{/if}
{/if}
</div>
</Alert>
{/if}
{#if isCloudHosted()}
<Alert type="info" title="Instance database not available on cloud" class="mt-4" size="xs">
On Windmill Cloud, data tables cannot use the Windmill instance database. Connect Supabase or
bring your own PostgreSQL instead.
</Alert>
{/if}
<SettingsFooter
class="mt-8"
{hasUnsavedChanges}
{onSave}
{onDiscard}
saveLabel="Save data table settings"
/>
<ConfirmationModal {...confirmationModal.props} />
<DataTableSettingsPanel
bind:this={panel}
{customInstanceDbs}
{confirmationModal}
existingNames={dataTableSettings.dataTables.map((d) => d.name)}
onChanged={reload}
/>
<AddDataTableWizard
bind:opened={
() => wizardOpen,
@@ -513,9 +310,14 @@
if (!v) wizardResume = undefined
}
}
existingNames={tempSettings.dataTables.map((d) => d.name)}
existingNames={dataTableSettings.dataTables.map((d) => d.name)}
existingDataTables={dataTableSettings.dataTables.map((d) => ({
name: d.name,
resourcePath: d.database.resource_path,
projectRef: d.origin?.project_ref
}))}
resume={wizardResume}
onDone={reloadAfterWizard}
onDone={reload}
{customInstanceDbs}
{confirmationModal}
{defaultInstanceDbName}
@@ -0,0 +1,554 @@
<script lang="ts">
import { ExternalLink, Loader2 } from 'lucide-svelte'
import { base } from '$lib/base'
import Button from '../common/button/Button.svelte'
import Drawer from '../common/drawer/Drawer.svelte'
import DrawerContent from '../common/drawer/DrawerContent.svelte'
import Alert from '../common/alert/Alert.svelte'
import TextInput from '../text_input/TextInput.svelte'
import Password from '../Password.svelte'
import Select from '../select/Select.svelte'
import ResourcePicker from '../ResourcePicker.svelte'
import CustomInstanceDbSelect from './CustomInstanceDbSelect.svelte'
import DataTableConnectionReport from './DataTableConnectionReport.svelte'
import SetupChecklist, { type SetupStep } from '../wizards/SetupChecklist.svelte'
import Section from '../Section.svelte'
import Label from '../Label.svelte'
import {
ResourceService,
VariableService,
WorkspaceService,
type DataTableOrigin,
type ListCustomInstanceDbsResponse,
type TestDataTableConnectionResponse
} from '$lib/gen'
import type { ResourceReturn } from 'runed'
import type { ConfirmationModalHandle } from '../common/confirmationModal/asyncConfirmationModal.svelte'
import { userStore, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { isCloudHosted } from '$lib/cloud'
import { isCustomInstanceDbEnabled } from './utils.svelte'
import { useSupabaseOauth } from './supabaseOauth.svelte'
import { isSupabaseHost } from './dataTableOrigin'
import { derivePlan, newWizardState, runSetup, type WizardState } from './addDataTableModel'
export type PanelDataTable = {
name: string
database: { resource_type: 'postgresql' | 'instance'; resource_path?: string }
origin?: DataTableOrigin
setup_incomplete?: boolean
}
type Props = {
customInstanceDbs: ResourceReturn<ListCustomInstanceDbsResponse>
confirmationModal: ConfirmationModalHandle
existingNames: string[]
/** Reloads the settings page's rows and health after anything here changes them. */
onChanged: () => Promise<void>
}
let { customInstanceDbs, confirmationModal, existingNames, onChanged }: Props = $props()
let drawer: Drawer | undefined = $state(undefined)
let dt: PanelDataTable | undefined = $state(undefined)
let renameTo = $state('')
let database = $state<{ resource_type: 'postgresql' | 'instance'; resource_path?: string }>({
resource_type: 'postgresql'
})
let busy = $state(false)
let check = $state<{
loading: boolean
report?: TestDataTableConnectionResponse
error?: string
}>({ loading: false })
let newPassword = $state('')
let resourceValue = $state<any>(undefined)
/** Finish setup, for a data table whose wizard run never completed. */
let resume = $state<{ steps: SetupStep[]; running: boolean } | undefined>(undefined)
const supaOauth = useSupabaseOauth()
export function open(target: PanelDataTable) {
dt = target
renameTo = target.name
database = { ...target.database }
check = { loading: false }
newPassword = ''
resourceValue = undefined
resume = undefined
loadResource(target)
drawer?.openDrawer()
}
// The row only knows the resource path; the panel is the one place that can afford to read
// the resource itself, which is how a data table created before `origin` existed can still
// be told apart from a plain Postgres one.
async function loadResource(target: PanelDataTable) {
if (target.database.resource_type !== 'postgresql' || !target.database.resource_path) return
try {
const resource = await ResourceService.getResource({
workspace: $workspaceStore!,
path: target.database.resource_path
})
resourceValue = resource.value
} catch {
resourceValue = undefined
}
}
// All of these take the data table as a parameter: a `$derived` that reads state declared
// in this same scope narrows `PanelDataTable | undefined` to `never` under the checker.
function supabaseBacked(target: PanelDataTable | undefined, host: string | undefined): boolean {
return target?.origin?.provider === 'supabase' || isSupabaseHost(host)
}
function renamed(target: PanelDataTable | undefined, to: string): boolean {
return !!target && to.trim() !== target.name && !!to.trim()
}
function repointed(
target: PanelDataTable | undefined,
next: { resource_type: string; resource_path?: string }
): boolean {
return (
!!target &&
(next.resource_type !== target.database.resource_type ||
next.resource_path !== target.database.resource_path)
)
}
let isSupabase = $derived(supabaseBacked(dt, resourceValue?.host))
let renameChanged = $derived(renamed(dt, renameTo))
let renameTaken = $derived(
renameChanged && existingNames.filter((n) => n !== dt?.name).includes(renameTo.trim())
)
let databaseChanged = $derived(repointed(dt, database))
async function testConnection() {
if (!dt) return
check = { loading: true }
try {
const report = await WorkspaceService.testDataTableConnection({
workspace: $workspaceStore!,
datatableName: dt.name
})
check = { loading: false, report }
} catch (err: any) {
check = { loading: false, error: err?.body ?? err?.message ?? String(err) }
}
}
/**
* Both a rename and a repoint go through the config form, which replaces the whole map, so
* the rest is read back and sent with it. Renames are declared separately because the
* backend cascades each data table's migration storage onto the new name.
*/
async function applyConfig(opts: { rename?: boolean; repoint?: boolean }) {
if (!dt) return
const confirmed = await confirmationModal.ask({
title: opts.rename ? `Rename ${dt.name}?` : `Connect ${dt.name} to another database?`,
children: opts.rename
? `Every script that refers to datatable://${dt.name} will stop working until it is updated to datatable://${renameTo.trim()}.`
: `${dt.name} will read and write a different database. Tables already created in the current one stay where they are.`,
confirmationText: opts.rename ? 'Rename' : 'Connect'
})
if (!confirmed) return
busy = true
try {
const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
const datatables: Record<string, any> = { ...(settings.datatable?.datatables ?? {}) }
const current = datatables[dt.name]
delete datatables[dt.name]
const name = opts.rename ? renameTo.trim() : dt.name
datatables[name] = {
...current,
database: opts.repoint ? { ...database } : current.database
}
await WorkspaceService.editDataTableConfig({
workspace: $workspaceStore!,
requestBody: {
settings: { datatables },
renames: opts.rename ? [{ from: dt.name, to: name }] : [],
deleted_datatables: []
}
})
sendUserToast(opts.rename ? `Renamed to ${name}` : `${name} now uses a different database`)
drawer?.closeDrawer()
await onChanged()
} catch (err) {
sendUserToast(String(err), true)
} finally {
busy = false
}
}
/**
* Supabase never returns a database password through its API, so there is nothing to
* re-fetch and no point re-authorizing: the repair is to be told the current password, or
* a new one after it has been reset in Supabase.
*/
async function updatePassword() {
if (!dt || !newPassword || !dt.database.resource_path) return
busy = true
try {
const varPath = String(resourceValue?.password ?? '').startsWith('$var:')
? String(resourceValue.password).slice('$var:'.length)
: dt.database.resource_path
await VariableService.updateVariable({
workspace: $workspaceStore!,
path: varPath,
requestBody: { value: newPassword, is_secret: true }
})
newPassword = ''
sendUserToast('Password updated')
await testConnection()
} catch (err) {
sendUserToast(String(err), true)
} finally {
busy = false
}
}
async function finishSetup() {
if (!dt) return
if (dt.origin?.provider === 'supabase' && !supaOauth.authed) {
supaOauth.connect()
return
}
const wiz: WizardState = newWizardState({
name: dt.name,
projectName: dt.origin?.project_name ?? '',
folder: ''
})
wiz.review.name = dt.name
if (dt.origin?.provider === 'supabase') {
wiz.provider = 'supabase'
wiz.supabase.mode = 'create'
wiz.supabase.org = dt.origin.org
wiz.supabase.region = dt.origin.region ?? wiz.supabase.region
wiz.supabase.connectionMode = dt.origin.connection_mode === 'direct' ? 'direct' : 'session'
const path = dt.database.resource_path ?? ''
wiz.review.folder = path.split('/').slice(0, 2).join('/')
wiz.review.resourceName = path.split('/').slice(2).join('/')
} else if (dt.database.resource_type === 'instance') {
wiz.provider = 'instance'
wiz.instance = { mode: 'create', dbName: dt.database.resource_path }
} else {
wiz.provider = 'resource'
wiz.own = { mode: 'pick', resourcePath: dt.database.resource_path, connectionString: '' }
}
resume = { steps: [], running: true }
try {
const resumeFrom = await derivePlan(wiz, {
workspace: $workspaceStore!,
supabaseToken: supaOauth.token
})
resume = { steps: resumeFrom, running: true }
const result = await runSetup(wiz, {
workspace: $workspaceStore!,
username: $userStore?.username ?? 'admin',
supabaseToken: supaOauth.token,
confirmInstanceSetup: async () => true,
onInstanceDbsChanged: async () => {
await customInstanceDbs.refetch()
},
onProgress: (steps) => (resume = { steps, running: true }),
resumeFrom
})
resume = { steps: resume?.steps ?? [], running: false }
if (result.ok) {
sendUserToast(`${dt.name} is ready`)
drawer?.closeDrawer()
}
await onChanged()
} catch (err) {
sendUserToast(String(err), true)
resume = { steps: resume?.steps ?? [], running: false }
}
}
async function remove() {
if (!dt) return
const path = dt.database.resource_type === 'postgresql' ? dt.database.resource_path : undefined
const mintedHere = !!dt.origin && dt.origin.provider !== 'resource'
const confirmed = await confirmationModal.ask({
title: `Delete ${dt.name}?`,
children: isSupabase
? `${dt.name} is removed from this workspace and scripts referring to it will fail. The Supabase project ${dt.origin?.project_name ?? ''} keeps running — delete it in Supabase if you no longer want it.`
: `${dt.name} is removed from this workspace and scripts referring to it will fail. The underlying database is not touched.`,
confirmationText: 'Delete data table'
})
if (!confirmed) return
busy = true
try {
const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
const datatables: Record<string, any> = { ...(settings.datatable?.datatables ?? {}) }
delete datatables[dt.name]
await WorkspaceService.editDataTableConfig({
workspace: $workspaceStore!,
requestBody: { settings: { datatables }, renames: [], deleted_datatables: [dt.name] }
})
if (mintedHere && path) {
// Only what this wizard created is offered for removal; a resource the user picked
// belongs to them and may well be used elsewhere.
const alsoResource = await confirmationModal.ask({
title: 'Remove the saved connection too?',
children: `Windmill created the resource and secret variable at ${path} for this data table. Delete them as well?`,
confirmationText: 'Delete them'
})
if (alsoResource) {
await ResourceService.deleteResource({ workspace: $workspaceStore!, path }).catch(
() => {}
)
await VariableService.deleteVariable({ workspace: $workspaceStore!, path }).catch(
() => {}
)
}
}
sendUserToast(`${dt.name} deleted`)
drawer?.closeDrawer()
await onChanged()
} catch (err) {
sendUserToast(String(err), true)
} finally {
busy = false
}
}
function supabaseProjectUrl(origin: DataTableOrigin | undefined): string | undefined {
if (!origin?.project_ref) return undefined
return `https://supabase.com/dashboard/project/${origin.project_ref}`
}
</script>
<Drawer bind:this={drawer} size="600px">
<DrawerContent title={dt?.name ?? 'Data table'} on:close={() => drawer?.closeDrawer()}>
{#if dt}
<div class="flex flex-col gap-6">
{#if dt.setup_incomplete}
<div class="flex flex-col gap-2">
<Alert type="warning" size="xs" title="Setup never finished">
{dt.name} is recorded but not usable yet. Finishing picks up where it stopped and will
not create a second project.
</Alert>
{#if resume}
<SetupChecklist steps={resume.steps} />
{/if}
<div>
<Button size="xs" variant="accent" loading={resume?.running} onClick={finishSetup}>
{dt.origin?.provider === 'supabase' && !supaOauth.authed
? 'Sign in to Supabase'
: 'Finish setup'}
</Button>
</div>
</div>
{/if}
<Section label="Connection" small class="flex flex-col gap-2">
<dl
class="grid grid-cols-[9rem_1fr] gap-y-1 gap-x-3 text-xs border rounded-md p-3 border-border-light"
>
{#if dt.origin?.project_name}
<dt class="text-secondary">Supabase project</dt>
<dd class="text-emphasis flex items-center gap-1">
{dt.origin.project_name}
{#if supabaseProjectUrl(dt.origin)}
<a
href={supabaseProjectUrl(dt.origin)}
target="_blank"
rel="noreferrer"
class="text-blue-500"><ExternalLink size={12} /></a
>
{/if}
</dd>
{/if}
{#if dt.origin?.org}
<dt class="text-secondary">Organization</dt>
<dd class="text-emphasis">{dt.origin.org}</dd>
{/if}
{#if dt.origin?.region}
<dt class="text-secondary">Region</dt>
<dd class="text-emphasis">{dt.origin.region}</dd>
{/if}
{#if dt.origin?.connection_mode}
<dt class="text-secondary">Connection</dt>
<dd class="text-emphasis">
{dt.origin.connection_mode === 'session' ? 'Session pooler' : 'Direct'}
</dd>
{/if}
<dt class="text-secondary">
{dt.database.resource_type === 'instance' ? 'Windmill database' : 'Resource'}
</dt>
<dd class="text-emphasis font-mono">
{#if dt.database.resource_type === 'postgresql' && dt.database.resource_path}
<!-- New tab: the drawer sits on the settings page, so navigating in place
would close everything the reader is in the middle of. -->
<a
href="{base}/resources?workspace={$workspaceStore}#/resource/{dt.database
.resource_path}"
target="_blank"
rel="noreferrer"
class="text-blue-500 hover:underline inline-flex items-center gap-1 break-all"
>
{dt.database.resource_path}<ExternalLink size={12} class="shrink-0" />
</a>
{:else}
{dt.database.resource_path ?? '—'}
{/if}
</dd>
{#if resourceValue?.host}
<dt class="text-secondary">Host</dt>
<dd class="text-emphasis font-mono break-all">{resourceValue.host}</dd>
{/if}
{#if dt.origin?.connected_by}
<dt class="text-secondary">Connected by</dt>
<dd class="text-emphasis">
{dt.origin.connected_by}{dt.origin.connected_at
? ` · ${new Date(dt.origin.connected_at).toLocaleDateString()}`
: ''}
</dd>
{/if}
</dl>
<div class="flex items-center gap-2">
<Button
size="xs"
variant="default"
loading={check.loading}
disabled={dt.setup_incomplete}
onClick={testConnection}
>
Test connection
</Button>
</div>
<DataTableConnectionReport name={dt.name} report={check.report} error={check.error} />
</Section>
{#if isSupabase}
<Label label="Database password" class="gap-2">
<Password bind:password={() => newPassword, (v) => (newPassword = v ?? '')} />
<p class="text-2xs text-secondary">
Supabase never exposes a project's database password, so signing in again cannot
recover it. Paste the current one, or
{#if supabaseProjectUrl(dt.origin)}
<a
href="{supabaseProjectUrl(dt.origin)}/database/settings"
target="_blank"
rel="noreferrer"
class="text-blue-500 hover:underline">set a new one in Supabase</a
>
{:else}
set a new one in Supabase
{/if}
— every existing connection to that project stops working when you do.
</p>
<div>
<Button
size="xs"
variant="default"
disabled={!newPassword}
loading={busy}
onClick={updatePassword}
>
Update password
</Button>
</div>
</Label>
{/if}
<Label label="Database" class="gap-2">
<p class="text-2xs text-secondary">
Scripts address this data table by name, as
<span class="font-mono">datatable://{dt.name}</span>. The database below is where its
tables actually live. Connecting to another one moves nothing across — the data table
then shows whatever that database already contains.
</p>
<div class="flex gap-2">
<Select
items={[
{ value: 'postgresql', label: 'PostgreSQL' },
{
value: 'instance',
label: 'Instance',
disabled: isCloudHosted(),
subtitle: $isCustomInstanceDbEnabled
? undefined
: isCloudHosted()
? 'Not available on cloud'
: 'Superadmin only'
}
]}
bind:value={
() => database.resource_type,
(resource_type) => (database = { resource_type, resource_path: undefined })
}
class="w-32"
/>
<div class="flex-1">
{#if database.resource_type === 'instance'}
<CustomInstanceDbSelect
{confirmationModal}
{customInstanceDbs}
bind:value={database.resource_path}
tag="datatable"
/>
{:else}
<ResourcePicker bind:value={database.resource_path} resourceType="postgresql" />
{/if}
</div>
</div>
<div>
<Button
size="xs"
variant="default"
disabled={!databaseChanged || !database.resource_path}
loading={busy}
onClick={() => applyConfig({ repoint: true })}
>
Connect to another database
</Button>
</div>
</Label>
<Label label="Name" class="gap-2">
<TextInput bind:value={renameTo} />
<p class="text-2xs text-secondary">
{#if renameTaken}
<span class="text-red-500">A data table called {renameTo.trim()} already exists.</span
>
{:else}
Scripts refer to this data table as
<span class="font-mono">datatable://{dt.name}</span>. Renaming it breaks every one of
them until they are updated.
{/if}
</p>
<div>
<Button
size="xs"
variant="default"
disabled={!renameChanged || renameTaken || !renameTo.trim()}
loading={busy}
onClick={() => applyConfig({ rename: true })}
>
Rename
</Button>
</div>
</Label>
<Section label="Danger zone" small class="flex flex-col gap-2">
<div>
<Button size="xs" variant="default" destructive loading={busy} onClick={remove}>
Delete data table
</Button>
</div>
</Section>
</div>
{:else}
<div class="flex items-center gap-2 text-xs text-secondary">
<Loader2 size={16} class="animate-spin" /> Loading
</div>
{/if}
</DrawerContent>
</Drawer>
@@ -0,0 +1,61 @@
<script lang="ts">
import { ChevronRight } from 'lucide-svelte'
import type { SupabaseConnectionMode } from './supabaseProvisioning'
type Props = {
mode: SupabaseConnectionMode
onChange?: () => void
}
let { mode = $bindable(), onChange }: Props = $props()
let open = $state(false)
function set(v: SupabaseConnectionMode) {
if (v === mode) return
mode = v
onChange?.()
}
const OPTIONS: { value: SupabaseConnectionMode; title: string; detail: string }[] = [
{
value: 'session',
title: 'Session pooler',
detail: 'Reaches Supabase over IPv4. Works from any worker.'
},
{
value: 'direct',
title: 'Direct connection',
detail:
'IPv6 only, unless the project has the IPv4 add-on. Workers on IPv4-only networks cannot reach it.'
}
]
</script>
<div class="border-t border-border-light pt-2">
<button
class="flex items-center gap-1 text-2xs text-secondary hover:text-primary"
onclick={() => (open = !open)}
>
<ChevronRight size={12} class="transition-transform {open ? 'rotate-90' : ''}" />
Connection mode: {mode === 'session' ? 'session pooler' : 'direct'}
</button>
{#if open}
<div class="flex flex-col gap-1.5 mt-2">
{#each OPTIONS as option (option.value)}
{@const selected = mode === option.value}
<button
class="text-left border rounded-md p-2 transition-colors {selected
? 'border-border-selected/50 bg-surface-accent-selected'
: 'border-border-light hover:bg-surface-hover'}"
onclick={() => set(option.value)}
>
<span class="text-xs font-medium {selected ? 'text-accent' : 'text-emphasis'}">
{option.title}{option.value === 'session' ? ' · recommended' : ''}
</span>
<span class="block text-2xs text-secondary">{option.detail}</span>
</button>
{/each}
</div>
{/if}
</div>
@@ -1,104 +1,71 @@
<script lang="ts" module>
/** What the host's primary button should say and do right now. */
export type SupabaseAction = {
label: string
disabled: boolean
busy?: boolean
act?: () => void
}
export type SupabasePick = { project: SupabaseProject; password: string }
</script>
<script lang="ts">
import Alert from '../common/alert/Alert.svelte'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
import TextInput from '../text_input/TextInput.svelte'
import Password from '../Password.svelte'
import Select from '../select/Select.svelte'
import SetupChecklist, { type SetupStep } from '../wizards/SetupChecklist.svelte'
import { Database, Loader2 } from 'lucide-svelte'
import { tick } from 'svelte'
import { sendUserToast } from '$lib/toast'
import { useSupabaseOauth } from './supabaseOauth.svelte'
import SupabaseConnectionMode from './SupabaseConnectionMode.svelte'
import type { WizardState } from './addDataTableModel'
import {
DEFAULT_SUPABASE_REGION,
SUPABASE_REGIONS,
createSupabaseProject,
generateDbPassword,
getSupabaseOrgPlan,
listSupabaseOrgs,
listSupabaseProjects,
orgSlug,
supabaseSetupSteps,
waitUntilSupabaseHealthy,
projectOrg,
projectRef,
SUPABASE_REGIONS,
type SupabaseOrg,
type SupabaseProject
} from './supabaseProvisioning'
type Props = {
/** Set once a project is usable and its password is known. */
result?: SupabasePick | undefined
defaultProjectName?: string
/** Resuming after a popup-blocked redirect. */
resume?: { region: string; projectName: string } | undefined
/** Called before the redirect fallback, so the host can park what the user had chosen. */
onPopupBlocked?: (state: { region: string; projectName: string }) => void
/** Appended below the provisioning steps, for work the host does after this one. */
extraSteps?: SetupStep[]
/** True while the host is busy with `result`, so the action reflects it. */
hostBusy?: boolean
/** Overrides the action label once everything here is done. */
continueLabel?: string
/** The wizard's Supabase slice. Collected only -- nothing here creates anything. */
intent: WizardState['supabase']
token: string
/** Hides the create tab where provisioning a billed project is not on offer. */
existingOnly?: boolean
/** Fired whenever the choice changes, so the host can drop what it derived from it. */
onIntentChange?: () => void
}
let {
result = $bindable(undefined),
defaultProjectName,
resume,
onPopupBlocked,
extraSteps,
hostBusy = false,
continueLabel = 'Continue'
}: Props = $props()
const oauth = useSupabaseOauth({
onPopupBlocked: () => onPopupBlocked?.({ region, projectName })
})
let mode: 'create' | 'existing' = $state('create')
let { intent = $bindable(), token, existingOnly = false, onIntentChange }: Props = $props()
let orgs: SupabaseOrg[] | undefined = $state(undefined)
let projects: SupabaseProject[] | undefined = $state(undefined)
let selectedOrg: string | undefined = $state(undefined)
let region = $state(resume?.region ?? DEFAULT_SUPABASE_REGION)
let projectName = $state(resume?.projectName ?? defaultProjectName ?? '')
let selectedProject: SupabaseProject | undefined = $state(undefined)
let existingPassword = $state('')
// 0 idle, 1 creating, 2 starting, 3 ready
let provisioning = $state(0)
let provisionStatus = $state('')
/** Set when a project was created but could not be handed over; the password is otherwise lost. */
let strandedPassword = $state('')
let plans: Record<string, string> = $state({})
// Nothing but a spinner until *both* lists are in. Which mode to open on depends on the
// projects, so clearing this when only the orgs have landed is what makes the toggle flip
// under the user a moment later.
let loading = $state(false)
let loaded = $state(false)
$effect(() => {
if (oauth.token && orgs === undefined) load(oauth.token)
if (token && !loaded) load(token)
})
async function load(t: string) {
loaded = true
loading = true
try {
orgs = await listSupabaseOrgs(t)
if (orgs?.length && !selectedOrg) selectedOrg = orgSlug(orgs[0])
if (orgs?.length && !intent.org) intent.org = orgSlug(orgs[0])
projects = await listSupabaseProjects(t)
// Someone who already has a Supabase database almost always means to connect it
// rather than make a second one. Decided before anything renders, so the toggle
// never visibly flips under the user; a resumed run was already mid-creation.
if (!resume && projects?.length) mode = 'existing'
// never visibly flips under the user.
if (projects?.length) intent.mode = 'existing'
else if (existingOnly) intent.mode = 'existing'
// The plan decides who gets billed, and the list endpoint does not carry it.
for (const o of orgs ?? []) {
getSupabaseOrgPlan(t, orgSlug(o)).then((p) => {
if (p) plans[orgSlug(o)] = p
})
}
} catch (err) {
sendUserToast(String(err), true)
orgs = orgs ?? []
@@ -113,174 +80,159 @@
return p.status === 'INACTIVE' ? 'paused' : p.status.toLowerCase().replace(/_/g, ' ')
}
async function provision() {
if (!oauth.token || !selectedOrg || !projectName) return
provisioning = 1
try {
const dbPass = generateDbPassword()
// Surface the password before waiting: Supabase never hands it back, so a failure
// after this point would leave a project whose password nobody holds.
strandedPassword = dbPass
const created = await createSupabaseProject(oauth.token, {
name: projectName,
organizationSlug: selectedOrg,
region,
dbPass
})
provisioning = 2
const healthy = await waitUntilSupabaseHealthy(
oauth.token,
created.id ?? (created as any).ref,
(st) => (provisionStatus = st ?? '')
)
provisioning = 3
strandedPassword = ''
result = { project: healthy, password: dbPass }
} catch (err) {
provisioning = 0
sendUserToast(`Could not create the Supabase project: ${err}`, true)
}
// Each tab owns what it produced. A project picked on one tab, and whatever the host
// derived from it, must not survive into the other.
function setMode(v: 'create' | 'existing') {
if (v === intent.mode) return
intent.mode = v
onIntentChange?.()
}
function useExisting() {
if (!selectedProject || !existingPassword) return
result = { project: selectedProject, password: existingPassword }
/** Takes the picked project as a parameter: read directly off the prop inside a `$derived`,
* the checker narrows its optional type to `never`. */
function isSelected(picked: SupabaseProject | undefined, p: SupabaseProject): boolean {
return !!picked && projectRef(picked) === projectRef(p)
}
// Only the two stages this component drives; whatever the host does with the finished
// project is appended by the host as its own step.
let steps = $derived([...supabaseSetupSteps(provisioning).slice(0, 2), ...(extraSteps ?? [])])
// The password field lives inside the card it belongs to, so it is scoped to one project:
// carrying a value over to another card would show it already filled in. Selecting the last
// card in a long list also grows it past the fold, hence the scroll once it has resized.
async function selectProject(p: SupabaseProject, card: HTMLElement | null) {
if (!isSelected(intent.project, p)) intent.password = ''
intent.project = p
onIntentChange?.()
await tick()
card?.scrollIntoView({ block: 'nearest' })
}
let action = $derived.by((): SupabaseAction => {
if (!oauth.authed)
/** Built from parameters rather than read off the surrounding `$state(undefined)`, which a
* `$derived` in the same scope narrows to `never`. */
function orgOptions(
all: SupabaseOrg[] | undefined,
projs: SupabaseProject[] | undefined,
plansBySlug: Record<string, string>
) {
return (all ?? []).map((o) => {
const slug = orgSlug(o)
const count = (projs ?? []).filter((p) => projectOrg(p) === slug).length
return {
label: oauth.pending ? 'Continue' : 'Connect to Supabase',
disabled: false,
act: () => oauth.connect()
label: o.name,
value: slug,
subtitle: [plansBySlug[slug], `${count} project${count === 1 ? '' : 's'}`]
.filter(Boolean)
.join(' · ')
}
if (loading) return { label: 'Loading', disabled: true, busy: true }
if (mode === 'create') {
if (provisioning === 0)
return {
label: 'Create database',
disabled: !projectName || !selectedOrg,
act: provision
}
if (provisioning < 3) return { label: 'Setting it up', disabled: true, busy: true }
return { label: continueLabel, disabled: false, busy: hostBusy }
}
return {
label: result ? continueLabel : continueLabel,
disabled: !selectedProject || !existingPassword,
busy: hostBusy,
act: useExisting
}
})
})
}
export function getAction(): SupabaseAction {
return action
}
export function isAuthed(): boolean {
return oauth.authed
}
let orgItems = $derived(orgOptions(orgs, projects, plans))
</script>
{#if !oauth.authed}
<Alert type="info" size="xs" bgClass="border-0" title="">
{#if oauth.pending}
Sign in and approve Windmill in the Supabase window, then come back here.
{:else}
Windmill needs your approval on Supabase to see your databases.
{/if}
</Alert>
{:else if loading}
{#if loading}
<div class="flex items-center gap-2 text-xs text-secondary py-2">
<Loader2 size={16} class="animate-spin" />
Loading your Supabase projects...
</div>
{:else}
<ToggleButtonGroup bind:selected={mode}>
{#snippet children({ item })}
<ToggleButton value="existing" label="Use an existing one" {item} small />
<ToggleButton value="create" label="Create a new project" {item} small />
{/snippet}
</ToggleButtonGroup>
{#if !existingOnly}
<ToggleButtonGroup bind:selected={() => intent.mode, (v) => setMode(v)}>
{#snippet children({ item })}
<ToggleButton value="existing" label="Use an existing project" {item} small />
<ToggleButton value="create" label="Create a new project" {item} small />
{/snippet}
</ToggleButtonGroup>
{/if}
{#if mode === 'create'}
{#if provisioning === 0}
<div class="grid grid-cols-2 gap-2">
<div>
<span class="text-xs font-semibold text-emphasis">Organization</span>
<Select
items={(orgs ?? []).map((o) => ({ label: o.name, value: orgSlug(o) }))}
bind:value={selectedOrg}
placeholder={orgs === undefined ? 'Loading...' : 'Select'}
/>
</div>
<div>
<span class="text-xs font-semibold text-emphasis">Region</span>
<Select
items={SUPABASE_REGIONS.map((r) => ({ label: r, value: r }))}
bind:value={region}
placeholder="Region"
/>
</div>
</div>
{#if intent.mode === 'create'}
<div class="grid grid-cols-2 gap-2">
<div>
<span class="text-xs font-semibold text-emphasis">Project name</span>
<TextInput bind:value={projectName} inputProps={{ placeholder: defaultProjectName }} />
</div>
{:else}
<SetupChecklist {steps} />
{#if provisioning < 3}
<p class="text-xs text-secondary">
This usually takes a minute or two. You can leave this open.{provisionStatus
? ` (${provisionStatus})`
: ''}
</p>
{/if}
{/if}
{:else}
<div class="flex flex-col gap-2 overflow-y-auto flex-1 min-h-24 pr-1">
{#each projects ?? [] as p (p.id)}
{@const selected = selectedProject?.id === p.id}
<button
class="text-left border rounded-md p-3 flex gap-3 items-start transition-colors {selected
? 'border-border-selected/50 bg-surface-accent-selected'
: 'border-border-light hover:bg-surface-hover'}"
onclick={() => (selectedProject = p)}
>
<span class="mt-0.5 shrink-0"><Database size={18} class="text-secondary" /></span>
<span class="flex flex-col gap-0.5 min-w-0">
<span class="text-xs font-medium {selected ? 'text-accent' : 'text-emphasis'}"
>{p.name}</span
>
<span class="text-xs text-secondary font-normal">
{p.region}{projectStatus(p) ? ` · ${projectStatus(p)}` : ''}
</span>
</span>
</button>
{/each}
</div>
{#if selectedProject}
<div>
<span class="text-xs font-semibold text-emphasis">Database password</span>
<TextInput
bind:value={existingPassword}
inputProps={{ type: 'password', placeholder: '••••••••' }}
<span class="text-xs font-semibold text-emphasis">Organization</span>
<Select
items={orgItems}
bind:value={() => intent.org, (v) => ((intent.org = v), onIntentChange?.())}
placeholder={orgs === undefined ? 'Loading...' : 'Select'}
/>
<p class="text-2xs text-secondary mt-1">
Find it in your Supabase project settings, under Database.
The project is created here and billed to this organization.
</p>
</div>
{/if}
{/if}
{#if strandedPassword}
<Alert type="warning" size="xs" bgClass="border-0" title="Save this password">
<span class="font-mono select-all">{strandedPassword}</span>
<br />
The project exists but Windmill could not finish. Supabase never shows this password again.
<div>
<span class="text-xs font-semibold text-emphasis">Region</span>
<Select
items={SUPABASE_REGIONS.map((r) => ({ label: r.label, value: r.code }))}
bind:value={intent.region}
placeholder="Region"
/>
</div>
</div>
<div>
<span class="text-xs font-semibold text-emphasis">Project name</span>
<TextInput bind:value={intent.projectName} inputProps={{ placeholder: 'windmill-data' }} />
</div>
<Alert type="info" size="xs" bgClass="border-0" title="">
Windmill generates and stores the database password. A new project takes a minute or two to
come up.
</Alert>
<SupabaseConnectionMode bind:mode={intent.connectionMode} onChange={onIntentChange} />
{:else}
<div class="flex flex-col gap-2 overflow-y-auto flex-1 min-h-24 pr-1">
{#each projects ?? [] as p (projectRef(p))}
{@const selected = isSelected(intent.project, p)}
<!-- shrink-0 or the flex column squeezes the cards to fit instead of letting the
list scroll, and the selected one loses its password field to the clip. -->
<div
class="shrink-0 border rounded-md overflow-hidden transition-colors {selected
? 'border-border-selected/50 bg-surface-accent-selected'
: 'border-border-light'}"
>
<button
class="w-full text-left p-3 flex gap-3 items-start {selected
? ''
: 'hover:bg-surface-hover'}"
onclick={(e) => selectProject(p, e.currentTarget.parentElement)}
>
<span class="mt-0.5 shrink-0"><Database size={18} class="text-secondary" /></span>
<span class="flex flex-col gap-0.5 min-w-0">
<span class="text-xs font-medium {selected ? 'text-accent' : 'text-emphasis'}"
>{p.name}</span
>
<span class="text-xs text-secondary font-normal">
{p.region}{projectOrg(p) ? ` · ${projectOrg(p)}` : ''}{projectStatus(p)
? ` · ${projectStatus(p)}`
: ''}
</span>
</span>
</button>
{#if selected}
<div class="px-3 pb-3 flex flex-col gap-2">
<div>
<span class="text-xs font-semibold text-emphasis">Database password</span>
<Password
bind:password={
() => intent.password, (v) => ((intent.password = v ?? ''), onIntentChange?.())
}
placeholder="••••••••"
/>
<p class="text-2xs text-secondary mt-1">
Supabase only shows this when the project is created, and never exposes it through
its API. If you no longer have it, <a
href="https://supabase.com/dashboard/project/{projectRef(p)}/database/settings"
target="_blank"
rel="noreferrer"
class="text-blue-500 hover:underline">set a new one</a
> — every existing connection to this project stops working when you do.
</p>
</div>
<SupabaseConnectionMode bind:mode={intent.connectionMode} onChange={onIntentChange} />
</div>
{/if}
</div>
{/each}
{#if (projects ?? []).length === 0}
<Alert type="info" size="xs" bgClass="border-0" title="">
This Supabase account has no projects yet.
</Alert>
{/if}
</div>
{/if}
{/if}
@@ -0,0 +1,477 @@
/**
* Everything the "add a database" wizard collects, and the one function that acts on it.
*
* The wizard writes nothing until the user finishes: steps 1 and 2 gather intent, step 3
* reviews it, and `runSetup` performs it. That ordering is what lets the review step show
* the resource path before the resource exists, and what lets the data table row be
* recorded before a billable Supabase project is created -- a run that dies half way
* leaves something repairable rather than an orphan on someone's bill.
*
* `runSetup` is also the retry: every step probes for its own result first, so calling it
* again on a half-finished data table resumes instead of duplicating.
*/
import {
ResourceService,
SettingService,
VariableService,
WorkspaceService,
type DataTableOrigin,
type TestDataTableConnectionResponse
} from '$lib/gen'
import type { SetupStep } from '../wizards/SetupChecklist.svelte'
import { parsePostgresConnectionString } from '$lib/utils/postgresConnectionString'
import {
createSupabaseProject,
generateDbPassword,
getSupabasePooler,
listSupabaseProjects,
projectOrg,
projectRef,
supabaseResourceValue,
waitUntilSupabaseHealthy,
DEFAULT_SUPABASE_REGION,
type SupabaseConnectionMode,
type SupabaseProject
} from './supabaseProvisioning'
export type Provider = 'supabase' | 'instance' | 'resource'
export type WizardState = {
step: 1 | 2 | 3
provider: Provider | undefined
supabase: {
mode: 'existing' | 'create'
project: SupabaseProject | undefined
password: string
/** Organization slug, for the create mode. */
org: string | undefined
region: string
projectName: string
connectionMode: SupabaseConnectionMode
}
instance: { mode: 'existing' | 'create'; dbName: string | undefined }
own: { mode: 'pick' | 'connstr'; resourcePath: string | undefined; connectionString: string }
review: { name: string; folder: string; resourceName: string }
/** Result of validating what step 2 collected. Cleared whenever its input changes. */
probe: {
checking: boolean
report: TestDataTableConnectionResponse | undefined
error: string | undefined
}
}
export function newWizardState(defaults: {
name: string
projectName: string
folder: string
}): WizardState {
return {
step: 1,
provider: undefined,
supabase: {
mode: 'create',
project: undefined,
password: '',
org: undefined,
region: DEFAULT_SUPABASE_REGION,
projectName: defaults.projectName,
connectionMode: 'session'
},
instance: { mode: 'create', dbName: undefined },
own: { mode: 'pick', resourcePath: undefined, connectionString: '' },
review: { name: defaults.name, folder: defaults.folder, resourceName: '' },
probe: { checking: false, report: undefined, error: undefined }
}
}
export function clearProbe(state: WizardState) {
state.probe = { checking: false, report: undefined, error: undefined }
}
/** Path of the resource and secret variable the run will write. They share one. */
export function resourcePathOf(state: WizardState): string {
return `${state.review.folder}/${state.review.resourceName}`
}
/** True once the branch has everything `runSetup` needs. */
export function intentComplete(state: WizardState): boolean {
if (state.provider === 'supabase') {
return state.supabase.mode === 'create'
? !!state.supabase.projectName.trim() && !!state.supabase.org
: !!state.supabase.project && !!state.supabase.password
}
if (state.provider === 'instance') return !!state.instance.dbName?.trim()
return state.own.mode === 'pick'
? !!state.own.resourcePath
: !!parsePostgresConnectionString(state.own.connectionString)
}
/**
* The connection value a branch can be validated against before anything is saved.
* Undefined for branches with nothing to validate yet: creating a Supabase project has no
* database to reach, and an instance database does not exist until setup runs.
*/
export function probeValue(state: WizardState): Record<string, any> | undefined {
if (state.provider === 'resource' && state.own.mode === 'connstr') {
const parts = parsePostgresConnectionString(state.own.connectionString)
if (!parts) return undefined
return { ...parts, sslmode: parts.sslmode ?? 'prefer' }
}
return undefined
}
/** What a finished run will have created, for the review step to state plainly. */
export function originOf(state: WizardState, username: string): DataTableOrigin {
if (state.provider === 'supabase') {
const created = state.supabase.mode === 'create'
return {
provider: 'supabase',
project_name: created ? state.supabase.projectName.trim() : state.supabase.project?.name,
project_ref: created ? undefined : projectRef(state.supabase.project!),
org: created ? state.supabase.org : projectOrg(state.supabase.project!),
region: created ? state.supabase.region : state.supabase.project?.region,
connection_mode: state.supabase.connectionMode,
connected_by: username,
connected_at: new Date().toISOString()
}
}
return {
provider: state.provider === 'instance' ? 'instance' : 'resource',
connected_by: username,
connected_at: new Date().toISOString()
}
}
export type RunStepKey =
| 'create_project'
| 'wait_healthy'
| 'save_credentials'
| 'setup_instance'
| 'check'
/**
* The steps this branch will run, in order. The key drives the runner and the title only
* the display, so rewording a step cannot change what it does.
*/
export function plan(state: WizardState): { key: RunStepKey; title: string }[] {
const path = resourcePathOf(state)
const steps: { key: RunStepKey; title: string }[] = []
if (state.provider === 'supabase') {
if (state.supabase.mode === 'create') {
steps.push({
key: 'create_project',
title: `Creating ${state.supabase.projectName.trim()} on Supabase`
})
steps.push({ key: 'wait_healthy', title: 'Waiting for the database to start' })
}
steps.push({ key: 'save_credentials', title: `Saving credentials to ${path}` })
} else if (state.provider === 'instance') {
steps.push({
key: 'setup_instance',
title: `Setting up ${state.instance.dbName} in the Windmill database`
})
} else if (state.own.mode === 'connstr') {
steps.push({ key: 'save_credentials', title: `Saving the connection to ${path}` })
}
steps.push({ key: 'check', title: 'Checking Windmill can store data' })
return steps
}
/** The same plan as a checklist, all pending. */
export function planSteps(state: WizardState): SetupStep[] {
return plan(state).map((s) => ({ title: s.title, status: 'pending' }))
}
export type RunDeps = {
workspace: string
username: string
/** Required for the Supabase branch; a retry re-authorizes to obtain one. */
supabaseToken?: string
/** Asked before creating an instance database, which is destructive enough to confirm. */
confirmInstanceSetup: (dbName: string) => Promise<boolean>
/** So the settings page's pool reflects a database this run created. */
onInstanceDbsChanged?: () => Promise<void>
onProgress: (steps: SetupStep[]) => void
/** From `derivePlan`, when resuming a data table whose setup never finished. */
resumeFrom?: SetupStep[]
/** Called as soon as the data table row exists, so the caller can offer to leave. */
onRowCreated?: () => void
onStatus?: (status: string | undefined) => void
}
export type RunResult = {
ok: boolean
report?: TestDataTableConnectionResponse
error?: string
}
async function exists(kind: 'variable' | 'resource', workspace: string, path: string) {
return kind === 'variable'
? VariableService.existsVariable({ workspace, path })
: ResourceService.existsResource({ workspace, path })
}
/**
* Adds the data table to the workspace config, marked incomplete, unless it is already
* there. `edit_datatable_config` replaces the whole map, so the rest is read back and sent
* with it; the backend refuses to take origin or the flag from this call for a data table
* that already exists, which is what makes calling it on a retry harmless.
*/
async function ensureRow(
deps: RunDeps,
name: string,
database: { resource_type: 'postgresql' | 'instance'; resource_path: string },
origin: DataTableOrigin
): Promise<void> {
const settings = await WorkspaceService.getSettings({ workspace: deps.workspace })
const datatables: Record<string, any> = { ...(settings.datatable?.datatables ?? {}) }
if (datatables[name]) return
datatables[name] = { database, origin, setup_incomplete: true }
await WorkspaceService.editDataTableConfig({
workspace: deps.workspace,
requestBody: { settings: { datatables }, renames: [], deleted_datatables: [] }
})
deps.onRowCreated?.()
}
async function writeSecret(workspace: string, path: string, value: string, description: string) {
if (await exists('variable', workspace, path)) {
await VariableService.updateVariable({
workspace,
path,
requestBody: { value, is_secret: true }
})
return
}
await VariableService.createVariable({
workspace,
requestBody: { path, value, is_secret: true, description, is_oauth: false }
})
}
async function writeResource(
workspace: string,
path: string,
value: Record<string, any>,
description: string
) {
if (await exists('resource', workspace, path)) {
await ResourceService.updateResource({ workspace, path, requestBody: { value, description } })
return
}
await ResourceService.createResource({
workspace,
requestBody: { resource_type: 'postgresql', path, value, description }
})
}
/**
* Performs what the wizard collected, reporting each step as it goes.
*
* Every step is safe to re-run: this is both the first attempt and the retry from an
* incomplete row, and the two must not diverge or the rarely-exercised one rots.
*/
export async function runSetup(state: WizardState, deps: RunDeps): Promise<RunResult> {
const planned = plan(state)
// A retry starts from what `derivePlan` already found in place, so it does not re-do
// work whose result is still there. Anything it did not vouch for runs again, which is
// safe: every step below upserts rather than assumes absence.
const steps: SetupStep[] = planned.map((s, i) => ({
title: s.title,
status: deps.resumeFrom?.[i]?.status === 'done' ? 'done' : 'pending'
}))
let index = 0
const advance = (status: 'running' | 'done' | 'failed', description?: string) => {
steps[index] = { ...steps[index], status, description }
deps.onProgress([...steps])
}
const fail = (message: string): RunResult => {
advance('failed', message)
return { ok: false, error: message }
}
const path = resourcePathOf(state)
const name = state.review.name.trim()
const origin = originOf(state, deps.username)
const instanceName = state.instance.dbName?.trim() ?? ''
try {
await ensureRow(
deps,
name,
state.provider === 'instance'
? { resource_type: 'instance', resource_path: instanceName }
: {
resource_type: 'postgresql',
resource_path: state.own.mode === 'pick' ? (state.own.resourcePath ?? path) : path
},
origin
)
} catch (err) {
return { ok: false, error: `Could not record the data table: ${err}` }
}
let project = state.supabase.project
let resourcePath =
state.provider === 'resource' && state.own.mode === 'pick' ? state.own.resourcePath! : path
for (; index < planned.length; index++) {
if (steps[index].status === 'done') continue
advance('running')
try {
if (planned[index].key === 'create_project') {
// The password is generated here and can never be read back from Supabase, so it
// is written to the secret variable before the project that uses it exists. A run
// that dies right after creation is then still repairable; the reverse order
// would strand a billed project nobody holds the password to.
const wanted = state.supabase.projectName.trim()
const existing = (await listSupabaseProjects(deps.supabaseToken!)).find(
(p) => p.name === wanted && (!state.supabase.org || projectOrg(p) === state.supabase.org)
)
if (existing) {
if (!(await exists('variable', deps.workspace, path)))
return fail(
`A Supabase project called ${wanted} already exists, but Windmill does not hold its password and Supabase cannot return it. Reset the password in Supabase and connect it as an existing project, or delete the project and retry.`
)
project = existing
} else {
const password = generateDbPassword()
await writeSecret(
deps.workspace,
path,
password,
`Password for the ${wanted} Supabase database`
)
project = await createSupabaseProject(deps.supabaseToken!, {
name: wanted,
organizationSlug: state.supabase.org!,
region: state.supabase.region,
dbPass: password
})
}
await WorkspaceService.setDataTableSetup({
workspace: deps.workspace,
datatableName: name,
requestBody: { origin: { ...origin, project_ref: projectRef(project) } }
})
} else if (planned[index].key === 'wait_healthy') {
project = await waitUntilSupabaseHealthy(
deps.supabaseToken!,
projectRef(project!),
deps.onStatus
)
} else if (planned[index].key === 'save_credentials') {
if (state.provider === 'supabase') {
if (state.supabase.mode === 'existing')
await writeSecret(
deps.workspace,
path,
state.supabase.password,
`Password for the ${project!.name} Supabase database`
)
const pooler =
state.supabase.connectionMode === 'session'
? await getSupabasePooler(deps.supabaseToken!, projectRef(project!))
: undefined
await writeResource(
deps.workspace,
path,
supabaseResourceValue(project!, path, {
mode: state.supabase.connectionMode,
pooler
}),
`Supabase project ${project!.name}`
)
} else {
const parts = parsePostgresConnectionString(state.own.connectionString)!
await writeSecret(
deps.workspace,
path,
parts.password ?? '',
`Password for the ${parts.host} database`
)
await writeResource(
deps.workspace,
path,
{
host: parts.host,
user: parts.user,
port: parts.port ?? 5432,
dbname: parts.dbname ?? 'postgres',
sslmode: parts.sslmode ?? 'prefer',
password: `$var:${path}`,
region: '',
root_certificate_pem: '',
use_iam_auth: false
},
`Database for the ${name} data table`
)
}
} else if (planned[index].key === 'setup_instance') {
const status = await SettingService.setupCustomInstanceDb({
name: instanceName,
requestBody: { tag: 'datatable' }
})
await deps.onInstanceDbsChanged?.()
if (!status.success) return fail(status.error ?? 'Setup failed')
} else {
const report =
state.provider === 'instance'
? await WorkspaceService.testDataTableConnection({
workspace: deps.workspace,
datatableName: name
})
: await WorkspaceService.testDataTableResourceConnection({
workspace: deps.workspace,
resourcePath
})
if (!report.can_create_table) {
advance('failed', 'The database is reachable but its user cannot create tables.')
return { ok: false, report }
}
advance('done')
await WorkspaceService.setDataTableSetup({
workspace: deps.workspace,
datatableName: name,
requestBody: { setup_incomplete: false }
})
return { ok: true, report }
}
advance('done')
} catch (err: any) {
return fail(err?.body ?? err?.message ?? String(err))
}
}
return { ok: true }
}
/**
* Whether a data table that never finished still needs each step, so a retry picks up
* where it stopped. Derived rather than stored: a stored position is wrong the moment
* someone repairs something by hand, and this is cheap to ask.
*/
export async function derivePlan(
state: WizardState,
deps: { workspace: string; supabaseToken?: string }
): Promise<SetupStep[]> {
const path = resourcePathOf(state)
const steps: SetupStep[] = []
for (const step of plan(state)) {
let done = false
if (step.key === 'create_project' && deps.supabaseToken) {
const wanted = state.supabase.projectName.trim()
const list = await listSupabaseProjects(deps.supabaseToken)
done = list.some(
(p) => p.name === wanted && (!state.supabase.org || projectOrg(p) === state.supabase.org)
)
} else if (step.key === 'save_credentials') {
done = await exists('resource', deps.workspace, path)
}
// `wait_healthy`, `setup_instance` and `check` are cheap to repeat and their result
// is exactly what a retry wants to re-establish, so they are never assumed done.
steps.push({ title: step.title, status: done ? 'done' : 'pending' })
}
return steps
}
@@ -0,0 +1,44 @@
import { WorkspaceService, type DataTableHealth } from '$lib/gen'
/**
* Connection health for every data table in the workspace.
*
* One request rather than one per row: the backend probes them concurrently and
* caps each, so an unreachable database costs the page a bounded wait instead of
* however long its driver takes to give up.
*/
export function useDataTableHealth(workspace: () => string | undefined) {
let health = $state<Record<string, DataTableHealth> | undefined>(undefined)
let loading = $state(false)
async function load() {
const ws = workspace()
if (!ws) return
loading = true
try {
health = await WorkspaceService.dataTableHealth({ workspace: ws })
} catch {
// A failed probe run is not a failed page: the rows still render, they
// just have nothing to say about health.
health = undefined
} finally {
loading = false
}
}
$effect(() => {
workspace()
load()
})
return {
get loading() {
return loading
},
get current() {
return health
},
/** After the wizard finishes, or a rename, or a repoint. */
refetch: load
}
}
@@ -0,0 +1,60 @@
/**
* Reading a data table's provenance for display.
*
* `origin` is only recorded by the setup wizard, so anything created before it --
* or by editing the config directly -- has none. Those fall back to what the
* config alone can say, and the panel, which loads the resource itself, can
* recognise a Supabase host from its value.
*/
import type { DataTableOrigin } from '$lib/gen'
export type DataTableProvider = 'supabase' | 'instance' | 'resource'
type DatabaseConfig = { resource_type: 'postgresql' | 'instance'; resource_path?: string }
export function dataTableProvider(
database: DatabaseConfig,
origin: DataTableOrigin | undefined
): DataTableProvider {
if (origin?.provider === 'supabase') return 'supabase'
return database.resource_type === 'instance' ? 'instance' : 'resource'
}
/** What the row prints under "Database". */
export function dataTableSubtitle(
database: DatabaseConfig,
origin: DataTableOrigin | undefined
): string {
if (origin?.provider === 'supabase' && origin.project_name)
return `Supabase · ${origin.project_name}`
if (database.resource_type === 'instance')
return `Windmill database · ${database.resource_path ?? ''}`
return database.resource_path ?? ''
}
/**
* Both host shapes a Supabase database answers on: the direct one, and any
* Supavisor pooler. Used to label a resource that predates `origin`, and to warn
* that a plain postgres resource is in fact a Supabase project.
*/
export function isSupabaseHost(host: string | undefined): boolean {
if (!host) return false
return /\.supabase\.co$/.test(host) || /\.pooler\.supabase\.com$/.test(host)
}
/** The project ref, when the host spells it out. */
export function supabaseRefFromHost(host: string | undefined): string | undefined {
if (!host) return undefined
const direct = host.match(/^db\.([a-z0-9]+)\.supabase\.co$/)
if (direct) return direct[1]
return undefined
}
/** Two data tables on one database share `_wm_migrations`, so identity is host + dbname. */
export function databaseIdentity(value: any): string | undefined {
const host = value?.host
const dbname = value?.dbname
if (!host) return undefined
return `${String(host).toLowerCase()}/${dbname ?? ''}`
}
@@ -6,43 +6,50 @@
* access token.
*/
import type { SetupStep } from '../wizards/SetupChecklist.svelte'
export type SupabaseOrg = { id: string; name: string; slug?: string }
export type SupabaseOrg = { id: string; slug?: string; name: string }
export type SupabaseProject = {
id: string
/** `id` is Supabase's deprecated spelling of `ref`; both are sent today. */
id?: string
ref?: string
name: string
region: string
status?: string
organization_slug?: string
organization_id?: string
database?: { host: string }
}
/**
* The provisioning stages, as a checklist. Each entry is driven only by its own index, so a
* host that stops at the Supabase side can take the first two and leave the rest.
* `stage` is 0 idle, 1 creating, 2 starting, 3 checking, 4 ready.
*/
export function supabaseSetupSteps(stage: number, failed = false): SetupStep[] {
const titles = ['Created on Supabase', 'Starting it up', 'Checking Windmill can store data']
return titles.map((title, i) => {
const done = stage >= i + 2
const running = stage === i + 1
if (done) return { title, status: 'done' }
if (running) return { title, status: failed ? 'failed' : 'running' }
return { title, status: 'pending' }
})
/** One Supavisor endpoint of a project. A project has one per mode and replica. */
export type SupabasePooler = {
database_type: 'PRIMARY' | 'READ_REPLICA'
pool_mode: 'transaction' | 'session'
db_user: string
db_host: string
db_port: number
db_name: string
}
/** Region codes accepted by region_selection. */
export const SUPABASE_REGIONS = [
'us-east-1',
'us-west-1',
'eu-central-1',
'eu-west-1',
'eu-west-3',
'ap-southeast-1',
'ap-northeast-1'
export type SupabaseConnectionMode = 'session' | 'direct'
/** Supabase deprecated `id` in favour of `ref`, and still sends both. */
export function projectRef(project: SupabaseProject): string {
return project.ref ?? project.id ?? ''
}
export function projectOrg(project: SupabaseProject): string | undefined {
return project.organization_slug ?? project.organization_id
}
/** Region codes accepted by region_selection, with the names Supabase shows for them. */
export const SUPABASE_REGIONS: { code: string; label: string }[] = [
{ code: 'us-east-1', label: 'East US (N. Virginia)' },
{ code: 'us-west-1', label: 'West US (N. California)' },
{ code: 'eu-central-1', label: 'Central EU (Frankfurt)' },
{ code: 'eu-west-1', label: 'West EU (Ireland)' },
{ code: 'eu-west-3', label: 'West EU (Paris)' },
{ code: 'ap-southeast-1', label: 'Southeast Asia (Singapore)' },
{ code: 'ap-northeast-1', label: 'Northeast Asia (Tokyo)' }
]
export const DEFAULT_SUPABASE_REGION = 'eu-central-1'
@@ -54,11 +61,25 @@ function headers(token: string): HeadersInit {
async function unwrap(res: Response, what: string): Promise<any> {
if (!res.ok) {
const body = await res.text()
throw new Error(`${what}: ${body || res.statusText}`)
throw new Error(`${what}: ${supabaseErrorMessage(body) || res.statusText}`)
}
return res.json()
}
/**
* Supabase answers with `{ message }` or `{ error }` and occasionally plain text.
* Surfacing the raw body puts a JSON blob in front of the user, so unwrap it to
* the sentence inside.
*/
export function supabaseErrorMessage(body: string): string {
try {
const parsed = JSON.parse(body)
return parsed?.message ?? parsed?.error ?? parsed?.msg ?? body
} catch {
return body
}
}
export async function listSupabaseOrgs(token: string): Promise<SupabaseOrg[]> {
const res = await fetch('/api/oauth/list_supabase_orgs', { headers: headers(token) })
return unwrap(res, 'Could not list your Supabase organizations')
@@ -69,6 +90,17 @@ export async function listSupabaseProjects(token: string): Promise<SupabaseProje
return unwrap(res, 'Could not list your Supabase projects')
}
/** Plan of one organization, which the list endpoint does not carry. */
export async function getSupabaseOrgPlan(token: string, slug: string): Promise<string | undefined> {
try {
const res = await fetch(`/api/oauth/get_supabase_org/${slug}`, { headers: headers(token) })
if (!res.ok) return undefined
return (await res.json())?.plan
} catch {
return undefined
}
}
/** organization_slug is what create takes; older payloads only carry an id. */
export function orgSlug(org: SupabaseOrg): string {
return org.slug ?? org.id
@@ -124,7 +156,7 @@ export async function waitUntilSupabaseHealthy(
} catch {
continue
}
const project = list?.find?.((p) => p.id === projectId)
const project = list?.find?.((p) => projectRef(p) === projectId)
if (project?.status === 'ACTIVE_HEALTHY') return project
onStatus?.(project?.status)
}
@@ -132,17 +164,37 @@ export async function waitUntilSupabaseHealthy(
}
/**
* https://github.com/orgs/supabase/discussions/17817
* host is `aws-0-${region}.pooler.supabase.com`, user is `postgres.${id}`. The direct host is
* IPv6-only on free projects, which is why this targets the pooler rather than
* `database.host` from the API.
* The session-mode Supavisor endpoint of the project's primary database.
*
* Which pooler a project sits behind is assigned by Supabase, not derived from its
* region: constructing `aws-0-<region>.pooler.supabase.com` is wrong for every project
* that landed on another one, and the resulting resource never connects.
*/
export function supabaseResourceValue(project: SupabaseProject, passwordVarPath: string) {
export async function getSupabasePooler(token: string, projectId: string): Promise<SupabasePooler> {
const res = await fetch(`/api/oauth/get_supabase_pooler/${projectId}`, {
headers: headers(token)
})
const configs: SupabasePooler[] = await unwrap(res, 'Could not read the connection details')
const primary = configs.filter((c) => c.database_type === 'PRIMARY')
const pooler = primary.find((c) => c.pool_mode === 'session') ?? primary[0] ?? configs[0]
if (!pooler) throw new Error('Supabase returned no connection details for this project')
return pooler
}
/** The resource value for a project, given the endpoint it should connect through. */
export function supabaseResourceValue(
project: SupabaseProject,
passwordVarPath: string,
connection: { mode: SupabaseConnectionMode; pooler?: SupabasePooler }
) {
const direct = connection.mode === 'direct' || !connection.pooler
return {
host: `aws-0-${project.region}.pooler.supabase.com`,
user: `postgres.${project.id}`,
port: 5432,
dbname: 'postgres',
host: direct
? (project.database?.host ?? `db.${projectRef(project)}.supabase.co`)
: connection.pooler!.db_host,
user: direct ? 'postgres' : connection.pooler!.db_user,
port: direct ? 5432 : connection.pooler!.db_port,
dbname: direct ? 'postgres' : connection.pooler!.db_name,
sslmode: 'prefer',
password: `$var:${passwordVarPath}`,
// Resource forms fill in every unset property from the schema as soon as they render,
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import { parsePostgresConnectionString } from './postgresConnectionString'
// Two callers depend on this producing the same resource value from the same string:
// the resource form's "From connection string", and the data table wizard.
describe('parsePostgresConnectionString', () => {
it('reads every part of a full URI', () => {
expect(
parsePostgresConnectionString('postgres://u:p@db.example.com:6543/mydb?sslmode=require')
).toEqual({
user: 'u',
password: 'p',
host: 'db.example.com',
port: 6543,
dbname: 'mydb',
sslmode: 'require'
})
})
it('leaves optional parts undefined rather than empty', () => {
expect(parsePostgresConnectionString('postgresql://u@host/')).toEqual({
user: 'u',
password: undefined,
host: 'host',
port: undefined,
dbname: undefined,
sslmode: undefined
})
})
it('returns undefined for anything that is not a postgres URI', () => {
expect(parsePostgresConnectionString('mysql://u:p@host/db')).toBeUndefined()
expect(parsePostgresConnectionString('')).toBeUndefined()
})
})
@@ -0,0 +1,36 @@
/**
* Parsing for `postgres://user:password@host:5432/dbname?sslmode=require`.
*
* Shared by the resource form and the data table wizard: both turn a pasted
* connection string into a `postgresql` resource value, and the two drifting
* apart would mean the same string produced two different resources.
*/
const CONNECTION_STRING =
/postgres(?:ql)?:\/\/(?<user>[^:@]+)(?::(?<password>[^@]+))?@(?<host>[^:\/?]+)(?::(?<port>\d+))?\/(?<dbname>[^\?]+)?(?:\?.*sslmode=(?<sslmode>[^&]+))?/
export type PostgresConnectionParts = {
user: string
password?: string
host: string
port?: number
dbname?: string
sslmode?: string
}
/** Undefined when the string is not a postgres URI. */
export function parsePostgresConnectionString(
connectionString: string
): PostgresConnectionParts | undefined {
const match = connectionString.match(CONNECTION_STRING)
if (!match?.groups) return undefined
const { user, password, host, port, dbname, sslmode } = match.groups
return {
user,
password: password || undefined,
host,
port: port ? Number(port) : undefined,
dbname: dbname || undefined,
sslmode: sslmode || undefined
}
}
@@ -27,7 +27,6 @@
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
import SupabaseConnect from '$lib/components/SupabaseConnect.svelte'
import Cell from '$lib/components/table/Cell.svelte'
import DataTable from '$lib/components/table/DataTable.svelte'
import Head from '$lib/components/table/Head.svelte'
@@ -120,7 +119,6 @@
let resourceEditor: ResourceEditorDrawer | undefined = $state(undefined)
let shareModal: ShareModal | undefined = $state(undefined)
let appConnect: AppConnect | undefined = $state(undefined)
let supabaseConnect: SupabaseConnect | undefined = $state(undefined)
let deleteConfirmedCallback: (() => void) | undefined = $state(undefined)
let deleteIsLinked = $state(false)
let deletePath = $state('')
@@ -395,11 +393,6 @@
}
onMount(() => {
const callback = page.url.searchParams.get('callback')
if (callback == 'supabase_wizard') {
supabaseConnect?.open?.()
}
const connect_app = page.url.searchParams.get('connect_app')
if (connect_app) {
const rt = connect_app ?? undefined
@@ -1370,7 +1363,6 @@
</CenteredPage>
{/if}
<SupabaseConnect bind:this={supabaseConnect} on:refresh={loadResources} />
<AppConnect bind:this={appConnect} on:refresh={loadResources} />
<ResourceEditorDrawer bind:this={resourceEditor} on:refresh={loadResources} />
@@ -1,132 +0,0 @@
<script lang="ts">
import SetupChecklist, { type SetupStep } from '$lib/components/wizards/SetupChecklist.svelte'
import { instanceSetupSteps } from '$lib/components/workspaceSettings/instanceDbSteps'
import { supabaseSetupSteps } from '$lib/components/workspaceSettings/supabaseProvisioning'
import { Button } from '$lib/components/common'
import Toggle from '$lib/components/Toggle.svelte'
import DarkModeToggle from '$lib/components/sidebar/DarkModeToggle.svelte'
import type { CustomInstanceDb, LoggedWizardStatus } from '$lib/gen'
// Playground for the wizard setup checklist: the REAL SetupChecklist driven by fake
// progress, so the run-through animation and every failure position can be seen without
// a backend, a superadmin, or a Supabase account.
let stepMs = $state(700)
let failAt = $state(0) // 0 = never fail, otherwise the 1-based step that fails
// --- Supabase: stage 0 idle, 1 created, 2 starting, 3 checking, 4 ready ---
let supaStage = $state(0)
let supaRunning = $state(false)
async function runSupabase() {
if (supaRunning) return
supaRunning = true
supaStage = 0
for (let s = 1; s <= 4; s++) {
supaStage = s
await sleep(stepMs)
if (failAt === s) break
}
supaRunning = false
}
let supaFailed = $derived(failAt > 0 && failAt === supaStage && !supaRunning && supaStage < 4)
// --- Instance: the backend reports every check at once, so the fake mirrors that ---
const INSTANCE_LOG_KEYS = [
'super_admin',
'database_credentials',
'valid_dbname',
'created_database',
'db_connect',
'grant_permissions',
'replication_user'
] as const
let instanceRunning = $state(false)
let instanceStatus: CustomInstanceDb | undefined = $state(undefined)
async function runInstance() {
if (instanceRunning) return
instanceRunning = true
instanceStatus = undefined
// One call, one answer: the spinner sits on the first unreported step for the whole
// duration, exactly as it does against the real endpoint.
await sleep(stepMs * 3)
instanceStatus = buildStatus()
instanceRunning = false
}
function buildStatus(): CustomInstanceDb {
const logs: Record<string, LoggedWizardStatus> = {}
for (let i = 0; i < INSTANCE_LOG_KEYS.length; i++) {
if (failAt > 0 && i + 1 >= failAt) break
logs[INSTANCE_LOG_KEYS[i]] = 'OK'
}
return {
success: failAt === 0,
error: failAt === 0 ? undefined : `Simulated failure at step ${failAt}`,
logs
} as unknown as CustomInstanceDb
}
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms))
}
// --- A hand-rolled list, to see every status side by side ---
const allStates: SetupStep[] = [
{ title: 'Pending', status: 'pending', description: 'Not reached yet.' },
{ title: 'Running', status: 'running' },
{ title: 'Done', status: 'done' },
{
title: 'Failed',
status: 'failed',
description: 'Failures expand on their own so the reason is never hidden behind a click.'
},
{ title: 'Skipped', status: 'skipped', description: 'Nothing to do for this one.' }
]
</script>
<div class="p-6 flex flex-col gap-6 max-w-3xl mx-auto">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">Setup checklist</h1>
<DarkModeToggle />
</div>
<div class="flex flex-wrap items-end gap-4 p-4 rounded-md bg-surface-secondary">
<label class="flex flex-col gap-1 text-xs">
<span class="font-semibold text-emphasis">Step duration (ms)</span>
<input type="number" bind:value={stepMs} min="100" step="100" class="w-32" />
</label>
<label class="flex flex-col gap-1 text-xs">
<span class="font-semibold text-emphasis">Fail at step (0 = never)</span>
<input type="number" bind:value={failAt} min="0" max="7" class="w-32" />
</label>
<Toggle bind:checked={supaRunning} disabled options={{ right: 'Supabase running' }} />
<Toggle bind:checked={instanceRunning} disabled options={{ right: 'Instance running' }} />
</div>
<section class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold text-emphasis">Supabase provisioning</h2>
<Button size="xs" variant="accent" onClick={runSupabase} disabled={supaRunning}>Run</Button>
</div>
<SetupChecklist steps={supabaseSetupSteps(supaStage, supaFailed)} />
</section>
<section class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold text-emphasis">Instance database setup</h2>
<Button size="xs" variant="accent" onClick={runInstance} disabled={instanceRunning}>
Run
</Button>
</div>
<SetupChecklist steps={instanceSetupSteps('dt_playground', instanceStatus, instanceRunning)} />
</section>
<section class="flex flex-col gap-2">
<h2 class="text-sm font-semibold text-emphasis">Every status</h2>
<SetupChecklist steps={allStates} />
</section>
</div>