Files
windmill/frontend/src/lib/components/DBManager.svelte
T
a298ed3fc1 feat: add the viewer's roles to unnamed data table connections (#11709)
* feat: connect to a data table with every role of the job's viewer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: key a cached result by the run's recorded viewer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: pin the per-viewer cache key and fix a stale doc sentence

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: length-prefix the viewer in the result cache key

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: drop data table role set logins unused for 30 days

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: pin the ee ref with the set login sweep fixes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: pin the ee ref with the sweep retry fix

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: give the cache key test its own migrated database

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: add the viewer's roles to a data table connection that names none

The default role of a data table under roles is now a floor: a connection
naming no role also holds the roles its viewer is a tenant of, unless the
default is admin. This replaces the explicit as=viewer option, which is
removed from the reference parser, the executors and both clients. The roles
drawer warns while the default is admin and confirms before removing the
default role.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: let a data table have no default role

A connection naming no role then holds its viewer's roles only and is
refused without any. The roles drawer offers the choice, states what an
unnamed connection gets for the current settings, says who gets admin while
it is the default, and notes who owns a table created over several roles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: start a data table's roles with admin open to everyone

Turning roles on from the drawer seeded admin with no tenant, which refused
every non-admin member's job as soon as it was saved. It now starts open to
everyone, so the toggle changes nothing until the roles are edited, and the
drawer warns for as long as everyone can connect as admin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: update ee-repo-ref to 40ab35596e6fcb82a4dfc56e186befb807f8e2af

This commit updates the EE repository reference after PR #873 was merged in windmill-ee-private.

Previous ee-repo-ref: a5def184a29fffab8ed42c70c5a7445b2b3cbe98

New ee-repo-ref: 40ab35596e6fcb82a4dfc56e186befb807f8e2af

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-10-13 09:01:21 +00:00

1634 lines
61 KiB
Svelte

<script lang="ts">
import { untrack, type Snippet } from 'svelte'
import { isCloudHosted } from '$lib/cloud'
import { enterpriseLicense, superadmin, type DBSchema } from '$lib/stores'
import {
ChevronDownIcon,
EditIcon,
Loader2,
Plus,
Table2,
Database as DatabaseIcon,
Folder as FolderIcon,
History as HistoryIcon,
KeyRound as KeyRoundIcon,
Download as DownloadIcon,
Trash2Icon,
UploadIcon
} from 'lucide-svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { ClearableInput, Drawer, DrawerContent } from './common'
import { sendUserToast } from '$lib/toast'
import {
ColumnIdentity,
renderDbEqualityFilter,
type ColumnDef
} from './apps/components/display/dbtable/utils'
import DBTable, { type DbForeignKeyTarget, type DbRowFilter } from './DBTable.svelte'
import type { IDbSchemaOps, IDbTableOps } from './dbOps'
import DropdownV2 from './DropdownV2.svelte'
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
import Button from './common/button/Button.svelte'
import DbTableEditor from './DBTableEditor.svelte'
import type { DbType } from './dbTypes'
import Portal from './Portal.svelte'
import {
dbSupportsTransactionalDdl,
diffTableEditorValues
} from './apps/components/display/dbtable/queries/alterTable'
import { resource } from 'runed'
import { capitalize, onlyAlphaNumAndUnderscore, pluralize } from '$lib/utils'
import type { DbFeatures } from './apps/components/display/dbtable/dbFeatures'
import Star from './Star.svelte'
import ResizeTransitionWrapper from './common/ResizeTransitionWrapper.svelte'
import PgAclEditor from './datatableAcl/PgAclEditor.svelte'
import { favoriteManager } from './sidebar/FavoriteMenu.svelte'
import DatatableRoleBadge from './DatatableRoleBadge.svelte'
import type { AclTarget, Asset, DataTableTables } from '$lib/gen'
import {
ADMIN_DATATABLE_ROLE,
datatableNameTakesRole,
namedDefaultRole,
type DatatableRowAction
} from './dbTypes'
import TextInput from './text_input/TextInput.svelte'
import Checkbox from './common/checkbox/Checkbox.svelte'
import DbSchemaDiagram from './dbdiagram/DbSchemaDiagram.svelte'
import type { DbRelation } from './dbRelations'
import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte'
import type { DbManagerTabs } from './dbManagerTabs.svelte'
import type { TableEditorForeignKey } from './apps/components/display/dbtable/tableEditor'
import type { DbJoinTarget } from './dbTableJoins'
import { SvelteMap, SvelteSet } from 'svelte/reactivity'
const operatingUser = useOperatingUser()
const actingUser = $derived(operatingUser.current)
/** Represents a selected table with its schema */
export interface SelectedTable {
/** Absent when the tree has no data table level (a plain database). */
datatable?: string
schema: string
table: string
}
/** An action asked for on one data table's row, waiting for the manager to be
* connected to that data table. `datatable` is what it was asked for: a switch
* that lands anywhere else — because the target failed to load, or because the
* user picked another one meanwhile — must not run it there. */
export type RowAction =
| { kind: 'create-table'; schema: string }
| { kind: 'create-schema' }
| { kind: 'alter-table'; schema: string; table: string }
| { kind: 'delete-table'; schema: string; table: string }
| { kind: 'drop-schema'; schema: string }
| { kind: 'rename-schema'; schema: string }
export type PendingRowAction = RowAction & { datatable: string }
/** The right pane's content: the rows of one table, or the schema diagram. */
export type DbManagerViewMode = 'data' | 'diagram'
type Props = {
/** Which of the two views the right pane shows. Owned by whoever renders the
* control that switches it; ignored for a database with no diagram. */
requestedViewMode?: DbManagerViewMode
/** Asked for from inside, e.g. opening a table from the diagram. */
onViewMode?: (mode: DbManagerViewMode) => void
/** Identifies the database this is connected to, stable across reloads of
* it. A string rather than one of the objects read from it: those are
* replaced as the manager re-reads, which says nothing about the database
* having changed. */
databaseKey?: string
dbType: DbType
dbSchema: DBSchema
dbSupportsSchemas: boolean
databaseIsEmpty?: boolean
colDefs: Record<string, ColumnDef[]> | undefined
dbTableOpsFactory: (params: { colDefs: ColumnDef[]; tableKey: string }) => IDbTableOps
dbSchemaOps: IDbSchemaOps
refresh?: () => void
initialSchemaKey?: string
initialTableKey?: string
selectedSchemaKey?: string | undefined
selectedTableKey?: string | undefined
/** Every data table with its schemas and tables. Present only when the manager
* is on a data table — that is what puts a data-table level at the top of the
* tree; otherwise the tree starts at schemas. */
datatableTree?: DataTableTables[]
datatableTreeLoading?: boolean
onSelectDatatable?: (datatable: string) => void
/** Role the manager is connected as; undefined means the data table's default. */
currentRole?: string
/** Switch a data table's role from its row badge. */
onSelectRole?: (datatable: string, role: string) => void
pendingAction?: PendingRowAction | undefined
/** Row-menu actions on a data table, run against that row's data table. */
onDatatableAction?: (datatable: string, action: DatatableRowAction) => void
/** Workspace the access drawer's own calls run against. */
workspace?: string
/** Whether the caller administers data tables here, which is who may edit their roles.
* The edition and hosting the feature also needs are checked here, to say which is missing. */
canManageDatatable?: boolean
/** Enable multi-select mode with checkboxes in sidebar */
multiSelectMode?: boolean
/** Selected tables in multi-select mode */
selectedTables?: SelectedTable[]
/** Tables that are already added and should show as disabled */
disabledTables?: SelectedTable[]
features?: DbFeatures
asset?: Asset
/** Shown in the data pane instead of the table, e.g. why the database could not be read. */
mainPane?: Snippet
onImport?: (mode: 'schema_and_data' | 'schema_only') => void
/** Where a table's grid layout (pins, widths) is remembered; not remembered without it. */
tableLayoutKey?: (schema: string | undefined, table: string) => string
/** Tabs for the right pane, replacing the single view `requestedViewMode` picks. The tree's
* selection is then the current data tab's table. */
tabs?: DbManagerTabs
/** A SQL editor tab's content. */
sqlTab?: Snippet<[{ id: string; code?: string }]>
}
let {
requestedViewMode = 'data',
onViewMode,
databaseKey,
dbType,
dbSchema,
dbTableOpsFactory,
dbSchemaOps,
dbSupportsSchemas,
databaseIsEmpty,
colDefs,
refresh,
initialSchemaKey,
initialTableKey,
selectedSchemaKey = $bindable(undefined),
selectedTableKey = $bindable(undefined),
datatableTree,
datatableTreeLoading,
onSelectDatatable,
currentRole,
onSelectRole,
pendingAction = $bindable(),
onDatatableAction,
workspace,
canManageDatatable = false,
multiSelectMode = false,
selectedTables = $bindable([]),
disabledTables = [],
features,
asset,
onImport,
tableLayoutKey,
tabs,
sqlTab,
mainPane
}: Props = $props()
// The engines whose SQL has `ALTER SCHEMA .. RENAME TO`: BigQuery datasets and DuckDB schemas
// cannot be renamed.
const SCHEMA_RENAME_DB_TYPES: DbType[] = ['postgresql', 'snowflake']
// PostgreSQL is the only database whose foreign keys can be read for the whole
// database in one query; the others would need one job per table. A caller
// already using the tree's checkboxes to collect tables keeps them.
let supportsDiagram = $derived(dbType === 'postgresql' && !multiSelectMode)
// The mode is asked for from outside, where the control that sets it lives, so
// a database this manager cannot draw has to be clamped here rather than left
// showing a diagram with no control in reach to leave it by.
let viewMode: DbManagerViewMode | 'sql' = $derived(
tabs ? tabs.active.kind : supportsDiagram ? requestedViewMode : 'data'
)
// Everything the diagram remembers, against the database it is about. The
// manager is not remounted when that database changes, so every read goes
// through `diagram`, where a key that no longer matches reads as the fresh
// diagram a switch should give. Kept apart from `selectedTables` so that
// checking a table to see it drawn can never add it to whatever the caller's
// multi-select is collecting.
type DiagramState = {
databaseKey: string | undefined
tables: SelectedTable[]
/** Whether this database's first draw has happened — by the user checking
* something, or by the automatic one below. */
drawn: boolean
}
let diagramState = $state<DiagramState>({ databaseKey: undefined, tables: [], drawn: false })
let diagram = $derived<DiagramState>(
diagramState.databaseKey === databaseKey
? diagramState
: { databaseKey, tables: [], drawn: false }
)
// With tabs, each diagram tab keeps its own tables, and the tree ticks the active one's.
let activeDiagramTab = $derived(tabs?.active.kind === 'diagram' ? tabs.active : undefined)
let diagramTables = $derived(tabs ? (activeDiagramTab?.tables ?? []) : diagram.tables)
let diagramDrawn = $derived(tabs ? activeDiagramTab?.tables !== undefined : diagram.drawn)
function setDiagramTables(tables: SelectedTable[]) {
if (tabs) {
if (activeDiagramTab) tabs.update(activeDiagramTab.id, { tables })
} else diagramState = { databaseKey, tables, drawn: true }
}
let checkedTables = $derived(viewMode === 'diagram' ? diagramTables : selectedTables)
function setCheckedTables(tables: SelectedTable[]) {
if (viewMode === 'diagram') setDiagramTables(tables)
else selectedTables = tables
}
/** The diagram only has the schema and relations of the database it is connected
* to, so another data table's rows get no checkbox there. */
function showsCheckbox(datatable: string | undefined): boolean {
return multiSelectMode || (viewMode === 'diagram' && datatable === currentDatatable)
}
const sameTable = (a: SelectedTable, b: SelectedTable) =>
a.datatable === b.datatable && a.schema === b.schema && a.table === b.table
function isTableSelected(t: SelectedTable): boolean {
return checkedTables.some((s) => sameTable(s, t))
}
/** Already added by the caller: shown ticked and locked. Only the caller's
* selection has tables that are already spoken for. */
function isTableDisabled(t: SelectedTable): boolean {
return viewMode !== 'diagram' && disabledTables.some((s) => sameTable(s, t))
}
function toggleTableSelection(t: SelectedTable) {
if (isTableDisabled(t)) return
setCheckedTables(
isTableSelected(t) ? checkedTables.filter((s) => !sameTable(s, t)) : [...checkedTables, t]
)
}
/** In the diagram a row toggles what is drawn: selecting it would fetch that one
* table's foreign keys for a data pane that is not shown. */
function onTableRowClick(t: SelectedTable) {
if (viewMode === 'diagram' && t.datatable === currentDatatable) toggleTableSelection(t)
else selectTable(t.datatable, t.schema, t.table)
}
/** Every table under a node, as it is currently rendered — so a batch toggle
* acts on what the user can see, search filter included. */
function tablesUnder(datatable: string | undefined, schemaKey?: string): SelectedTable[] {
return treeRoots
.filter((r) => r.datatable === datatable)
.flatMap((r) =>
r.schemas
.filter((sc) => schemaKey === undefined || sc.schemaKey === schemaKey)
.flatMap((sc) => sc.tables.map((table) => ({ datatable, schema: sc.schemaKey, table })))
)
}
/** Tri-state of a node's batch checkbox. Locked tables count as ticked, so a
* node whose tables were all already added reads as full rather than empty. */
function batchState(
datatable: string | undefined,
schemaKey?: string
): { checked: boolean; indeterminate: boolean; disabled: boolean } {
const tables = tablesUnder(datatable, schemaKey)
const selectable = tables.filter((t) => !isTableDisabled(t))
const n = tables.filter((t) => isTableSelected(t) || isTableDisabled(t)).length
return {
checked: tables.length > 0 && n === tables.length,
indeterminate: n > 0 && n < tables.length,
disabled: selectable.length === 0
}
}
function toggleBatch(datatable: string | undefined, schemaKey?: string) {
const selectable = tablesUnder(datatable, schemaKey).filter((t) => !isTableDisabled(t))
if (selectable.every((t) => isTableSelected(t))) {
setCheckedTables(checkedTables.filter((s) => !selectable.some((t) => sameTable(s, t))))
} else {
const missing = selectable.filter((t) => !isTableSelected(t))
setCheckedTables([...checkedTables, ...missing])
}
}
let schemaKeys = $derived(Object.keys(dbSchema.schema ?? {}))
// --- Left-pane tree ---------------------------------------------------------
// Levels: data table -> schema -> table. The top two collapse away on their own
// terms: no `datatableTree` means this is not a data table, and a database
// without schemas has nothing to put between a data table and its tables.
const currentDatatable = $derived(asset?.kind === 'datatable' ? asset.path : undefined)
/** Favourites are keyed by the table's own asset URI, so a row under another
* data table must not borrow the one the manager is currently pointed at. */
function tableAssetPath(datatable: string | undefined, schemaKey: string, tableKey: string) {
const kind = datatable !== undefined ? 'datatable' : asset!.kind
const path = datatable ?? asset!.path
return `${kind}://${path === 'main' ? '' : path}/${schemaKey}.${tableKey}`
}
/** Tables per schema for a data table, as `schema -> table[]`. */
function schemasOf(datatable: string | undefined): Record<string, string[]> {
// The open data table reads from `dbSchema`, which is refetched after a DDL;
// the tree snapshot is not, so using it here would hide a table until the
// next full reload.
if (datatable === undefined || datatable === currentDatatable) {
return Object.fromEntries(
Object.entries(dbSchema.schema ?? {}).map(([sk, tables]) => [sk, Object.keys(tables ?? {})])
)
}
return datatableTree?.find((d) => d.datatable_name === datatable)?.schemas ?? {}
}
function errorOf(datatable: string): string | undefined {
return datatableTree?.find((d) => d.datatable_name === datatable)?.error
}
/** Whether the role this connection uses may create a table in that schema,
* or a schema at all. A plain database reports nothing, and there the buttons
* stay: hiding them on no information would be worse than a refusal. */
function canCreateTableIn(datatable: string | undefined, schemaKey: string): boolean {
const entry = datatableTree?.find((d) => d.datatable_name === datatable)
if (!entry?.creatable_schemas) return true
// A schema created since the snapshot is not in it yet; the role just made
// it, so it can write to it.
if (!(schemaKey in entry.schemas)) return true
return entry.creatable_schemas.includes(schemaKey)
}
/** Roles and grants only exist on the instance database, which cloud does not offer. Whether
* the caller may change them is the access editor's to say: it opens read-only for everyone
* else. */
function isInstanceDatatable(datatable: string | undefined): boolean {
return (
!isCloudHosted() && !!datatableTree?.find((d) => d.datatable_name === datatable)?.instance
)
}
/** Why a data table's roles or grants cannot be used from here, or undefined when they can.
* The menu entries stay listed either way, disabled with this reason, so the feature is
* discoverable. `ee` marks the reason that is the edition. */
function accessBlocker(
datatable: string | undefined,
needsAdmin: boolean
): { reason: string; ee: boolean } | undefined {
if (!$enterpriseLicense)
return { reason: 'Data table roles and grants are an Enterprise Edition feature.', ee: true }
if (isCloudHosted())
return {
reason: 'Data table roles and grants are only available on self-hosted instances.',
ee: false
}
if (!isInstanceDatatable(datatable))
return {
reason:
"Roles and grants are not supported on data tables backed by a Postgres resource. Store the data table on Windmill's database or on an external Postgres instance (set up by a superadmin in instance settings) to use them.",
ee: false
}
if (needsAdmin && !canManageDatatable)
return { reason: 'Only workspace admins can manage data table roles.', ee: false }
return undefined
}
/** A roles or grants menu entry, disabled with its reason when it cannot be used. */
function accessItem(
label: string,
datatable: string | undefined,
needsAdmin: boolean,
action: () => void
) {
const blocker = accessBlocker(datatable, needsAdmin)
return {
displayName: blocker?.ee ? `${label} (EE)` : label,
icon: KeyRoundIcon,
disabled: !!blocker,
tooltip: blocker?.reason,
action: blocker ? () => {} : action
}
}
function canCreateSchemaIn(datatable: string | undefined): boolean {
const entry = datatableTree?.find((d) => d.datatable_name === datatable)
return entry === undefined || !!entry.can_create_schema
}
/** The role a data table row is reached through, and what it can be switched
* to. `none` is a data table under roles that grants this caller none of them.
* Absent where naming the role says nothing: a data table not under roles, or
* one whose single usable role is already `admin`. */
function roleOf(
datatable: string
): { kind: 'role'; role: string; roles: string[] } | { kind: 'none' } | undefined {
const entry = datatableTree?.find((d) => d.datatable_name === datatable)
if (!entry?.permissioned || !datatableNameTakesRole(datatable)) return undefined
const roles = entry.usable_roles
if (roles.length === 0) return { kind: 'none' }
const role =
(datatable === currentDatatable ? currentRole : undefined) ??
namedDefaultRole(entry.default_role) ??
roles[0]
if (roles.length === 1 && roles[0] === ADMIN_DATATABLE_ROLE && role === ADMIN_DATATABLE_ROLE) {
return undefined
}
return { kind: 'role', role, roles }
}
const matchesSearch = (t: string) => t.toLowerCase().includes(search.trim().toLowerCase())
/** The tree as rendered: only nodes with a matching descendant survive a search. */
let treeRoots = $derived.by(() => {
const datatables = datatableTree
? datatableTree.map((d) => d.datatable_name)
: [undefined as string | undefined]
return datatables
.map((dt) => {
const schemas = Object.entries(schemasOf(dt))
.map(([schemaKey, tables]) => ({
schemaKey,
// A schema that matches keeps all of its tables — the search named
// the schema, so what is in it is the answer. Copy before sorting:
// `tables` belongs to the tree snapshot, which is reactive state.
tables: (matchesSearch(schemaKey) ? [...tables] : tables.filter(matchesSearch)).sort()
}))
.filter(
(sc) => search.trim() === '' || matchesSearch(sc.schemaKey) || sc.tables.length > 0
)
schemas.sort((a, b) => a.schemaKey.localeCompare(b.schemaKey))
return { datatable: dt, schemas, error: dt ? errorOf(dt) : undefined }
})
.filter(
// A search narrows the tree to what matched; a data table with no match
// left in it would otherwise sit there as an empty row.
(root) => search.trim() === '' || root.schemas.length > 0
)
})
// Explicit open/closed choices, over a default rule. Storing only the
// overrides is what lets the current data table and selected schema — which
// default to open — actually be folded; a plain "expanded" set could never
// close them, since the default would keep winning.
// What the permissions drawer is open on, if anything: a schema, or a table.
let aclEditor = $state<PgAclEditor | undefined>(undefined)
/** The open access editor lists the data table's roles, which the roles drawer can change. */
export function refreshAcl() {
aclEditor?.refresh()
}
let aclDrawer = $state<{ datatable: string | undefined; target: AclTarget } | undefined>(
undefined
)
let expandOverrides = $state<Map<string, boolean>>(new Map())
const nodeKey = (dt: string | undefined, schemaKey?: string) =>
`${dt ?? ''}${schemaKey === undefined ? '' : `/${schemaKey}`}`
function defaultExpanded(dt: string | undefined, schemaKey?: string): boolean {
if (dt !== undefined && dt !== currentDatatable) return false
return schemaKey === undefined || schemaKey === selected.schemaKey
}
function isExpanded(dt: string | undefined, schemaKey?: string): boolean {
// A search narrows the tree to what matched, so everything left is shown.
if (search.trim() !== '') return true
return expandOverrides.get(nodeKey(dt, schemaKey)) ?? defaultExpanded(dt, schemaKey)
}
function toggle(dt: string | undefined, schemaKey?: string) {
const key = nodeKey(dt, schemaKey)
const open = expandOverrides.get(key) ?? defaultExpanded(dt, schemaKey)
const next = new Map(expandOverrides)
next.set(key, !open)
expandOverrides = next
}
const rowChevronClass = (open: boolean) =>
'shrink-0 text-secondary transition-transform ' + (open ? '' : '-rotate-90')
/** A favourite says something about the table, so it stays visible; an empty
* star is just an affordance and waits for the pointer. */
const rowStarClass = (path: string) =>
'-ml-1 w-1 flex shrink-0 transition-opacity ' +
(favoriteManager.isStarred(path, 'asset') ? '' : 'opacity-0 group-hover:opacity-100')
/** Reveal a node, dropping a stale "closed" that would hide a new selection. */
function reveal(dt: string | undefined, schemaKey?: string) {
const next = new Map(expandOverrides)
next.delete(nodeKey(dt))
next.delete(nodeKey(dt, schemaKey))
expandOverrides = next
}
function selectTable(dt: string | undefined, schemaKey: string, tableKey: string) {
if (dt !== undefined && dt !== currentDatatable) {
// Switching data table re-mounts this component against the new one, so
// the target has to travel through the bound keys the parent keeps —
// local state here is about to be thrown away.
selectedSchemaKey = schemaKey
selectedTableKey = tableKey
onSelectDatatable?.(dt)
return
}
reveal(dt, schemaKey)
rowFilter = undefined
tabs?.focusData()
setSelected(schemaKey, tableKey)
}
/** Run a row action on the data table it belongs to, switching to it first
* when it is not the one the manager is connected to. */
function onDatatable(dt: string | undefined, action: RowAction): boolean {
if (dt === undefined || dt === currentDatatable) return true
if ('schema' in action) selectedSchemaKey = action.schema
if ('table' in action) selectedTableKey = action.table
pendingAction = { ...action, datatable: dt }
onSelectDatatable?.(dt)
return false
}
function startCreateTable(dt: string | undefined, schema: string) {
if (!onDatatable(dt, { kind: 'create-table', schema })) return
setSelected(schema, undefined)
dbTableEditorState = { open: true }
}
function startCreateSchema(dt: string | undefined) {
if (!onDatatable(dt, { kind: 'create-schema' })) return
schemaDialog = { mode: 'create' }
}
function startAlterTable(dt: string | undefined, schema: string, table: string) {
if (!onDatatable(dt, { kind: 'alter-table', schema, table })) return
setSelected(schema, table)
dbTableEditorState = { open: true, alterTableKey: table }
}
function startDeleteTable(dt: string | undefined, schema: string, table: string) {
if (!onDatatable(dt, { kind: 'delete-table', schema, table })) return
askingForConfirmation = {
title: `Are you sure you want to delete ${table} ? This action is irreversible`,
confirmationText: 'Delete permanently',
open: true,
id: 'db-manager-delete-table-confirmation-modal',
onConfirm: async () => {
askingForConfirmation && (askingForConfirmation.loading = true)
try {
await dbSchemaOps.onDelete({ tableKey: table, schema })
refresh?.()
sendUserToast(`Table '${table}' deleted successfully`)
} catch (e) {
let msg: string | undefined = (e as any).body ?? (e as Error).message
if (typeof msg !== 'string') msg = e ? JSON.stringify(e) : undefined
sendUserToast(msg ?? 'Action failed!', true)
}
askingForConfirmation = undefined
}
}
}
function startRenameSchema(dt: string | undefined, schema: string) {
if (!onDatatable(dt, { kind: 'rename-schema', schema })) return
schemaDialog = { mode: 'rename', schema }
newSchemaName = schema
}
function startDropSchema(dt: string | undefined, schema: string) {
if (!onDatatable(dt, { kind: 'drop-schema', schema })) return
askingForConfirmation = {
title: `Are you sure you want to drop ${schema} ? Everything in it goes with it, and this action is irreversible`,
confirmationText: 'Drop permanently',
open: true,
id: 'db-manager-drop-schema-confirmation-modal',
onConfirm: async () => {
askingForConfirmation && (askingForConfirmation.loading = true)
try {
await dbSchemaOps.onDeleteSchema({ schema })
refresh?.()
sendUserToast(`Schema '${schema}' dropped successfully`)
} catch (e) {
let msg: string | undefined = (e as any).body ?? (e as Error).message
if (typeof msg !== 'string') msg = e ? JSON.stringify(e) : undefined
sendUserToast(msg ?? 'Action failed!', true)
}
askingForConfirmation = undefined
}
}
}
// Finishes an action requested before the switch, now that this component is
// mounted against the data table it targeted.
$effect(() => {
const req = pendingAction
if (!req) return
// Landed somewhere else: the target may have failed to load, or the user
// may have moved on. Either way this action was asked for on another
// database, and dropping a schema is not a thing to do by approximation.
// `undefined` is somewhere else too — a plain postgres resource or a
// DuckLake — so the two must be equal, not merely not-known-to-differ.
if (req.datatable !== currentDatatable) {
pendingAction = undefined
return
}
// Creating a schema is the one action a data table with none can still
// take, so it must not wait on a schema being there.
if (req.kind === 'create-schema') {
pendingAction = undefined
schemaDialog = { mode: 'create' }
return
}
if (!schemaKeys.length) return
pendingAction = undefined
if (!schemaKeys.includes(req.schema)) return
if (req.kind === 'create-table') startCreateTable(undefined, req.schema)
else if (req.kind === 'drop-schema') startDropSchema(undefined, req.schema)
else if (req.kind === 'rename-schema') startRenameSchema(undefined, req.schema)
else if (req.kind === 'alter-table') startAlterTable(undefined, req.schema, req.table)
else startDeleteTable(undefined, req.schema, req.table)
})
let search = $state('')
// The table the tree has selected. With tabs it is the current data tab's; `ownSelected`
// keeps the browsed schema for when there is no data tab, and for a new one to start in.
let ownSelected: { schemaKey?: string; tableKey?: string } = $state({})
const selected = {
get schemaKey(): string | undefined {
const tab = tabs?.currentData
return (tab ? tab.schema : undefined) ?? ownSelected.schemaKey
},
set schemaKey(schemaKey: string | undefined) {
setSelected(schemaKey, selected.tableKey)
},
get tableKey(): string | undefined {
const tab = tabs?.currentData
return tab ? tab.table : ownSelected.tableKey
},
set tableKey(tableKey: string | undefined) {
setSelected(selected.schemaKey, tableKey)
}
}
function setSelected(schemaKey: string | undefined, tableKey: string | undefined) {
ownSelected = { schemaKey, tableKey }
const tab = tabs?.currentData
if (tab) tabs!.update(tab.id, { schema: schemaKey, table: tableKey })
}
$effect(() => {
if (!selected.schemaKey && schemaKeys.length) {
let schemaKey =
initialSchemaKey ??
('public' in dbSchema.schema
? 'public'
: 'dbo' in dbSchema.schema
? 'dbo'
: 'main' in dbSchema.schema
? 'main'
: schemaKeys[0])
let tableKey =
initialTableKey && dbSchema.schema?.[schemaKey]?.[initialTableKey]
? initialTableKey
: undefined
setSelected(schemaKey, tableKey)
}
})
// Sync selected state with bindable props
$effect(() => {
if (selected.schemaKey) {
selectedSchemaKey = selected.schemaKey
}
if (selected.tableKey) {
selectedTableKey = selected.tableKey
}
})
let tableKeys = $derived.by(() => {
if (dbSchema.lang === 'graphql') {
sendUserToast('graphql not supported by DBExplorerTable', true)
return []
}
if (!selected.schemaKey) return []
return Object.keys(dbSchema.schema[selected.schemaKey] ?? {})
})
$effect(() => {
if (tableKeys.length && !selected.tableKey) {
selected.tableKey = filteredTableKeys[0]
}
})
let filteredTableKeys = $derived.by(() => {
const l = tableKeys.filter((tk) => tk.includes(search))
l.sort()
return l
})
let tableKey = $derived(
dbSupportsSchemas && selected.schemaKey
? `${selected.schemaKey}.${selected.tableKey}`
: selected.tableKey
)
// Set by following a foreign key, until the table's search bar takes it; pinned
// to the table it was created for so a schema change can't carry it onto an
// unrelated table.
let rowFilter: (DbRowFilter & { tableKey: string }) | undefined = $state()
/** Where a foreign key's `schema.table` target lives in the sidebar, or
* undefined when it cannot be opened from here. */
function resolveForeignKeyTarget(
targetTable: string
): { schemaKey: string; table: string } | undefined {
const parts = targetTable.split('.')
const table = parts[parts.length - 1]
const qualifier = parts.length > 1 ? parts.slice(0, -1).join('.') : undefined
// Without schema support the sidebar browses the connection's default
// schema only, and unqualified reads would hit a same-named local table.
if (!dbSupportsSchemas && qualifier && qualifier !== selected.schemaKey) return undefined
const schemaKey = dbSupportsSchemas && qualifier ? qualifier : selected.schemaKey
if (!schemaKey || !(table in (dbSchema.schema[schemaKey] ?? {}))) return undefined
return { schemaKey, table }
}
function goToRow(target: DbForeignKeyTarget) {
const resolved = resolveForeignKeyTarget(target.table)
if (!resolved) {
sendUserToast(`Table ${target.table} cannot be opened from this schema`, true)
return
}
if (renderDbEqualityFilter(target.column, target.value, dbType) === undefined) {
sendUserToast('This value cannot be used as a filter', true)
return
}
const { schemaKey, table } = resolved
if (tabs) {
// The referenced row opens beside the table it was followed from, not in its place.
reveal(currentDatatable, schemaKey)
tabs.add('data', { schema: schemaKey, table })
} else {
selectTable(currentDatatable, schemaKey, table)
}
rowFilter = {
tableKey: dbSupportsSchemas ? `${schemaKey}.${table}` : table,
column: target.column,
value: target.value
}
}
// The schema lists a table's columns before the metadata query returns their full
// definitions: the grid shows its header from those meanwhile, and reads no rows until the
// definitions it queries with are in.
function provisionalColDefs(schema: string | undefined, table: string | undefined): ColumnDef[] {
const columns = (schema && table && dbSchema.schema?.[schema]?.[table]) || {}
return Object.entries(columns).map(([field, col]: [string, any]) => ({
field,
datatype: col?.type ?? '',
defaultvalue: col?.default ?? '',
isprimarykey: false,
isidentity: ColumnIdentity.No,
isnullable: col?.required ? 'NO' : 'YES',
isenum: false
}))
}
function waitingDbTableOps(defs: ColumnDef[], key: string): IDbTableOps {
const never = () => new Promise<never>(() => {})
return { dbType, tableKey: key, colDefs: defs, getRows: never, getCount: never }
}
const tableKeyOf = (schema: string | undefined, table: string | undefined) =>
table && (dbSupportsSchemas && schema ? `${schema}.${table}` : table)
// The tables on screen: every opened data tab keeps its grid, so each needs its keys.
let mountedTabs = new SvelteSet<string>()
$effect(() => {
if (tabs) mountedTabs.add(tabs.activeId)
})
let shownTables = $derived.by(() => {
if (!tabs) return tableKey ? [{ schema: selected.schemaKey, table: selected.tableKey! }] : []
return tabs.tabs.flatMap((t) =>
t.kind === 'data' && t.table && mountedTabs.has(t.id)
? [{ schema: t.schema ?? ownSelected.schemaKey, table: t.table }]
: []
)
})
// Foreign keys by table key, read once per table and metadata: a reload of the metadata
// (after a DDL) drops them all.
let fkCache = new SvelteMap<string, TableEditorForeignKey[]>()
let fkRequested = new Set<string>()
let fkDefs: unknown = undefined
$effect(() => {
const defs = colDefs
const wanted = shownTables
untrack(() => {
if (fkDefs !== defs) {
fkDefs = defs
fkCache.clear()
fkRequested.clear()
}
for (const { schema, table } of wanted) {
const key = tableKeyOf(schema, table)!
if (fkRequested.has(key)) continue
fkRequested.add(key)
if (features?.foreignKeys === false) {
fkCache.set(key, [])
continue
}
dbSchemaOps.onFetchForeignKeys({ table, schema }).then(
(fks) => fkDefs === defs && fkCache.set(key, fks),
() => fkRequested.delete(key)
)
}
})
})
/** The tables a table can show columns of: those it references by a single-column
* foreign key, with their columns. Undefined until both its foreign keys and the columns
* of the tables they reference are read: an empty answer would prune saved joins. */
function joinTargetsOf(key: string): DbJoinTarget[] | undefined {
if (!colDefs) return undefined
return foreignKeysOf(key)?.flatMap((fk) => {
if (fk.columns.length !== 1 || !fk.targetTable) return []
const { sourceColumn, targetColumn } = fk.columns[0]
const resolved = resolveForeignKeyTarget(fk.targetTable)
if (!sourceColumn || !targetColumn || !resolved) return []
const targetKey = tableKeyOf(resolved.schemaKey, resolved.table)!
const columns = (colDefs?.[targetKey] ?? [])
.filter((c) => !c.ignored)
.map((c) => ({ field: c.field, datatype: c.datatype }))
return columns.length ? [{ sourceColumn, targetTable: targetKey, targetColumn, columns }] : []
})
}
// Only keys whose target the sidebar can open get the "Go to row" affordance.
function foreignKeysOf(key: string): TableEditorForeignKey[] | undefined {
return fkCache
.get(key)
?.filter((fk) => fk.targetTable && resolveForeignKeyTarget(fk.targetTable) !== undefined)
}
// Fetched once for the whole database rather than per table: the diagram needs
// every relation at once, and the per-table query would be one job each. A
// queued job cannot be recalled, so there is at most one read per database and
// metadata, kept across mode changes — not a `resource`, which abandons a run
// on any change of its inputs, a toggle included. A read is replaced only by
// another database, reloaded metadata, or a retry after failing.
type RelationsRead = {
databaseKey: string | undefined
/** The metadata it was read beside, which is what the cards are built from. */
defs: Record<string, ColumnDef[]>
token: number
status: 'loading' | 'ok' | 'failed'
relations: DbRelation[]
error?: string
}
let relationsRead = $state.raw<RelationsRead | undefined>(undefined)
let relationsToken = 0
$effect(() => {
const key = databaseKey
// The metadata can land after the schema; its arrival re-runs this.
const defs = colDefs
if (viewMode !== 'diagram' || defs === undefined) return
untrack(() => {
const current = relationsRead
const reusable =
!!current &&
current.databaseKey === key &&
current.defs === defs &&
current.status !== 'failed'
if (reusable) return
const token = ++relationsToken
relationsRead = { databaseKey: key, defs, token, status: 'loading', relations: [] }
dbSchemaOps.onFetchAllForeignKeys().then(
(relations) => settleRelations(token, { status: 'ok', relations }),
(e) =>
settleRelations(token, {
status: 'failed',
relations: [],
error: (e as any)?.body ?? (e as Error)?.message ?? String(e)
})
)
})
})
/** A read that has since been replaced answers about something no longer shown. */
function settleRelations(
token: number,
outcome: Pick<RelationsRead, 'status' | 'relations' | 'error'>
) {
if (relationsRead?.token !== token) return
relationsRead = { ...relationsRead, ...outcome }
}
// Only ever shown beside the database and metadata it was read from.
let shownRelationsRead = $derived(
relationsRead?.databaseKey === databaseKey && relationsRead?.defs === colDefs
? relationsRead
: undefined
)
let currentRelations = $derived(shownRelationsRead?.relations ?? [])
/** The schema a database's diagram opens on. `selected.schemaKey` follows the
* tree and is only replaced once it is empty, so after a switch between
* databases it can still name a schema of the previous one; it is taken only
* when this database actually has it. */
function schemaToDraw(schema: DBSchema['schema']): string | undefined {
const browsed = selected.schemaKey
if (browsed && browsed in schema) return browsed
return ['public', 'dbo', 'main'].find((s) => s in schema) ?? Object.keys(schema)[0]
}
// Opening the diagram on an empty canvas would make it look broken, so a
// database's first draw is that schema — unless it is big enough that drawing
// all of it is a choice the user should make. Everything it needs is read
// reactively: after a switch the schema arrives late, and counting the draw
// done without it is how a database ends up on a blank canvas for good.
const DIAGRAM_AUTOSELECT_LIMIT = 40
$effect(() => {
// Re-run on a database switch, which the draw is per.
databaseKey
const schema = dbSchema.schema
if (viewMode !== 'diagram' || diagramDrawn) return
const schemaKey = schemaToDraw(schema)
if (!schemaKey) return
const tables = Object.keys(schema[schemaKey] ?? {})
if (!tables.length) return
untrack(() => {
setDiagramTables(
tables.length > DIAGRAM_AUTOSELECT_LIMIT
? []
: tables.map((table) => ({ datatable: currentDatatable, schema: schemaKey, table }))
)
})
})
let askingForConfirmation:
| (ConfirmationModal['$$prop_def'] & { onConfirm: () => void })
| undefined = $state()
let dbTableEditorState:
| { open: boolean; alterTableKey?: undefined }
| { open: true; alterTableKey: string } = $state({
open: false
})
let dbTableEditorAlterTableData = resource(
[() => dbTableEditorState.alterTableKey, () => colDefs],
async ([table]) => {
if (!table) return
let tableKey2 =
dbSupportsSchemas && selected.schemaKey ? `${selected.schemaKey}.${table}` : table
if (!colDefs?.[tableKey2]) return
return await dbSchemaOps.onFetchTableEditorDefinition({
table: table,
schema: selected.schemaKey,
colDefs: colDefs[tableKey2]
})
}
)
// Naming a schema: a new one, or a new name for one that exists.
let schemaDialog = $state<{ mode: 'create' } | { mode: 'rename'; schema: string } | undefined>(
undefined
)
let newSchemaName = $state('')
/** What the drawer is about, for its title. */
function aclLabel(target: AclTarget | undefined): string {
if (!target) return ''
switch (target.kind) {
case 'table':
return `${target.schema}.${target.table}`
case 'schema':
return target.schema
default:
return 'database'
}
}
function closeSchemaDialog() {
schemaDialog = undefined
newSchemaName = ''
}
// Check if the sanitized schema name already exists
const sanitizedNewSchemaName = $derived.by(() => {
let s = newSchemaName.trim().replace(/[^a-zA-Z0-9_]/g, '')
if (dbType === 'snowflake') s = s.toUpperCase()
return s
})
const schemaAlreadyExists = $derived(
sanitizedNewSchemaName !== '' &&
schemaKeys
.filter((s) => !(schemaDialog?.mode === 'rename' && s === schemaDialog.schema))
.map((s) => s.toLowerCase())
.includes(sanitizedNewSchemaName.toLowerCase())
)
/** The statement the dialog is about to run, which is also what it asks about. */
const schemaStatement = $derived(
schemaDialog?.mode === 'rename'
? `ALTER SCHEMA ${schemaDialog.schema} RENAME TO ${sanitizedNewSchemaName}`
: `CREATE SCHEMA ${sanitizedNewSchemaName}`
)
const canSubmitSchemaName = $derived(
!!sanitizedNewSchemaName &&
!schemaAlreadyExists &&
(schemaDialog?.mode !== 'rename' || sanitizedNewSchemaName !== schemaDialog.schema)
)
function submitSchemaName() {
const dialog = schemaDialog
if (!dialog || !canSubmitSchemaName) return
const name = sanitizedNewSchemaName
askingForConfirmation = {
confirmationText: dialog.mode === 'rename' ? `Rename to ${name}` : `Create ${name}`,
type: 'reload',
title: `This will run '${schemaStatement}' on your database. Are you sure?`,
open: true,
id: 'db-schema-name-confirmation-modal',
onConfirm: async () => {
askingForConfirmation && (askingForConfirmation.loading = true)
try {
if (dialog.mode === 'rename') {
await dbSchemaOps.onRenameSchema({ schema: dialog.schema, newSchema: name })
} else {
await dbSchemaOps.onCreateSchema({ schema: name })
}
refresh?.()
selected.schemaKey = name
closeSchemaDialog()
} finally {
askingForConfirmation = undefined
}
}
}
}
let dbTableRefs: Record<string, DBTable | undefined> = $state({})
/** The grid of the current data tab, or the only one without tabs. */
export const dbTable = () => dbTableRefs[tabs ? (tabs.currentData?.id ?? '') : '']
// Splitpanes sizes in percent, which makes the tree grow with the screen: convert a
// fixed width once, from the width the manager opens at.
const TREE_WIDTH_PX = 320
let treeSize: number | undefined = $state()
function measureTreeSize(el: HTMLElement) {
untrack(() => {
if (treeSize === undefined && el.clientWidth > 0)
treeSize = Math.min(50, (TREE_WIDTH_PX / el.clientWidth) * 100)
})
}
let managerWidth = $state(0)
let mainPaneWidth = $state(0)
/** Where the right pane starts, in px from the manager's left edge; follows the divider. */
export const mainPaneLeft = () => managerWidth - mainPaneWidth
</script>
{#snippet diagramView(tables: SelectedTable[])}
<DbSchemaDiagram
{dbSchema}
{colDefs}
selectedTables={tables}
relations={currentRelations}
loading={shownRelationsRead?.status === 'loading'}
error={shownRelationsRead?.error}
onOpenTable={({ schema, table }) => {
if (!tabs) onViewMode?.('data')
selectTable(currentDatatable, schema, table)
}}
/>
{/snippet}
{#snippet dataView(
refKey: string,
schema: string | undefined,
table: string | undefined,
isCurrent: boolean
)}
{@const key = tableKeyOf(schema, table)}
{@const schemaDefs = key && !colDefs?.[key]?.length ? provisionalColDefs(schema, table) : []}
{#if key && (colDefs?.[key]?.length || schemaDefs.length)}
{@const dbTableOps = colDefs?.[key]?.length
? dbTableOpsFactory({ colDefs: colDefs[key], tableKey: key })
: waitingDbTableOps(schemaDefs, key)}
<!-- Filters, sort and column widths belong to one table. -->
{#key key}
<DBTable
{dbTableOps}
foreignKeys={foreignKeysOf(key)}
onGoToRow={goToRow}
rowFilter={isCurrent && rowFilter?.tableKey === key ? rowFilter : undefined}
onRowFilterApplied={() => (rowFilter = undefined)}
layoutStorageKey={table ? tableLayoutKey?.(schema, table) : undefined}
joinTargets={joinTargetsOf(key)}
onNewColumn={schema && table
? () => startAlterTable(currentDatatable, schema, table)
: undefined}
bind:this={dbTableRefs[refKey]}
/>
{/key}
{:else if databaseIsEmpty}
<div class="h-full w-full center-center flex-col gap-4">
<span class="text-hint">Database is empty</span>
{#if onImport}
<div class="flex gap-4">
<button
onclick={() => onImport('schema_only')}
class="hover:opacity-70 transition-opacity rounded-md border aspect-square w-52 gap-4 p-4 center-center flex-col"
>
<UploadIcon size={64} class="text-secondary" />
<span class="text-center font-normal text-sm text-secondary">
Import schema from database
</span>
</button>
{#if !!actingUser?.is_admin || !!$superadmin}
<button
onclick={() => onImport('schema_and_data')}
class="hover:opacity-70 transition-opacity rounded-md border aspect-square w-52 gap-4 p-4 center-center flex-col"
>
<UploadIcon size={64} class="text-secondary" />
<span class="text-center font-normal text-sm text-secondary">
Import schema and data from database
</span>
</button>
{/if}
</div>
{/if}
</div>
{/if}
{/snippet}
<div class="h-full w-full" bind:clientWidth={managerWidth} {@attach measureTreeSize}>
{#if treeSize !== undefined}
<Splitpanes>
<Pane size={treeSize} class="relative flex flex-col">
<div class="mx-3 mt-3 flex flex-col gap-2">
<TextInput
bind:value={search}
inputProps={{ placeholder: 'Search table or schema...' }}
/>
</div>
<div class="overflow-x-clip overflow-y-auto relative mt-1.5 flex-1">
<!-- Normal mode: data table -> schema -> table, each level dropping out
when it has nothing to say (no data table / no schemas). -->
{#if datatableTreeLoading && (datatableTree?.length ?? 0) === 0}
<div class="flex items-center gap-2 text-tertiary p-3">
<Loader2 class="animate-spin" size={14} />
<span class="text-xs">Loading...</span>
</div>
{/if}
{#each treeRoots as root (root.datatable ?? '')}
{@const dtOpen = isExpanded(root.datatable)}
{#if root.datatable !== undefined}
{@const hasMenu = !multiSelectMode && onDatatableAction !== undefined}
{@const roleInfo = roleOf(root.datatable)}
<button
class="group w-full text-xs font-normal text-primary flex gap-2 items-center h-8 cursor-pointer pl-3 pr-1 hover:bg-gray-500/10"
onclick={() => toggle(root.datatable)}
>
{#if showsCheckbox(root.datatable)}
{@const state = batchState(root.datatable)}
<Checkbox
checked={state.checked}
indeterminate={state.indeterminate}
disabled={state.disabled}
onChange={() => toggleBatch(root.datatable)}
onClick={(e) => e.stopPropagation()}
class="shrink-0"
/>
{/if}
<ChevronDownIcon class={rowChevronClass(dtOpen)} size={14} />
<DatabaseIcon class="shrink-0" size={14} />
<span class="truncate text-ellipsis text-left text-xs">{root.datatable}</span>
{#if root.datatable === currentDatatable}
<!-- Several data tables are listed, but only one is the one being
queried; the tree would otherwise not say which. -->
<span
class="shrink-0 w-1.5 h-1.5 rounded-full bg-green-400"
title="The data table this manager is connected to"
></span>
{/if}
{#if roleInfo?.kind === 'role'}
{@const dt = root.datatable}
<DatatableRoleBadge
role={roleInfo.role}
roles={roleInfo.roles}
onSelect={(role) => onSelectRole?.(dt, role)}
/>
{:else if roleInfo?.kind === 'none'}
<span
class="shrink-0 text-2xs text-tertiary"
title="This data table is under roles, and none of them is granted to you. Ask an admin of the workspace that governs it."
>
No usable role
</span>
{/if}
<div class="grow"></div>
<div class="relative shrink-0 w-6 h-8 flex items-center justify-end mr-2">
{#if hasMenu}
{@const dt = root.datatable}
<DropdownV2
enableFlyTransition
class="[&_button]:text-secondary"
items={() => [
{
displayName: 'Migrations',
icon: HistoryIcon,
action: () => onDatatableAction?.(dt, 'migrations')
},
accessItem('Roles', dt, true, () => onDatatableAction?.(dt, 'roles')),
{
displayName: 'Export',
icon: DownloadIcon,
action: () => onDatatableAction?.(dt, 'export')
},
{
displayName: 'Import',
icon: UploadIcon,
action: () => onDatatableAction?.(dt, 'import')
}
]}
btnId={'db-manager-datatable-actions-' + onlyAlphaNumAndUnderscore(dt)}
/>
{/if}
</div>
</button>
{/if}
{#if dtOpen}
{#if root.error}
<p class="text-xs text-red-400 px-3 py-2">{root.error}</p>
{/if}
{#each root.schemas as sc (sc.schemaKey)}
{@const schemaOpen = isExpanded(root.datatable, sc.schemaKey)}
{@const indent = root.datatable !== undefined ? 'pl-7' : 'pl-3'}
{#if dbSupportsSchemas}
<button
class={'group w-full text-xs font-normal text-primary flex gap-2 items-center h-8 cursor-pointer pr-1 hover:bg-gray-500/10 ' +
indent}
onclick={() => toggle(root.datatable, sc.schemaKey)}
>
{#if showsCheckbox(root.datatable)}
{@const state = batchState(root.datatable, sc.schemaKey)}
<Checkbox
checked={state.checked}
indeterminate={state.indeterminate}
disabled={state.disabled}
onChange={() => toggleBatch(root.datatable, sc.schemaKey)}
onClick={(e) => e.stopPropagation()}
class="shrink-0"
/>
{/if}
<ChevronDownIcon class={rowChevronClass(schemaOpen)} size={14} />
<FolderIcon class="shrink-0" size={14} />
<span class="truncate text-ellipsis grow text-left text-xs">{sc.schemaKey}</span
>
<div class="relative shrink-0 w-6 h-8 flex items-center justify-end mr-2">
{#if !multiSelectMode}
<DropdownV2
enableFlyTransition
class="[&_button]:text-secondary"
items={() => [
...(root.datatable !== undefined
? [
accessItem('Access', root.datatable, false, () => {
aclDrawer = {
datatable: root.datatable,
target: { kind: 'schema', schema: sc.schemaKey }
}
})
]
: []),
...(SCHEMA_RENAME_DB_TYPES.includes(dbType)
? [
{
displayName: 'Rename schema',
icon: EditIcon,
action: () => startRenameSchema(root.datatable, sc.schemaKey)
}
]
: []),
{
displayName: 'Drop schema',
icon: Trash2Icon,
type: 'delete',
action: () => startDropSchema(root.datatable, sc.schemaKey)
}
]}
btnId={'db-manager-schema-actions-' +
onlyAlphaNumAndUnderscore(sc.schemaKey)}
/>
{/if}
</div>
</button>
{/if}
<!-- Opening a schema slides its tables in rather than snapping them. -->
<ResizeTransitionWrapper vertical innerClass="w-full">
{#if schemaOpen || !dbSupportsSchemas}
{@const tableIndent = dbSupportsSchemas
? root.datatable !== undefined
? 'pl-11'
: 'pl-7'
: root.datatable !== undefined
? 'pl-7'
: 'pl-3'}
{#each sc.tables as tableKey (tableKey)}
{@const entry = {
datatable: root.datatable,
schema: sc.schemaKey,
table: tableKey
}}
{@const hasMenu = !multiSelectMode}
{@const isSelected =
root.datatable === currentDatatable &&
selected.schemaKey === sc.schemaKey &&
selected.tableKey === tableKey}
<button
class={'group w-full text-xs font-normal text-primary flex gap-2 items-center h-8 cursor-pointer pr-1 ' +
tableIndent +
' ' +
(isSelected ? 'bg-surface-secondary' : 'hover:bg-surface-hover')}
onclick={() => onTableRowClick(entry)}
>
{#if showsCheckbox(root.datatable)}
<Checkbox
checked={isTableSelected(entry) || isTableDisabled(entry)}
disabled={isTableDisabled(entry)}
title={isTableDisabled(entry) ? 'Already added' : undefined}
onChange={() => toggleTableSelection(entry)}
onClick={(e) => e.stopPropagation()}
class="shrink-0"
/>
{/if}
<span class="shrink-0 w-3.5"></span>
<Table2 class="shrink-0" size={14} />
<p class="db-manager-table-key truncate text-ellipsis text-left text-xs">
{tableKey}
</p>
{#if asset}
{@const starPath = tableAssetPath(root.datatable, sc.schemaKey, tableKey)}
<span class={rowStarClass(starPath)}>
<Star size={14} kind="asset" path={starPath} />
</span>
{/if}
<div class="grow"></div>
<div class="relative shrink-0 w-6 h-8 flex items-center justify-end mr-2">
{#if hasMenu}
<DropdownV2
enableFlyTransition
class="[&_button]:text-secondary"
items={() => [
...(root.datatable !== undefined
? [
accessItem('Access', root.datatable, false, () => {
aclDrawer = {
datatable: root.datatable,
target: {
kind: 'table',
schema: sc.schemaKey,
table: tableKey
}
}
})
]
: []),
{
displayName: 'Delete table',
icon: Trash2Icon,
action: () =>
startDeleteTable(root.datatable, sc.schemaKey, tableKey)
},
{
displayName: 'Alter table',
icon: EditIcon,
action: () =>
startAlterTable(root.datatable, sc.schemaKey, tableKey)
}
]}
btnId={'db-manager-table-actions-' +
onlyAlphaNumAndUnderscore(tableKey)}
/>
{/if}
</div>
</button>
{/each}
{#if canCreateTableIn(root.datatable, sc.schemaKey)}
<button
class={'w-full text-xs font-normal flex gap-2 items-center h-8 cursor-pointer pr-1 hover:bg-gray-500/10 text-secondary ' +
tableIndent}
onclick={() => startCreateTable(root.datatable, sc.schemaKey)}
>
<Plus class="shrink-0" size={14} />
<span class="text-xs">New table</span>
</button>
{/if}
{/if}
</ResizeTransitionWrapper>
{/each}
{#if dbSupportsSchemas && search.trim() === '' && canCreateSchemaIn(root.datatable)}
<button
class={'w-full text-xs font-normal flex gap-2 items-center h-8 cursor-pointer pr-1 hover:bg-gray-500/10 text-secondary ' +
(root.datatable !== undefined ? 'pl-7' : 'pl-3')}
onclick={() => startCreateSchema(root.datatable)}
>
<Plus class="shrink-0" size={14} />
<span class="text-xs">New schema</span>
</button>
{/if}
{/if}
{/each}
</div>
</Pane>
<Pane class={mainPane ? 'relative p-3 pt-1' : 'relative'}>
<div class="absolute inset-x-0 top-0 h-0" bind:clientWidth={mainPaneWidth}></div>
{#if mainPane}
{@render mainPane()}
{:else if tabs}
<!-- Every opened tab stays mounted, only hidden, so it is found as it was left:
scroll, filters, query results. Hidden is not enough on its own, as the diagram
forces its layers visible: the others are also stacked below the active one,
which has an opaque background. -->
{#each tabs.tabs as tab (tab.id)}
{#if mountedTabs.has(tab.id)}
<div
class={tab.id === tabs.activeId
? 'absolute inset-0 bg-surface'
: 'absolute inset-0 -z-10 invisible pointer-events-none'}
>
{#if tab.kind === 'data'}
{@render dataView(
tab.id,
tab.schema ?? ownSelected.schemaKey,
tab.table,
tab.id === tabs.currentData?.id
)}
{:else if tab.kind === 'diagram'}
{@render diagramView(tab.tables ?? [])}
{:else}
{@render sqlTab?.(tab)}
{/if}
</div>
{/if}
{/each}
{:else if viewMode === 'diagram'}
{@render diagramView(diagramTables)}
{:else}
{@render dataView('', selected.schemaKey, selected.tableKey, true)}
{/if}
</Pane>
</Splitpanes>
{/if}
</div>
<Portal>
<Drawer open={!!aclDrawer} size="900px" on:close={() => (aclDrawer = undefined)}>
<DrawerContent
title="Access: {aclLabel(aclDrawer?.target)}"
on:close={() => (aclDrawer = undefined)}
tooltip="Who owns this, and what each role may do with it."
>
{#if aclDrawer && workspace}
{@const dt = aclDrawer.datatable ?? currentDatatable}
{#if dt}
{#key `${dt}~${JSON.stringify(aclDrawer.target)}`}
<PgAclEditor
bind:this={aclEditor}
{workspace}
datatable={dt}
target={aclDrawer.target}
manageRoles={onDatatableAction
? {
open: () => onDatatableAction?.(dt, 'roles'),
blocker: accessBlocker(dt, true)?.reason
}
: undefined}
/>
{/key}
{/if}
{/if}
</DrawerContent>
</Drawer>
<ConfirmationModal
{...askingForConfirmation ?? { confirmationText: '', title: '' }}
on:canceled={() => (askingForConfirmation = undefined)}
on:confirmed={askingForConfirmation?.onConfirm ?? (() => {})}
/>
</Portal>
<Drawer
size="600px"
open={dbTableEditorState.open}
on:close={() => (dbTableEditorState = { open: false })}
>
<DrawerContent
id="db-table-editor-drawer"
on:close={() => (dbTableEditorState = { open: false })}
title={dbTableEditorState.alterTableKey
? `Alter ${dbTableEditorState.alterTableKey}`
: 'Create a new table'}
>
{#key dbTableEditorState.alterTableKey}
{#if !dbTableEditorState.alterTableKey || dbTableEditorAlterTableData.current}
<DbTableEditor
{features}
{dbSchema}
currentSchema={selected.schemaKey}
initialValues={dbTableEditorAlterTableData.current}
onConfirm={async ({ values }) => {
if (dbTableEditorState.alterTableKey && dbTableEditorAlterTableData.current) {
let diff = diffTableEditorValues(dbTableEditorAlterTableData.current, values)
// Reverse diff (new → old) so the migration's down undoes the alter.
let reverse = diffTableEditorValues(values, dbTableEditorAlterTableData.current)
await dbSchemaOps.onAlter({ schema: selected.schemaKey, values: diff, reverse })
} else {
await dbSchemaOps.onCreate({ values, schema: selected.schemaKey })
}
refresh?.()
sendUserToast(
dbTableEditorState.alterTableKey
? dbTableEditorState.alterTableKey + ' updated!'
: values.name + ' created!'
)
dbTableEditorState = { open: false }
}}
{dbType}
computePreview={async ({ values }) => {
if (dbTableEditorState.alterTableKey && dbTableEditorAlterTableData.current) {
let diff = diffTableEditorValues(dbTableEditorAlterTableData.current, values)
let sql = await dbSchemaOps.previewAlterSql({
values: diff,
schema: selected.schemaKey
})
let alert = !dbSupportsTransactionalDdl(dbType)
? {
title: capitalize(dbType) + ' does not support transactional DDL',
body: 'Any of these statements failing may leave your database in an intermediate state.'
}
: undefined
return { sql, ...(alert ? { alert } : {}) }
} else {
let sql = await dbSchemaOps.previewCreateSql({ values, schema: selected.schemaKey })
return { sql }
}
}}
computeBtnProps={({ values }) => {
if (dbTableEditorState.alterTableKey && dbTableEditorAlterTableData.current) {
let diff = diffTableEditorValues(dbTableEditorAlterTableData.current, values)
if (!diff.operations.length) {
return { text: 'No changes detected', disabled: true }
}
return {
text: `Alter table (${pluralize(diff.operations.length, 'change')} detected)`
}
} else {
return { text: 'Create table' }
}
}}
/>
{:else if dbTableEditorAlterTableData.loading || !colDefs}
<Loader2 class="animate-spin" size={32} />
{:else}
<p class="text-sm text-tertiary">Failed to load table definition.</p>
<p>{dbTableEditorAlterTableData.error}</p>
{/if}
{/key}
</DrawerContent>
</Drawer>
<Drawer size="400px" open={!!schemaDialog} on:close={closeSchemaDialog}>
<DrawerContent
on:close={closeSchemaDialog}
title={schemaDialog?.mode === 'rename'
? `Rename ${schemaDialog.schema}`
: 'Create a new schema'}
>
<div class="flex flex-col gap-4">
<div>
<label for="schema-name" class="block text-sm font-medium text-primary mb-1"
>Schema name</label
>
<ClearableInput
bind:value={newSchemaName}
placeholder="Enter schema name..."
autofocus
on:keydown={(e) => {
if (e.key === 'Enter') submitSchemaName()
}}
/>
{#if schemaAlreadyExists}
<p class="text-xs text-red-500 mt-1">
Schema "{sanitizedNewSchemaName}" already exists
</p>
{:else}
<p class="text-xs text-tertiary mt-1">
Only letters, numbers, and underscores are allowed.
</p>
{/if}
</div>
</div>
{#snippet actions()}
<Button variant="accent" disabled={!canSubmitSchemaName} on:click={submitSchemaName}>
{schemaDialog?.mode === 'rename' ? 'Rename schema' : 'Create schema'}
</Button>
{/snippet}
</DrawerContent>
</Drawer>