mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-14 08:02:58 +00:00
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
258 lines
7.5 KiB
TypeScript
258 lines
7.5 KiB
TypeScript
import { z } from 'zod'
|
||
import { useSearchParams } from '$lib/svelte5UtilsKit.svelte'
|
||
import type { DbInput, DbType } from './dbTypes'
|
||
import { isDbType } from './dbTypes'
|
||
|
||
/**
|
||
* Single URL param `dbm` encodes the full DB manager state:
|
||
* firstSegment~path~schema.table~role=name
|
||
*
|
||
* firstSegment:
|
||
* datatable – database with datatable:// resource (resourceType always postgresql)
|
||
* ducklake – ducklake connection
|
||
* postgresql, mysql, … – regular database (the segment IS the resource type)
|
||
*
|
||
* schema.table (third segment, optional):
|
||
* schema.table – both
|
||
* .table – table only
|
||
* schema. – schema only
|
||
* (omitted) – neither
|
||
*
|
||
* Default schemas (omitted from URL, restored on parse):
|
||
* datatable → public
|
||
* ducklake → main
|
||
*
|
||
* Examples:
|
||
* datatable~main~.customers (schema "public" implied)
|
||
* ducklake~main~.orders (schema "main" implied)
|
||
* postgresql~$res:u/user/my_pg~public.customers
|
||
* datatable~main~.customers~role=analyst
|
||
* datatable~main~role=analyst (no schema/table selected)
|
||
*
|
||
* role=name (last segment, optional, data tables only): the data table role to connect as.
|
||
* Omitted means the data table's default role. A trailing segment starting with `role=` is always
|
||
* the role, whatever follows. The name is kept as written, even when invalid (a `.` included), so
|
||
* the connection refuses it visibly instead of falling back to the default.
|
||
*/
|
||
|
||
const dbManagerSchema = z.object({
|
||
dbm: z.string().nullable()
|
||
})
|
||
|
||
export interface ParsedDbm {
|
||
type: 'database' | 'datatable' | 'ducklake'
|
||
path: string
|
||
resType?: string
|
||
schema?: string
|
||
table?: string
|
||
role?: string
|
||
}
|
||
|
||
const ROLE_SEGMENT_PREFIX = 'role='
|
||
|
||
function isRoleSegment(segment: string | undefined): segment is string {
|
||
return !!segment && segment.startsWith(ROLE_SEGMENT_PREFIX)
|
||
}
|
||
|
||
export function parseDbm(raw: unknown): ParsedDbm | null {
|
||
if (!raw || typeof raw !== 'string') return null
|
||
const parts = raw.split('~')
|
||
if (parts.length < 2 || !parts[1]) return null
|
||
|
||
const firstSeg = parts[0]
|
||
const path = parts[1]
|
||
const rest = parts.slice(2)
|
||
const role = isRoleSegment(rest.at(-1))
|
||
? rest.pop()!.slice(ROLE_SEGMENT_PREFIX.length)
|
||
: undefined
|
||
const schemaTable = rest[0] ?? ''
|
||
|
||
let type: ParsedDbm['type']
|
||
let resType: string | undefined
|
||
if (firstSeg === 'datatable') {
|
||
type = 'datatable'
|
||
} else if (firstSeg === 'ducklake') {
|
||
type = 'ducklake'
|
||
} else if (isDbType(firstSeg)) {
|
||
type = 'database'
|
||
resType = firstSeg
|
||
} else {
|
||
return null
|
||
}
|
||
|
||
let schema: string | undefined
|
||
let table: string | undefined
|
||
if (schemaTable) {
|
||
const dotIdx = schemaTable.indexOf('.')
|
||
if (dotIdx === 0) {
|
||
table = schemaTable.slice(1) || undefined
|
||
} else if (dotIdx === schemaTable.length - 1) {
|
||
schema = schemaTable.slice(0, -1) || undefined
|
||
} else if (dotIdx > 0) {
|
||
schema = schemaTable.slice(0, dotIdx)
|
||
table = schemaTable.slice(dotIdx + 1)
|
||
}
|
||
}
|
||
|
||
// Restore default schema when omitted for datatable/ducklake
|
||
if (!schema && table && type in defaultSchemas) {
|
||
schema = defaultSchemas[type]
|
||
}
|
||
|
||
return { type, path, resType, schema, table, role: type === 'datatable' ? role : undefined }
|
||
}
|
||
|
||
const defaultSchemas: Record<string, string> = { datatable: 'public', ducklake: 'main' }
|
||
|
||
export function buildDbm(p: ParsedDbm): string {
|
||
const firstSeg = p.type === 'database' ? p.resType! : p.type
|
||
const schema = p.schema === defaultSchemas[p.type] ? undefined : p.schema
|
||
let schemaTable = ''
|
||
if (schema && p.table) {
|
||
schemaTable = `${schema}.${p.table}`
|
||
} else if (p.table) {
|
||
schemaTable = `.${p.table}`
|
||
} else if (schema) {
|
||
schemaTable = `${schema}.`
|
||
}
|
||
const segments = [firstSeg, p.path]
|
||
if (schemaTable) segments.push(schemaTable)
|
||
if (p.type === 'datatable' && p.role !== undefined) {
|
||
segments.push(`${ROLE_SEGMENT_PREFIX}${p.role}`)
|
||
}
|
||
return segments.join('~')
|
||
}
|
||
|
||
export interface DbManagerUriState {
|
||
readonly input: DbInput | undefined
|
||
readonly effectiveInput: DbInput | undefined
|
||
readonly isDatatableInput: boolean
|
||
selectedDatatable: string | undefined
|
||
/** The data table role the drawer connects as; undefined means its default. */
|
||
selectedRole: string | undefined
|
||
selectedSchema: string | undefined
|
||
selectedTable: string | undefined
|
||
readonly open: boolean
|
||
/** Workspace the drawer's DB operations run against — the acting workspace of
|
||
* the editor that opened it, else the navigation workspace. */
|
||
workspace: string | undefined
|
||
openDrawer: (nInput: DbInput, workspace?: string) => void
|
||
closeDrawer: () => void
|
||
}
|
||
|
||
export function useDbManagerUriState(): DbManagerUriState {
|
||
const params = useSearchParams(dbManagerSchema)
|
||
|
||
const parsed = $derived(parseDbm(params.dbm))
|
||
|
||
let input: DbInput | undefined = $derived.by(() => {
|
||
if (!parsed) return undefined
|
||
if (parsed.type === 'ducklake') {
|
||
return {
|
||
type: 'ducklake' as const,
|
||
ducklake: parsed.path,
|
||
specificSchema: parsed.schema,
|
||
specificTable: parsed.table
|
||
}
|
||
}
|
||
// datatable or database
|
||
const resType = parsed.type === 'datatable' ? 'postgresql' : parsed.resType
|
||
if (!isDbType(resType ?? undefined)) return undefined
|
||
return {
|
||
type: 'database' as const,
|
||
resourceType: resType as DbType,
|
||
resourcePath: parsed.type === 'datatable' ? `datatable://${parsed.path}` : parsed.path,
|
||
role: parsed.role,
|
||
specificSchema: parsed.schema,
|
||
specificTable: parsed.table
|
||
}
|
||
})
|
||
|
||
const isDatatableInput = $derived(parsed?.type === 'datatable')
|
||
|
||
function updateField(updates: Partial<ParsedDbm>) {
|
||
const p = parseDbm(params.dbm)
|
||
if (!p) return
|
||
Object.assign(p, updates)
|
||
params.dbm = buildDbm(p)
|
||
}
|
||
|
||
// Not URL-persisted: the drawer defaults back to the nav workspace on reload,
|
||
// which is the correct fallback outside the session that opened it.
|
||
let workspace = $state<string | undefined>(undefined)
|
||
|
||
function openDrawer(nInput: DbInput, ws?: string) {
|
||
workspace = ws
|
||
if (nInput.type === 'database') {
|
||
const isDatatable = nInput.resourcePath.startsWith('datatable://')
|
||
params.dbm = buildDbm({
|
||
type: isDatatable ? 'datatable' : 'database',
|
||
path: isDatatable ? nInput.resourcePath.slice('datatable://'.length) : nInput.resourcePath,
|
||
resType: isDatatable ? undefined : nInput.resourceType,
|
||
role: isDatatable ? nInput.role : undefined,
|
||
schema: nInput.specificSchema,
|
||
table: nInput.specificTable
|
||
})
|
||
} else {
|
||
params.dbm = buildDbm({
|
||
type: 'ducklake',
|
||
path: nInput.ducklake,
|
||
schema: nInput.specificSchema,
|
||
table: nInput.specificTable
|
||
})
|
||
}
|
||
}
|
||
|
||
function closeDrawer() {
|
||
params.dbm = null
|
||
}
|
||
|
||
return {
|
||
get input() {
|
||
return input
|
||
},
|
||
get effectiveInput() {
|
||
return input
|
||
},
|
||
get isDatatableInput() {
|
||
return isDatatableInput
|
||
},
|
||
get selectedDatatable() {
|
||
return parsed?.type === 'datatable' ? parsed.path : undefined
|
||
},
|
||
set selectedDatatable(v: string | undefined) {
|
||
// A role belongs to one data table, so it cannot carry over to another.
|
||
if (v) updateField({ path: v, role: undefined })
|
||
},
|
||
get selectedRole() {
|
||
return parsed?.role
|
||
},
|
||
set selectedRole(v: string | undefined) {
|
||
updateField({ role: v })
|
||
},
|
||
get selectedSchema() {
|
||
return parsed?.schema
|
||
},
|
||
set selectedSchema(v: string | undefined) {
|
||
updateField({ schema: v })
|
||
},
|
||
get selectedTable() {
|
||
return parsed?.table
|
||
},
|
||
set selectedTable(v: string | undefined) {
|
||
updateField({ table: v })
|
||
},
|
||
get open() {
|
||
return !!input
|
||
},
|
||
get workspace() {
|
||
return workspace
|
||
},
|
||
set workspace(v: string | undefined) {
|
||
workspace = v
|
||
},
|
||
openDrawer,
|
||
closeDrawer
|
||
}
|
||
}
|