feat(datatables): data table roles in the DB manager and raw apps

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-09-16 15:23:20 +02:00
co-authored by Claude Opus 5
parent a1b91690fd
commit f36aa69fc3
54 changed files with 3275 additions and 1053 deletions
File diff suppressed because it is too large Load Diff
@@ -1,16 +1,21 @@
<script lang="ts">
import { dbSchemas, workspaceStore, type DBSchema } from '$lib/stores'
import type { DataTableTables } from '$lib/gen'
import { sortArray } from '$lib/utils'
import { Loader2, RefreshCcw } from 'lucide-svelte'
import Alert from './common/alert/Alert.svelte'
import Button from './common/button/Button.svelte'
import { dbSupportsSchemas } from './apps/components/display/dbtable/utils'
import {
dbSupportsSchemas,
getLanguageByResourceType
} from './apps/components/display/dbtable/utils'
import DbManager from './DBManager.svelte'
import DbWorkerTagPicker from './DbWorkerTagPicker.svelte'
import MissingWorkerTagAlert from './jobs/MissingWorkerTagAlert.svelte'
import {
dbSchemaOpsWithPreviewScripts,
dbTableOpsWithPreviewScripts,
getDatabaseArg,
getDbType,
getDefaultDbTag,
getDucklakeSchema
@@ -18,11 +23,11 @@
import { Pane, Splitpanes } from 'svelte-splitpanes'
import SqlRepl from './SqlRepl.svelte'
import SimpleAgTable from './SimpleAgTable.svelte'
import { type Snippet } from 'svelte'
import type { DbInput } from './dbTypes'
import type { DatatableRowAction, DbInput } from './dbTypes'
import { schemaCacheKey } from './dbSchemaCache'
import { getDbSchemas, loadAllTablesMetaData } from './apps/components/display/dbtable/metadata'
import type { SelectedTable } from './DBManager.svelte'
import type { PendingRowAction, SelectedTable } from './DBManager.svelte'
import { getDbFeatures } from './apps/components/display/dbtable/dbFeatures'
import { resource } from 'runed'
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
@@ -36,7 +41,15 @@
hasReplResult?: boolean
selectedSchemaKey?: string | undefined
selectedTableKey?: string | undefined
dbSelector?: Snippet<[]>
/** Every data table with its schemas and tables, for the left-pane tree. Undefined when
* the manager is not on a data table, which drops the tree's top level. */
datatableTree?: DataTableTables[]
datatableTreeLoading?: boolean
onSelectDatatable?: (datatable: string) => void
onSelectRole?: (datatable: string, role: string) => void
pendingAction?: PendingRowAction | undefined
onDatatableAction?: (datatable: string, action: DatatableRowAction) => void
canManageDatatable?: boolean
/** Enable multi-select mode with checkboxes in sidebar */
multiSelectMode?: boolean
/** Selected tables in multi-select mode */
@@ -59,7 +72,13 @@
hasReplResult = $bindable(false),
selectedSchemaKey = $bindable(undefined),
selectedTableKey = $bindable(undefined),
dbSelector,
datatableTree,
datatableTreeLoading,
onSelectDatatable,
onSelectRole,
pendingAction = $bindable(),
onDatatableAction,
canManageDatatable,
multiSelectMode = false,
selectedTables = $bindable([]),
disabledTables = [],
@@ -70,33 +89,25 @@
let ws = $derived(workspace ?? $workspaceStore)
let dbSchema: DBSchema | undefined = $derived(input && $dbSchemas[schemaCacheKey(input)])
let dbSchema: DBSchema | undefined = $derived(input && $dbSchemas[schemaCacheKey(ws, input)])
const outOfOrderModal = createAsyncConfirmationModal()
function getDbSchemasPath(input: DbInput): string {
switch (input.type) {
case 'database':
return input.resourcePath
case 'ducklake':
return 'ducklake://' + input.ducklake
}
}
// Scope the shared `dbSchemas` cache by the acting workspace: a datatable of
// the same name can exist in both the nav and the acting workspace, so the
// bare resource path alone would let one workspace's schema be reused for the
// other while DB operations target the acting one.
function schemaCacheKey(input: DbInput): string {
return `${ws}:${getDbSchemasPath(input)}`
}
// Reported in place of the loading spinner: both queries run as jobs, so
// anything from a bad connection to a tag no worker serves surfaces here
// instead of leaving the manager spinning with no explanation. Each query
// owns its slot so neither can clear the other's error on a refetch.
let schemaError = $state<string | undefined>(undefined)
let colDefsError = $state<string | undefined>(undefined)
function emptySchemaFor(db: DbInput): DBSchema {
return {
lang: db.type === 'ducklake' ? 'ducklake' : getLanguageByResourceType(db.resourceType),
schema: {},
publicOnly: undefined,
stringified: ''
} as DBSchema
}
let loadError = $derived(
schemaError
? { title: 'Could not load the database schema', message: schemaError }
@@ -136,14 +147,23 @@
const run = ++schemaRun
schemaError = undefined
if (!input) return
const dbSchemasPath = schemaCacheKey(input)
const dbSchemasPath = schemaCacheKey(ws, input)
if (input.type == 'database') {
let connection = input.resourcePath
try {
// The role'd reference, validated: an invalid role fails here rather than
// reading the schema as the data table's default role.
if (connection.startsWith('datatable://')) connection = getDatabaseArg(input).database!
} catch (e) {
schemaError = (e as Error)?.message ?? String(e)
return
}
// Reported through a local, not `schemaError` directly, so a superseded
// run's callback can't fail a load that already succeeded.
let queryError: string | undefined
const schema = await getDbSchemas(
input.resourceType,
input.resourcePath,
connection,
ws,
(message: string) => (queryError = message),
{ customTag: workerTag }
@@ -223,17 +243,23 @@
}}
/>
<!-- The error branch comes first on purpose: `dbSchema` is read from a cache that
survives a failed refetch, so ordering it first would hide the failure behind
stale content. -->
{#if loadError}
<!-- A load error replaces only the data pane: the tree, its role badge and menus, and the REPL
stay usable, so another data table or role can be picked and the connection tried by hand.
The tree then gets an empty schema: the cached one survives a failed refetch and would pass
stale content off as what this connection reaches. -->
{#snippet errorPane()}
<div class="h-full w-full flex flex-col items-center justify-center gap-3 p-8">
<div class="max-w-2xl w-full flex flex-col gap-3">
<Alert type="error" title={loadError.title} size="xs">
{loadError.message}
<Alert type="error" title={loadError?.title ?? ''} size="xs">
{loadError?.message}
</Alert>
<div class="self-start">
<Button size="xs" color="light" startIcon={{ icon: RefreshCcw }} on:click={() => refresh()}>
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: RefreshCcw }}
on:click={() => refresh()}
>
Retry
</Button>
</div>
@@ -247,9 +273,12 @@
/>
</div>
</div>
{:else if dbSchema && ws && input}
{/snippet}
{#if (loadError || dbSchema) && ws && input}
{@const _input = input}
{@const dbType = getDbType(_input)}
{@const shownSchema = loadError || !dbSchema ? emptySchemaFor(_input) : dbSchema}
<Splitpanes horizontal>
<Pane class="relative">
<!-- svelte-ignore a11y_click_events_have_key_events -->
@@ -274,9 +303,11 @@
</div>
<DbManager
dbSupportsSchemas={dbSupportsSchemas(dbType)}
databaseIsEmpty={!Object.values(dbSchema.schema).flatMap((s) => Object.values(s)).length}
{dbSchema}
colDefs={colDefs.current}
databaseIsEmpty={!loadError &&
!Object.values(shownSchema.schema).flatMap((s) => Object.values(s)).length}
dbSchema={shownSchema}
mainPane={loadError ? errorPane : undefined}
colDefs={loadError ? undefined : colDefs.current}
dbTableOpsFactory={({ colDefs, tableKey, whereClause }) =>
dbTableOpsWithPreviewScripts({
colDefs,
@@ -306,7 +337,15 @@
: undefined}
{dbType}
refresh={() => refresh()}
{dbSelector}
{datatableTree}
{datatableTreeLoading}
{onSelectDatatable}
{onSelectRole}
workspace={ws}
currentRole={input.type === 'database' ? input.role : undefined}
bind:pendingAction
{onDatatableAction}
{canManageDatatable}
{onImport}
bind:selectedSchemaKey
bind:selectedTableKey
@@ -329,12 +368,12 @@
onSchemaChange={() => refresh()}
placeholderTableName={sortArray(
Object.keys(
dbSchema?.schema[
'public' in dbSchema?.schema
shownSchema.schema[
'public' in shownSchema.schema
? 'public'
: 'dbo' in dbSchema?.schema
: 'dbo' in shownSchema.schema
? 'dbo'
: Object.keys(dbSchema?.schema ?? {})?.[0]
: Object.keys(shownSchema.schema ?? {})?.[0]
] ?? {}
)
)?.[0]}
@@ -1,25 +1,20 @@
<script lang="ts">
import { superadmin, userStore, workspaceStore } from '$lib/stores'
import { WorkspaceService } from '$lib/gen'
import { enterpriseLicense, superadmin, userStore, workspaceStore } from '$lib/stores'
import { WorkspaceService, type DataTableTables } from '$lib/gen'
import { listUsableDatatableRoles } from './datatableUsableRoles'
import Button from './common/button/Button.svelte'
import Drawer from './common/drawer/Drawer.svelte'
import DrawerContent from './common/drawer/DrawerContent.svelte'
import Select from './select/Select.svelte'
import {
ArrowLeft,
Copy,
Download,
Expand,
LoaderCircle,
Minimize,
RefreshCcw,
Upload
} from 'lucide-svelte'
import { ArrowLeft, Copy, Download, Expand, Minimize, RefreshCcw, Upload } from 'lucide-svelte'
import DBManagerContent from './DBManagerContent.svelte'
import type { PendingRowAction } from './DBManager.svelte'
import DataTableMigrationsButton from './workspaceSettings/DataTableMigrationsButton.svelte'
import DataTablePermissionsButton from './workspaceSettings/DataTablePermissionsButton.svelte'
import { resource } from 'runed'
import { untrack } from 'svelte'
import { tick, untrack } from 'svelte'
import type { DbManagerUriState } from './dbManagerDrawerModel.svelte'
import { ADMIN_DATATABLE_ROLE, type DatatableRowAction } from './dbTypes'
import ResourcePicker from './ResourcePicker.svelte'
import Alert from './common/alert/Alert.svelte'
import { sendUserToast } from '$lib/toast'
@@ -41,31 +36,95 @@
// the editor that opened it (set via openDrawer), else the nav workspace.
let ws = $derived(uriState.workspace ?? $workspaceStore)
// Load available datatables when drawer opens with datatable input
const datatables = resource<string[]>([], async () => {
if (!ws) return []
try {
return (await WorkspaceService.listDataTables({ workspace: ws })).map((d) => d.name)
} catch (e) {
console.error('Failed to load datatables:', e)
return []
}
})
// A create started on a data table other than the current one: survives the
// re-mount the switch causes.
let pendingAction = $state<PendingRowAction | undefined>(undefined)
const datatableItems = $derived(
datatables.current.map((dt) => ({
value: dt,
label: dt
}))
// Read once through primitives: the getters return values of a freshly parsed URL, which
// changes on every table click, and the listings below must not refetch for that.
const selectedDatatable = $derived(uriState.selectedDatatable)
const selectedRole = $derived(uriState.selectedRole)
// Roles the caller may use, to settle the role before anything connects. Offering only
// these is a convenience: the server refuses any other.
const usableRoles = resource(
() => [ws, selectedDatatable] as const,
async ([workspace, datatable]) => {
if (!workspace || !datatable) return undefined
try {
return {
datatable,
...(await listUsableDatatableRoles(workspace, datatable))
}
} catch (e) {
// Never leave the drawer waiting on this: fall back to the
// unpermissioned shape so it opens and the server picks the role.
console.error('Failed to load datatable roles:', e)
return { datatable, permissioned: false, roles: [], default_role: ADMIN_DATATABLE_ROLE }
}
}
)
// Refetch datatables when switching to a datatable input
// A resource keeps its previous value while refetching, and roles are per data
// table: settling from the last one's answer would connect to the new data
// table as a role it may not even have.
const rolesOfCurrent = $derived(
usableRoles.current?.datatable === selectedDatatable ? usableRoles.current : undefined
)
// Nothing that connects runs until the role is settled: a first round sent without a role
// would run — and cache — as whatever the server defaults to.
const roleSettled = $derived(
!uriState.isDatatableInput ||
(rolesOfCurrent !== undefined &&
(!rolesOfCurrent.permissioned ||
rolesOfCurrent.roles.length === 0 ||
selectedRole !== undefined))
)
// Make the role explicit before anything queries the data table, so the URL, the
// cache and every migration the manager writes name it. A role already in the URL
// is kept even when it is not usable: the server refuses it, visibly.
$effect(() => {
if (uriState.isDatatableInput) {
untrack(() => datatables.refetch())
}
const roles = rolesOfCurrent
if (!roles?.permissioned || selectedRole !== undefined) return
const effective = roles.roles.includes(roles.default_role) ? roles.default_role : roles.roles[0]
if (effective) untrack(() => (uriState.selectedRole = effective))
})
// Every data table with its schemas and tables, in one call: this is what the
// left pane's tree navigates, so it has to cover the data tables the user is
// not currently on, not just the selected one. The privileges it reports are
// the connected role's, so the role picked on the open data table is part of
// what is being asked. Gated on the drawer being open on a data table: this
// reaches every data table's database in turn, and the component is mounted on
// every logged-in page.
let datatablesRun = 0
const datatables = resource(
() =>
[
open && uriState.isDatatableInput,
ws,
selectedDatatable,
selectedRole,
roleSettled
] as const,
async ([active, workspace, roleFor, role, settled]): Promise<DataTableTables[]> => {
if (!active || !workspace) return []
if (!settled) return untrack(() => datatables.current)
const run = ++datatablesRun
try {
const result = await WorkspaceService.listDataTableTables({ workspace, roleFor, role })
// An answer for a selection that has since changed describes another role.
return run === datatablesRun ? result : untrack(() => datatables.current)
} catch (e) {
console.error('Failed to load datatables:', e)
return run === datatablesRun ? [] : untrack(() => datatables.current)
}
},
{ initialValue: [] }
)
function handleClose() {
uriState.closeDrawer()
dbManagerContent?.clearReplResult()
@@ -78,6 +137,10 @@
if (!open) {
expand = false
uriState.closeDrawer()
// An action asked for on one data table must not be waiting when the
// drawer is next opened on another database — or on no data table at
// all, where nothing would recognise it as foreign.
pendingAction = undefined
}
})
@@ -97,6 +160,8 @@
let importDrawerOpen = $state(false)
let importLoading = $state(false)
let importSource = $state<string | undefined>(undefined)
/** Which database an import writes into; set when driven from a tree row. */
let importTarget = $state<string | undefined>(undefined)
let importBehavior = $state<'schema_only' | 'schema_and_data'>('schema_only')
let isPostgresqlInput = $derived(
@@ -116,13 +181,49 @@
return toSourceIdentifier(input.resourcePath)
}
// The tree's row menus act on the data table of the row that was clicked, which
// is not necessarily the one currently open — so the target is set first and the
// headless modals are keyed on it.
let actionDatatable = $state<string | undefined>(undefined)
let migrationsModal = $state<DataTableMigrationsButton | undefined>()
let permissionsDrawer = $state<DataTablePermissionsButton | undefined>()
async function runDatatableAction(datatable: string, action: DatatableRowAction) {
actionDatatable = datatable
// Let the keyed block above mount against the new target before driving it.
await tick()
switch (action) {
case 'migrations':
migrationsModal?.open()
break
case 'roles':
permissionsDrawer?.open()
break
case 'export':
await handleExportSchema(`datatable://${datatable}`)
break
case 'import':
importTarget = `datatable://${datatable}`
importDrawerOpen = true
break
}
}
function refreshManager() {
dbManagerContent?.refresh()
dbManagerContent?.dbManager()?.dbTable()?.refresh()
refreshRoles()
}
async function handleExportSchema() {
const source = currentSourceIdentifier()
/** Re-read what the tree and the role picker show: both are answers about the
* data table's roles, which the permissions drawer can have just changed. */
function refreshRoles() {
datatables.refetch()
usableRoles.refetch()
}
async function handleExportSchema(explicitSource?: string) {
const source = explicitSource ?? currentSourceIdentifier()
if (!source || !ws) return
try {
exportResult = await WorkspaceService.exportPgSchema({
@@ -137,7 +238,7 @@
async function handleImportDatabase() {
if (!importSource || !ws) return
const target = currentSourceIdentifier()
const target = importTarget ?? currentSourceIdentifier()
if (!target) return
importLoading = true
try {
@@ -183,52 +284,48 @@
noPadding
id="db-manager-drawer"
>
{#if uriState.effectiveInput && ws}
{#key uriState.selectedDatatable}
{#if uriState.effectiveInput && ws && roleSettled}
{#key `${selectedDatatable}~${selectedRole ?? ''}`}
<DBManagerContent
bind:this={dbManagerContent}
input={uriState.effectiveInput}
workspace={uriState.workspace}
datatableTree={uriState.isDatatableInput ? datatables.current : undefined}
datatableTreeLoading={datatables.loading}
onSelectDatatable={(dt) => (uriState.selectedDatatable = dt)}
onSelectRole={(dt, role) => {
// Setting the data table clears the role, so the order matters.
uriState.selectedDatatable = dt
uriState.selectedRole = role
}}
bind:pendingAction
canManageDatatable={!!($superadmin || $userStore?.is_admin) &&
!!$enterpriseLicense &&
!isCloudHosted()}
onDatatableAction={runDatatableAction}
bind:workerTag={() => workerTag.tag, (v) => (workerTag.tag = v)}
bind:hasReplResult
bind:selectedSchemaKey={uriState.selectedSchema}
bind:selectedTableKey={uriState.selectedTable}
onImport={enableImportExport
? (mode) => ((importDrawerOpen = true), (importBehavior = mode))
? (mode) => (
(importTarget = undefined),
(importDrawerOpen = true),
(importBehavior = mode)
)
: undefined}
>
{#snippet dbSelector()}
{#if uriState.isDatatableInput}
{#if datatables.loading}
<div class="flex items-center gap-2 text-tertiary ml-2">
<LoaderCircle size={14} class="animate-spin" />
<span class="text-sm">Loading...</span>
</div>
{:else if datatables.current.length >= 1}
<Select
transformInputSelectedText={(s) => `Datatable: ${s}`}
items={datatableItems}
bind:value={uriState.selectedDatatable}
placeholder="Select data table"
size="md"
/>
{/if}
{/if}
{/snippet}
</DBManagerContent>
></DBManagerContent>
{/key}
{/if}
{#snippet actions()}
{#if uriState.isDatatableInput && uriState.selectedDatatable && ws}
<DataTableMigrationsButton
workspace={ws}
datatable={uriState.selectedDatatable}
onSchemaChanged={refreshManager}
/>
{/if}
{#if enableImportExport}
<Button startIcon={{ icon: Download }} onClick={handleExportSchema}>Export</Button>
<Button startIcon={{ icon: Upload }} onClick={() => (importDrawerOpen = true)}>
<!-- A data table exports and imports from its row menu in the tree; a plain
database has no tree row to hold them. -->
{#if enableImportExport && !uriState.isDatatableInput}
<Button startIcon={{ icon: Download }} onClick={() => handleExportSchema()}>Export</Button>
<Button
startIcon={{ icon: Upload }}
onClick={() => ((importTarget = undefined), (importDrawerOpen = true))}
>
Import
</Button>
{/if}
@@ -260,6 +357,27 @@
</DrawerContent>
</Drawer>
{#if actionDatatable && ws}
{#key actionDatatable}
<DataTableMigrationsButton
bind:this={migrationsModal}
hideTrigger
workspace={ws}
datatable={actionDatatable}
onSchemaChanged={refreshManager}
/>
{#if $enterpriseLicense && !isCloudHosted()}
<DataTablePermissionsButton
bind:this={permissionsDrawer}
hideTrigger
workspace={ws}
datatable={actionDatatable}
onSaved={refreshRoles}
/>
{/if}
{/key}
{/if}
<Drawer bind:open={exportDrawerOpen} size="800px" offset={offset + 1}>
<DrawerContent title="Export Schemas" on:close={() => (exportDrawerOpen = false)}>
{#if exportResult}
@@ -503,7 +503,7 @@
{/if}
{#if askingForConfirmation?.codeContent}
<div
class="bg-surface-secondary border border-surface-selected rounded-md p-2 relative group"
class="bg-surface-secondary border border-surface-selected rounded-md p-2 relative group min-w-0"
>
<button
class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-surface-hover"
@@ -512,9 +512,7 @@
>
<ClipboardCopy size={14} />
</button>
<pre class="whitespace-pre-wrap text-sm"
><code>{askingForConfirmation.codeContent}</code></pre
>
<pre class="overflow-x-auto text-sm"><code>{askingForConfirmation.codeContent}</code></pre>
</div>
{/if}
</ConfirmationModal>
@@ -0,0 +1,66 @@
<script lang="ts">
import { ChevronDown } from 'lucide-svelte'
import SelectDropdown from './select/SelectDropdown.svelte'
import Badge from './common/badge/Badge.svelte'
import { clickOutside } from '$lib/utils'
let {
role,
roles,
onSelect
}: {
/** The role in effect, shown on the badge. */
role: string
/** The roles the caller may switch to. */
roles: string[]
onSelect: (role: string) => void
} = $props()
let open = $state(false)
let anchorEl: HTMLSpanElement | undefined = $state()
const items = $derived(roles.map((r) => ({ label: r, value: r })))
// The table picker's drawer opens at `disposables + 10000`, which the
// dropdown's own z-index would sit under.
const dropdownClass = 'z-[20000]'
</script>
<span
bind:this={anchorEl}
class="relative flex min-w-0"
use:clickOutside={{ onClickOutside: () => (open = false) }}
>
<Badge
clickable
color="gray"
wrapperClass="min-w-0"
class="min-w-0 gap-0.5 pl-2 pr-1 bg-surface-sunken hover:bg-surface-sunken text-primary
transition-[filter,transform] hover:brightness-95 active:brightness-90 active:scale-[0.97]
{open ? 'brightness-95' : ''}"
onclick={(e) => {
// The row underneath folds on click, and picking a role is not that.
e.stopPropagation()
open = !open
}}
>
<!-- A long role name gives way rather than pushing the row's own actions
past its right edge. -->
<span class="truncate">{role}</span>
<ChevronDown
size={11}
class="shrink-0 text-secondary transition-transform {open ? 'rotate-180' : ''}"
/>
</Badge>
<SelectDropdown
processedItems={items}
value={role}
{open}
listAutoWidth={false}
class={dropdownClass}
getInputRect={anchorEl && (() => anchorEl!.getBoundingClientRect())}
onSelectValue={(item) => {
open = false
if (item.value !== role) onSelect(item.value)
}}
/>
</span>
@@ -6,8 +6,18 @@
import { joinSqlStatements, splitSqlRuns } from './sqlDdl'
import { logDdlGuardChoice } from './workspaceSettings/datatableTelemetry'
import { CornerDownLeft } from 'lucide-svelte'
import { withMigrationRole } from './datatableMigrationRole'
let { workspace, datatable }: { workspace: string; datatable: string } = $props()
let {
workspace,
datatable,
role
}: {
workspace: string
datatable: string
/** The role the editor runs as. The migration declares it, or it would run as admin. */
role?: string
} = $props()
type Choice = 'run' | 'migrate' | 'cancel'
@@ -73,7 +83,7 @@
function openMigrationModal(sql: string): Promise<boolean> {
return new Promise((resolve) => {
resolveMigrationClosed = (created: boolean) => resolve(created)
newMigrationModal?.open({ codeUp: sql })
newMigrationModal?.open({ codeUp: withMigrationRole(sql, role) })
})
}
@@ -145,6 +155,11 @@
migrations rather than run ad-hoc. Create a migration for it instead?
{/if}
</p>
{#if role}
<p class="text-sm text-secondary">
It will run as role <span class="font-mono">{role}</span>.
</p>
{/if}
<pre
class="text-xs whitespace-pre-wrap font-mono bg-surface-secondary rounded p-3 max-h-48 overflow-auto"
>{promptSql}</pre
+6 -1
View File
@@ -223,5 +223,10 @@
</Splitpanes>
{#if datatableName && ws}
<DdlMigrationGuard bind:this={ddlGuard} workspace={ws} datatable={datatableName} />
<DdlMigrationGuard
bind:this={ddlGuard}
workspace={ws}
datatable={datatableName}
role={input.type === 'database' ? input.role : undefined}
/>
{/if}
+5 -4
View File
@@ -9,9 +9,10 @@
kind: FavoriteKind
summary?: string
workspaceId?: string
size?: number
}
let { path, kind, workspaceId, summary }: Props = $props()
let { path, kind, workspaceId, summary, size = 16 }: Props = $props()
let buttonHover = $state(false)
let starred = $derived(favoriteManager.isStarred(path, kind))
@@ -31,14 +32,14 @@
>
{#if starred}
{#if buttonHover}
<StarOff size={16} fill="currentcolor" />
<StarOff {size} fill="currentcolor" />
{:else}
<Star size={16} fill="currentcolor" />
<Star {size} fill="currentcolor" />
{/if}
{:else}
<Star
class={!buttonHover ? 'opacity-60' : ''}
size={16}
{size}
fill={buttonHover ? 'currentcolor' : 'none'}
/>
{/if}
@@ -282,7 +282,8 @@ const scriptsV2: typeof legacyScripts = {
...legacyScripts.postgresql,
code: `
SELECT table_name, column_name, udt_name, column_default, is_nullable, nsp.nspname AS table_schema FROM information_schema.columns
RIGHT JOIN pg_namespace nsp ON table_schema = nsp.nspname WHERE nsp.nspname NOT IN ('information_schema', 'pg_toast', 'pg_catalog')`
RIGHT JOIN pg_namespace nsp ON table_schema = nsp.nspname WHERE nsp.nspname NOT IN ('information_schema', 'pg_toast', 'pg_catalog')
AND NOT starts_with(nsp.nspname, 'pg_') AND has_schema_privilege(nsp.oid, 'USAGE')`
}
}
@@ -186,7 +186,7 @@
<Icon class={theme[type].classes.icon} />
</div>
{/if}
<div class={twMerge('ml-0 text-left flex-1 ', showIcon ? 'ml-4' : '')}>
<div class={twMerge('ml-0 text-left flex-1 min-w-0', showIcon ? 'ml-4' : '')}>
<h3 class="text-lg font-medium text-primary">
{title}
</h3>
@@ -713,12 +713,14 @@ export class AIChatManager {
/** Every mounted flow editor. */
#flowEditors = new Set<FlowAIChatHelpers>()
appAiChatHelpers = $state<AppAIChatHelpers | undefined>(undefined)
/** Datatable creation policy: enabled flag, datatable name, and optional schema */
/** Datatable creation policy: enabled flag, datatable name, optional schema, and the role the
* app uses each data table through */
datatableCreationPolicy = $state<{
enabled: boolean
datatable: string | undefined
schema: string | undefined
}>({ enabled: false, datatable: undefined, schema: undefined })
roles?: Record<string, string>
}>({ enabled: false, datatable: undefined, schema: undefined, roles: undefined })
pendingNewCode = $state<string | undefined>(undefined)
apiTools = $state<Tool<any>[]>([])
aiChatInput = $state<AIChatInput | null>(null)
@@ -67,6 +67,7 @@
<DefaultDatabaseSelector
datatable={aiChatManager.datatableCreationPolicy.datatable}
schema={aiChatManager.datatableCreationPolicy.schema}
roles={aiChatManager.datatableCreationPolicy.roles}
onChange={handleDefaultChange}
description="Set the default datatable and schema for new tables. When table creation is enabled, AI can create tables here if needed."
/>
@@ -18,6 +18,7 @@ import {
type AppCodeSelectionElement,
type AppDatatableElement
} from '../context'
import { appDatatableRole, sdkDatatableCall } from '$lib/components/raw_apps/dataTableRefUtils'
// Backend runnable types
export type BackendRunnableType = 'script' | 'flow' | 'hubscript' | 'inline'
@@ -921,9 +922,20 @@ export function prepareAppSystemMessage(customPrompt?: string): ChatCompletionSy
const policy = aiChatManager.datatableCreationPolicy
const datatableName = policy.datatable ?? 'main'
const schemaPrefix = policy.schema ? `${policy.schema}.` : ''
// Use wmill.datatable() for 'main' (default), otherwise wmill.datatable('name')
const datatableCall =
datatableName === 'main' ? 'wmill.datatable()' : `wmill.datatable('${datatableName}')`
// A role names the privileges the app's queries run with, so it has to be in the code the
// model writes.
const datatableRole = appDatatableRole(policy.roles, datatableName)
const tsDatatableCall = sdkDatatableCall(datatableName, datatableRole, 'typescript')
const pyDatatableCall = sdkDatatableCall(datatableName, datatableRole, 'python')
const roleEntries = Object.entries(policy.roles ?? {})
const rolesNote =
roleEntries.length > 0
? `\n\nThis app uses these data tables through a role: ${roleEntries
.map(([dt, role]) => `\`${dt}\` as \`${role}\``)
.join(
', '
)}. Always pass that role when calling \`wmill.datatable\` on them, as in the examples. The role only reaches what it was granted, so a query on a table it lacks privileges on fails with \`permission denied\`.`
: ''
let content = `You are a helpful assistant that creates and edits apps on the Windmill platform. Apps are defined as a collection of files that contains both the frontend and the backend.
@@ -1024,7 +1036,7 @@ Backend runnables should only perform **data operations** (SELECT, INSERT, UPDAT
import * as wmill from 'windmill-client';
export async function main(user_id: string) {
const sql = ${datatableCall};
const sql = ${tsDatatableCall};
const user = await sql\`SELECT * FROM ${schemaPrefix}users WHERE id = \${user_id}\`.fetchOne();
return user;
}
@@ -1035,12 +1047,12 @@ export async function main(user_id: string) {
import wmill
def main(user_id: str):
db = ${datatableCall}
db = ${pyDatatableCall}
user = db.query('SELECT * FROM ${schemaPrefix}users WHERE id = $1', user_id).fetch_one()
return user
\`\`\`
Use these examples for normal datatable access.
Use these examples for normal datatable access.${rolesNote}
### Schema Modifications (DDL) - Use exec_datatable_sql tool ONLY
@@ -2,6 +2,7 @@ import { z } from 'zod'
import { WorkspaceService, type CompletedJob } from '$lib/gen'
import type { DataTableTables } from '$lib/gen/types.gen'
import { runScript } from '$lib/components/jobs/utils'
import { datatableReference } from '$lib/components/dbTypes'
import {
createToolDef,
executeTestRun,
@@ -15,9 +16,9 @@ import {
*
* Datatables are workspace-level managed PostgreSQL databases. The backend
* endpoints used here (`list_datatable_tables`, `get_datatable_table_schema`)
* and SQL execution (`datatable://<name>`) are gated only by workspace
* membership, so these tools need no app context and operate directly on the
* workspace. This is the unrestricted counterpart to the app-mode datatable
* and SQL execution (`datatable://<name>`) need no app context: the server
* decides what the caller reaches, as the datatable role they name or its
* default. This is the unrestricted counterpart to the app-mode datatable
* tools in `app/core.ts`, which additionally filter by the app's whitelist.
*/
@@ -31,9 +32,19 @@ const memo = <T>(factory: () => T): (() => T) => {
// ============= Pure workspace-scoped operations =============
/** List all datatables configured in the workspace, with their schema/table names. */
export async function listDatatables(workspace: string): Promise<DataTableTables[]> {
return await WorkspaceService.listDataTableTables({ workspace })
/** List the datatables configured in the workspace, with their schema/table names: all of them as
* their default role, or only `datatableName`, as `role` when one is given. */
export async function listDatatables(
workspace: string,
datatableName?: string,
role?: string
): Promise<DataTableTables[]> {
if (datatableName === undefined) return await WorkspaceService.listDataTableTables({ workspace })
return await WorkspaceService.listDataTableTables({
workspace,
datatableName,
...(role !== undefined && { roleFor: datatableName, role })
})
}
/** Get the columns (column_name -> compact_type) of one datatable table. */
@@ -41,13 +52,15 @@ export async function getDatatableColumns(
workspace: string,
datatableName: string,
schemaName: string,
tableName: string
tableName: string,
role?: string
): Promise<Record<string, string>> {
const schema = await WorkspaceService.getDataTableTableSchema({
workspace,
datatableName,
schemaName,
tableName
tableName,
role
})
return schema.columns
}
@@ -81,7 +94,26 @@ const NO_DATATABLES_CONFIGURED_MESSAGE =
// ============= Tool definitions =============
const getListDatatablesSchema = memo(() => z.object({}))
// The same rule the server applies to `-- role <name>`; a name it would refuse fails here instead.
const getRoleSchema = memo(() =>
z
.string()
.regex(/^[A-Za-z0-9_-]{1,63}$/)
.optional()
.describe(
"The datatable role to connect as, when the code you are working on uses one (an app's `data.roles` entry, or the `role` it passes to wmill.datatable). Omit for the datatable's default role."
)
)
const getListDatatablesSchema = memo(() =>
z.object({
datatable_name: z
.string()
.optional()
.describe('List only this datatable. Required with `role`.'),
role: getRoleSchema()
})
)
const getListDatatablesToolDef = memo(() =>
createToolDef(
getListDatatablesSchema(),
@@ -94,7 +126,8 @@ const getGetDatatableTableSchemaSchema = memo(() =>
z.object({
datatable_name: z.string().describe('The datatable name to inspect, e.g. "main".'),
schema_name: z.string().describe('The schema name, e.g. "public".'),
table_name: z.string().describe('The table name to inspect.')
table_name: z.string().describe('The table name to inspect.'),
role: getRoleSchema()
})
)
const getGetDatatableTableSchemaToolDef = memo(() =>
@@ -117,6 +150,7 @@ const getExecDatatableSqlSchema = memo(() =>
.describe(
'The SQL query to execute. Supports SELECT, INSERT, UPDATE, DELETE, CREATE TABLE, ALTER TABLE, DROP TABLE, etc. For SELECT queries, results are returned as an array of objects. A newly created table will appear in list_datatables automatically.'
),
role: getRoleSchema(),
background: z
.boolean()
.optional()
@@ -217,10 +251,18 @@ export function getDatatableTools(): Tool<{}>[] {
{
def: getListDatatablesToolDef(),
planModeSafe: true,
fn: async ({ workspace, toolId, toolCallbacks }) => {
fn: async ({ args, workspace, toolId, toolCallbacks }) => {
toolCallbacks.setToolStatus(toolId, { content: 'Listing datatables...' })
try {
const metadata = await listDatatables(workspace)
const parsedArgs = getListDatatablesSchema().parse(args ?? {})
if (parsedArgs.role !== undefined && parsedArgs.datatable_name === undefined) {
throw new Error('`role` needs `datatable_name`, the datatable it is a role of')
}
const metadata = await listDatatables(
workspace,
parsedArgs.datatable_name,
parsedArgs.role
)
if (metadata.length === 0) {
toolCallbacks.setToolStatus(toolId, {
content: 'No datatables configured — set one up in workspace settings'
@@ -236,7 +278,18 @@ export function getDatatableTools(): Tool<{}>[] {
toolCallbacks.setToolStatus(toolId, {
content: `Listed ${metadata.length} datatable(s) with ${totalTables} table(s)`
})
return JSON.stringify(metadata, null, 2)
// Only what the model acts on: the roles it may pass, not the creation privileges
// the manager's UI gates on.
return JSON.stringify(
metadata.map((d) => ({
datatable_name: d.datatable_name,
schemas: d.schemas,
...(d.error && { error: d.error }),
...(d.permissioned && { usable_roles: d.usable_roles, default_role: d.default_role })
})),
null,
2
)
} catch (e) {
const errorMsg = `Error listing datatables: ${e instanceof Error ? e.message : String(e)}`
toolCallbacks.setToolStatus(toolId, { content: errorMsg, error: errorMsg })
@@ -257,7 +310,8 @@ export function getDatatableTools(): Tool<{}>[] {
workspace,
parsedArgs.datatable_name,
parsedArgs.schema_name,
parsedArgs.table_name
parsedArgs.table_name,
parsedArgs.role
)
toolCallbacks.setToolStatus(toolId, {
content: `Retrieved schema for ${parsedArgs.schema_name}.${parsedArgs.table_name}`
@@ -300,7 +354,7 @@ export function getDatatableTools(): Tool<{}>[] {
requestBody: {
language: 'postgresql',
content: parsedArgs.sql,
args: { database: `datatable://${name}` }
args: { database: datatableReference(name, parsedArgs.role) }
}
}),
workspace,
@@ -1411,7 +1411,12 @@ Data Tables:
- Datatables are workspace-scoped managed PostgreSQL databases, shared across the workspace (not owned by any single app). They must be configured by the user in their workspace settings (Workspace settings Data Tables); they cannot be created via SQL.
- Use list_datatables to discover the available datatables and their tables. Reuse an existing table rather than creating a duplicate. If list_datatables reports none, this is a blocking prerequisite tell the user to set up a datatable in their workspace settings and stop; do not assume a "main" datatable exists or call exec_datatable_sql.
- Use get_datatable_table_schema only when you need a table's column names/types; list_datatables is enough for table-list or availability summaries.
- Use exec_datatable_sql to explore data, run queries, mutate rows, or change schema (CREATE/ALTER/DROP). Creating a table is a normal CREATE TABLE statement it appears in list_datatables afterward, with no registration step.
- Use exec_datatable_sql to explore data, run queries, mutate rows, or change schema (CREATE/ALTER/DROP). Creating a table is a normal CREATE TABLE statement it appears in list_datatables afterward, with no registration step.${
isCloudHosted()
? ''
: `
- A raw app may use a datatable through a role (\`data.roles\` in its raw_app.yaml). When working on such an app, pass that role to the datatable tools, and to wmill.datatable in its runnables, so you see and change only what the app itself can.`
}
- When writing runnable code (inline app runnables, scripts, flow modules) that reads or writes datatable data at runtime, it accesses a datatable via wmill.datatable(). Default to TypeScript (bun) unless the user asked for another language. Call get_instructions with subject "datatable" and language "bun" for the TypeScript SQL SDK reference (or language "python3" for Python) it returns only that language so you get just what you need.${
skills.length > 0
? `
@@ -1,50 +0,0 @@
<script lang="ts">
import Select from '../select/Select.svelte'
let {
schemas,
tables,
schemasLoading = false,
schema = $bindable(),
table = $bindable()
}: {
/** The database's schemas, as the editor last read them. */
schemas: string[]
/** The picked schema's tables, as the editor last read them. */
tables: string[]
/** The editor has not read the database yet, so `schemas` is not known to be empty. */
schemasLoading?: boolean
/** Unset for the database itself. */
schema?: string
/** Unset for the whole schema. */
table?: string
} = $props()
</script>
<div class="flex flex-wrap items-center gap-2">
<Select
items={schemas.map((s) => ({ value: s, label: s }))}
bind:value={
() => schema,
(s) => {
schema = s
table = undefined
}
}
placeholder="The database itself"
clearable
loading={schemasLoading}
size="sm"
class="w-56"
/>
{#if schema}
<Select
items={tables.map((t) => ({ value: t, label: t }))}
bind:value={table}
placeholder="The whole schema"
clearable
size="sm"
class="w-56"
/>
{/if}
</div>
@@ -25,30 +25,24 @@
let {
workspace,
datatable,
target,
onLoaded
target
}: {
workspace: string
datatable: string
/** What owner and grants are read and written for. */
target: AclTarget
/** Each read, with the target it was made for: it also lists what the target holds. */
onLoaded?: (target: AclTarget, info: DatatableAclInfo) => void
} = $props()
const acl = resource(
() => [workspace, datatable, target] as const,
async ([ws, dt, t]) => {
const loaded = await WorkspaceService.getDatatableAcl({
async ([ws, dt, t]) =>
await WorkspaceService.getDatatableAcl({
workspace: ws,
datatableName: dt,
kind: t.kind,
schema: t.kind === 'database' ? undefined : t.schema,
table: t.kind === 'table' ? t.table : undefined
})
onLoaded?.(t, loaded)
return loaded
}
)
// Nothing is written before its SQL has been shown, and the apply runs exactly that SQL: the
@@ -0,0 +1,60 @@
import { describe, test, expect } from 'vitest'
import { parseMigrationRole, withMigrationRole } from './datatableMigrationRole'
describe('parseMigrationRole', () => {
test('reads every spelling the server accepts from the leading comment block', () => {
for (const line of [
'-- role analyst',
'-- Role: analyst',
'-- role=analyst',
'-- role analyst;'
]) {
expect(parseMigrationRole(`\n${line}\nBEGIN;\nEND;`)).toEqual({
kind: 'role',
role: 'analyst'
})
}
})
test('an annotation below BEGIN is not one', () => {
expect(parseMigrationRole('BEGIN;\n-- role analyst\nEND;')).toEqual({ kind: 'none' })
})
test('a malformed attempt is an error, not the default', () => {
for (const line of [
'-- role based access below',
'-- role',
'-- role:',
'-- role an;alytics'
]) {
expect(parseMigrationRole(`${line}\nBEGIN;`)).toEqual({ kind: 'malformed', line })
}
})
test('comments that do not start with the word role are ignored', () => {
expect(parseMigrationRole('-- roles analyst\n-- rolex\nBEGIN;')).toEqual({ kind: 'none' })
})
})
describe('withMigrationRole', () => {
test('leads above BEGIN, so the server reads it', () => {
const out = withMigrationRole('BEGIN;\nSELECT 1;\nEND;', 'analyst')
expect(out).toBe('-- role analyst\nBEGIN;\nSELECT 1;\nEND;')
})
test('replaces any attempt rather than stacking, malformed ones included', () => {
const out = withMigrationRole(
'-- Role: auditor\n-- role oops no\n-- keep me\nBEGIN;',
'analyst'
)
expect(out).toBe('-- role analyst\n-- keep me\nBEGIN;')
})
test('undefined strips the annotation, so it runs as admin', () => {
expect(withMigrationRole('-- role analyst\n\nBEGIN;\nEND;', undefined)).toBe('BEGIN;\nEND;')
})
test('refuses a name the server would refuse', () => {
expect(() => withMigrationRole('BEGIN;', 'bad;name')).toThrow()
})
})
@@ -0,0 +1,70 @@
import { isDatatableRoleName } from './dbTypes'
/**
* A migration carries the data table role it runs as in its own SQL, as a `-- role <name>`
* annotation. There is no separate field: the annotation is what the server reads, and keeping
* it in the SQL is what lets it survive a `wmill sync` round-trip.
*
* Mirrors `SqlAnnotations::datatable_role` on the backend. It is only read from the leading
* comment block, so an annotation below `BEGIN;` is ignored and the migration runs as admin. A
* leading comment whose first word is `role` is an annotation attempt, and a malformed one is an
* error there, so it is one here too.
*/
export type MigrationRole =
| { kind: 'none' }
| { kind: 'role'; role: string }
| { kind: 'malformed'; line: string }
/** The body of a leading comment line that attempts a role annotation, or undefined. */
function roleAttempt(line: string): string | undefined {
if (!line.startsWith('--')) return undefined
const body = line.slice(2).trimStart()
if (body.slice(0, 4).toLowerCase() !== 'role') return undefined
const after = body.slice(4)
if (after !== '' && !/^[\s:=]/.test(after)) return undefined
return after
}
function parseAttempt(after: string): string | undefined {
let rest = after.trimStart()
if (rest.startsWith(':') || rest.startsWith('=')) rest = rest.slice(1)
const tokens = rest.split(/\s+/).filter((t) => t !== '')
if (tokens.length !== 1) return undefined
const role = tokens[0].endsWith(';') ? tokens[0].slice(0, -1) : tokens[0]
return isDatatableRoleName(role) ? role : undefined
}
export function parseMigrationRole(sql: string): MigrationRole {
for (const raw of sql.split('\n')) {
const line = raw.trim()
if (line === '') continue
if (!line.startsWith('--')) break
const after = roleAttempt(line)
if (after === undefined) continue
const role = parseAttempt(after)
return role === undefined ? { kind: 'malformed', line } : { kind: 'role', role }
}
return { kind: 'none' }
}
/**
* `sql` declaring `role`: any role annotation attempt in the leading comment block is removed,
* and `-- role <role>` is prepended above everything, or nothing when `role` is undefined.
*/
export function withMigrationRole(sql: string, role: string | undefined): string {
if (role !== undefined && !isDatatableRoleName(role)) {
throw new Error(`Invalid data table role '${role}'`)
}
const lines = sql.split('\n')
const kept: string[] = []
let i = 0
for (; i < lines.length; i++) {
const line = lines[i].trim()
if (line !== '' && !line.startsWith('--')) break
if (roleAttempt(line) === undefined) kept.push(lines[i])
}
const rest = [...kept, ...lines.slice(i)]
while (rest.length > 0 && rest[0].trim() === '') rest.shift()
return role === undefined ? rest.join('\n') : [`-- role ${role}`, ...rest].join('\n')
}
@@ -0,0 +1,34 @@
import { WorkspaceService, type ListUsableDatatableRolesResponse } from '$lib/gen'
import { isCloudHosted } from '$lib/cloud'
import { ADMIN_DATATABLE_ROLE } from './dbTypes'
// `datatable_roles_unavailable` on the server, which is a plain 400: rewording it there without
// here makes every role picker on a non-Enterprise build fail instead of reading "not under roles".
const ROLES_UNAVAILABLE = 'Data table roles are a Windmill Enterprise Edition feature'
const NOT_UNDER_ROLES: ListUsableDatatableRolesResponse = {
permissioned: false,
roles: [],
default_role: ADMIN_DATATABLE_ROLE
}
/**
* The roles the caller may connect as on a data table. Cloud has no instance database, so no data
* table there is under roles, and none of the role pickers show. Without the Enterprise Edition
* every roles route refuses, which reads the same way: the data table is then used the way it was
* before roles, and one that is under roles is refused when something connects to it.
*/
export async function listUsableDatatableRoles(
workspace: string,
datatableName: string
): Promise<ListUsableDatatableRolesResponse> {
if (isCloudHosted()) return NOT_UNDER_ROLES
try {
return await WorkspaceService.listUsableDatatableRoles({ workspace, datatableName })
} catch (e) {
const body = (e as { body?: unknown })?.body
const detail = `${typeof body === 'string' ? body : JSON.stringify(body ?? '')} ${(e as Error)?.message ?? e}`
if (detail.includes(ROLES_UNAVAILABLE)) return NOT_UNDER_ROLES
throw e
}
}
@@ -5,7 +5,7 @@ import { isDbType } from './dbTypes'
/**
* Single URL param `dbm` encodes the full DB manager state:
* firstSegment~path~schema.table
* firstSegment~path~schema.table~role=name
*
* firstSegment:
* datatable database with datatable:// resource (resourceType always postgresql)
@@ -26,28 +26,46 @@ import { isDbType } from './dbTypes'
* 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()
})
interface ParsedDbm {
export interface ParsedDbm {
type: 'database' | 'datatable' | 'ducklake'
path: string
resType?: string
schema?: string
table?: string
role?: string
}
function parseDbm(raw: unknown): ParsedDbm | null {
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 schemaTable = parts[2] ?? ''
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
@@ -81,12 +99,12 @@ function parseDbm(raw: unknown): ParsedDbm | null {
schema = defaultSchemas[type]
}
return { type, path, resType, schema, table }
return { type, path, resType, schema, table, role: type === 'datatable' ? role : undefined }
}
const defaultSchemas: Record<string, string> = { datatable: 'public', ducklake: 'main' }
function buildDbm(p: ParsedDbm): string {
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 = ''
@@ -97,7 +115,12 @@ function buildDbm(p: ParsedDbm): string {
} else if (schema) {
schemaTable = `${schema}.`
}
return schemaTable ? `${firstSeg}~${p.path}~${schemaTable}` : `${firstSeg}~${p.path}`
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 {
@@ -105,6 +128,8 @@ export interface DbManagerUriState {
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
@@ -137,6 +162,7 @@ export function useDbManagerUriState(): DbManagerUriState {
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
}
@@ -163,6 +189,7 @@ export function useDbManagerUriState(): DbManagerUriState {
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
})
@@ -194,7 +221,14 @@ export function useDbManagerUriState(): DbManagerUriState {
return parsed?.type === 'datatable' ? parsed.path : undefined
},
set selectedDatatable(v: string | undefined) {
if (v) updateField({ path: v })
// 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
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { buildDbm, parseDbm } from './dbManagerDrawerModel.svelte'
import { schemaCacheKey } from './dbSchemaCache'
import { datatableReference, type DbInput } from './dbTypes'
describe('dbm role segment', () => {
it('round-trips a role, with and without a table', () => {
for (const dbm of ['datatable~main~.orders~role=p4_analytics', 'datatable~main~role=p4-op']) {
expect(buildDbm(parseDbm(dbm)!)).toBe(dbm)
}
expect(parseDbm('datatable~main~sales.orders~role=analyst')).toMatchObject({
path: 'main',
schema: 'sales',
table: 'orders',
role: 'analyst'
})
})
it('reads a link without a role as the default role', () => {
const parsed = parseDbm('datatable~main~.orders')!
expect(parsed.role).toBeUndefined()
expect(parsed).toMatchObject({ schema: 'public', table: 'orders' })
expect(buildDbm(parsed)).toBe('datatable~main~.orders')
})
it('keeps an invalid role as written, so the connection refuses it', () => {
expect(parseDbm('datatable~main~role=a;b')?.role).toBe('a;b')
// A dot does not turn it into a schema.table selection read as the default role.
expect(parseDbm('datatable~main~role=bad.name')).toMatchObject({
role: 'bad.name',
schema: undefined,
table: undefined
})
})
})
describe('connecting as a role', () => {
const input = (role?: string): DbInput => ({
type: 'database',
resourceType: 'postgresql',
resourcePath: 'datatable://main',
role
})
// What `getDatabaseArg` builds every DB manager connection from.
it('appends the role to the data table reference', () => {
expect(datatableReference('main', 'p4_analytics')).toBe('datatable://main?role=p4_analytics')
expect(datatableReference('main', undefined)).toBe('datatable://main')
})
it('refuses a role name the server would not accept', () => {
expect(() => datatableReference('main', 'a&role=admin')).toThrow(/Invalid data table role/)
expect(() => datatableReference('main', '')).toThrow(/Invalid data table role/)
})
it('keys the schema cache by role', () => {
expect(schemaCacheKey('ws', input('a'))).not.toBe(schemaCacheKey('ws', input('b')))
expect(schemaCacheKey('ws', input('a'))).not.toBe(schemaCacheKey('ws', input()))
})
})
+32 -14
View File
@@ -8,7 +8,8 @@ import { runScriptAndPollResult } from './jobs/utils'
import { writingJobOptions } from './jobs/writingJob'
import type { DBSchema, SQLSchema } from '$lib/stores'
import { stringifySchema } from './copilot/lib'
import type { DbInput, DbType } from './dbTypes'
import { datatableReference, type DbInput, type DbType } from './dbTypes'
import { withMigrationRole } from './datatableMigrationRole'
import { assert } from '$lib/utils'
import { WorkspaceService } from '$lib/gen'
import { pendingMigrations } from './workspaceSettings/datatableMigrationUtils'
@@ -70,7 +71,9 @@ export function dbTableOpsWithPreviewScripts({
}): IDbTableOps {
const dbType = getDbType(input)
const language = getLanguageByResourceType(dbType)
const dbArg = getDatabaseArg(input)
// Built per call: an invalid role throws there, as that operation's error, rather than while
// the manager renders.
const dbArg = () => getDatabaseArg(input)
const ducklake = input.type === 'ducklake' ? input.ducklake : undefined
function makeMarker(op: string, payload: Record<string, unknown>): string {
@@ -91,7 +94,7 @@ export function dbTableOpsWithPreviewScripts({
})
const result = await runScriptAndPollResult({
workspace,
requestBody: { args: { ...dbArg, quicksearch }, language, content, tag }
requestBody: { args: { ...dbArg(), quicksearch }, language, content, tag }
})
const count = result?.[0].count as number
return count
@@ -106,7 +109,7 @@ export function dbTableOpsWithPreviewScripts({
})
let items = (await runScriptAndPollResult({
workspace,
requestBody: { args: { ...dbArg, ...params }, language, content, tag }
requestBody: { args: { ...dbArg(), ...params }, language, content, tag }
})) as unknown[]
if (!items || !Array.isArray(items)) {
throw 'items is not an array'
@@ -123,7 +126,7 @@ export function dbTableOpsWithPreviewScripts({
{
workspace,
requestBody: {
args: { ...dbArg, value_to_update: newValue, ...values },
args: { ...dbArg(), value_to_update: newValue, ...values },
language,
content,
tag
@@ -135,14 +138,14 @@ export function dbTableOpsWithPreviewScripts({
onDelete: async ({ values }) => {
const content = makeMarker('DELETE', { table: tableKey, columns: colDefs })
await runScriptAndPollResult(
{ workspace, requestBody: { args: { ...dbArg, ...values }, language, content, tag } },
{ workspace, requestBody: { args: { ...dbArg(), ...values }, language, content, tag } },
writingJobOptions
)
},
onInsert: async ({ values }) => {
const content = makeMarker('INSERT', { table: tableKey, columns: colDefs })
await runScriptAndPollResult(
{ workspace, requestBody: { args: { ...dbArg, ...values }, language, content, tag } },
{ workspace, requestBody: { args: { ...dbArg(), ...values }, language, content, tag } },
writingJobOptions
)
}
@@ -246,6 +249,7 @@ export type IDbSchemaOps = {
previewAlterSql: (params: { values: AlterTableValues; schema?: string }) => Promise<string>
onCreateSchema: (params: { schema: string }) => Promise<void>
onDeleteSchema: (params: { schema: string }) => Promise<void>
onRenameSchema: (params: { schema: string; newSchema: string }) => Promise<void>
onFetchTableEditorDefinition: (params: {
table: string
schema?: string
@@ -283,7 +287,8 @@ export function dbSchemaOpsWithPreviewScripts({
tag?: string
}): IDbSchemaOps {
const dbType = getDbType(input)
const dbArg = getDatabaseArg(input)
// Built per call, for the same reason as in the table ops above.
const dbArg = () => getDatabaseArg(input)
const language = getLanguageByResourceType(dbType)
const ducklake = input.type === 'ducklake' ? input.ducklake : undefined
@@ -293,6 +298,8 @@ export function dbSchemaOpsWithPreviewScripts({
input.type === 'database' && input.resourcePath.startsWith('datatable://')
? input.resourcePath.slice('datatable://'.length)
: undefined
// A migration declaring no role runs as admin, whatever role the manager connects as.
const migrationRole = input.type === 'database' ? input.role : undefined
function makeMarker(op: string, payload: Record<string, unknown>): string {
if (ducklake) payload.ducklake = ducklake
@@ -359,7 +366,7 @@ export function dbSchemaOpsWithPreviewScripts({
: undefined
if (!datatableName || !status?.enabled) {
await runScriptAndPollResult(
{ workspace, requestBody: { args: dbArg, content, language, tag } },
{ workspace, requestBody: { args: dbArg(), content, language, tag } },
writingJobOptions
)
return
@@ -373,12 +380,16 @@ export function dbSchemaOpsWithPreviewScripts({
throw new MigrationRunCancelled()
}
}
const codeUp = wrapMigration(await expandMarker(workspace, language, content))
// Wrapped before annotating: the annotation must lead, above `BEGIN;`.
const codeUp = withMigrationRole(
wrapMigration(await expandMarker(workspace, language, content)),
migrationRole
)
// Down migrations are only generated for Postgres for now.
let codeDown: string | undefined
if (downContent && dbType === 'postgresql') {
const downSql = (await expandMarker(workspace, language, downContent)).trim()
if (downSql) codeDown = wrapMigration(downSql)
if (downSql) codeDown = withMigrationRole(wrapMigration(downSql), migrationRole)
}
const created = await WorkspaceService.createDatatableMigration({
workspace,
@@ -415,7 +426,7 @@ export function dbSchemaOpsWithPreviewScripts({
const fkContent = makeMarker('FOREIGN_KEYS', { table, schema })
const fkResult = await runScriptAndPollResult({
workspace,
requestBody: { args: dbArg, content: fkContent, language, tag }
requestBody: { args: dbArg(), content: fkContent, language, tag }
})
let rawForeignKeys: RawForeignKey[]
@@ -501,6 +512,11 @@ export function dbSchemaOpsWithPreviewScripts({
const downContent = makeMarker('CREATE_SCHEMA', { schema })
await applyDdl(migrationName('drop_schema', schema), content, downContent)
},
onRenameSchema: async ({ schema, newSchema }) => {
const content = makeMarker('RENAME_SCHEMA', { schema, new_schema: newSchema })
const downContent = makeMarker('RENAME_SCHEMA', { schema: newSchema, new_schema: schema })
await applyDdl(migrationName('rename_schema', schema), content, downContent)
},
onFetchForeignKeys: fetchForeignKeys,
onFetchTableEditorDefinition: async ({ table, schema, colDefs }) => {
const foreignKeys = await fetchForeignKeys({ table, schema })
@@ -512,7 +528,7 @@ export function dbSchemaOpsWithPreviewScripts({
const pkContent = makeMarker('PRIMARY_KEY_CONSTRAINT', { table, schema })
const pkResult = (await runScriptAndPollResult({
workspace,
requestBody: { args: dbArg, content: pkContent, language, tag }
requestBody: { args: dbArg(), content: pkContent, language, tag }
})) as { constraint_name?: string; CONSTRAINT_NAME?: string }[]
if (pkResult && Array.isArray(pkResult) && pkResult.length > 0) {
@@ -611,7 +627,9 @@ export function getDefaultDbTag(input: DbInput): string {
export function getDatabaseArg(input: DbInput | undefined) {
if (input?.type === 'database') {
if (input.resourcePath.startsWith('datatable://')) {
return { database: input.resourcePath }
return {
database: datatableReference(input.resourcePath.slice('datatable://'.length), input.role)
}
} else {
return { database: '$res:' + input.resourcePath }
}
@@ -0,0 +1,21 @@
import type { DbInput } from './dbTypes'
/** What identifies a database's schema, role included: two roles on one data table may reach
* different schemas, so they cannot share a cache entry. Never throws, since it keys derived
* state; the connection itself is what refuses an invalid role. */
export function getDbSchemasPath(input: DbInput): string {
switch (input.type) {
case 'database':
return input.role !== undefined && input.resourcePath.startsWith('datatable://')
? `${input.resourcePath}?role=${input.role}`
: input.resourcePath
case 'ducklake':
return 'ducklake://' + input.ducklake
}
}
/** Scoped by the acting workspace: a data table of the same name can exist in both the nav and
* the acting workspace, and one's schema must not be reused for the other. */
export function schemaCacheKey(workspace: string | undefined, input: DbInput): string {
return `${workspace}:${getDbSchemasPath(input)}`
}
+25
View File
@@ -3,6 +3,9 @@ export type DbInput =
type: 'database'
resourceType: DbType
resourcePath: string
/** The data table role to connect as; the data table's default when unset. Only
* meaningful for a `datatable://` path. */
role?: string
specificSchema?: string
specificTable?: string
}
@@ -23,3 +26,25 @@ export const dbTypes = [
'duckdb'
] as const
export const isDbType = (str?: string): str is DbType => !!str && dbTypes.includes(str as DbType)
/** The role every data table has: the one it connects as when it is not under roles. */
export const ADMIN_DATATABLE_ROLE = 'admin'
/** What the server accepts in `-- role <name>` and `?role=<name>`. */
export function isDatatableRoleName(name: string): boolean {
return /^[A-Za-z0-9_-]{1,63}$/.test(name)
}
/** `datatable://<name>`, with `?role=<role>` when a role is named. Throws rather than build a
* reference the executor would refuse, or one that would silently mean another role. */
export function datatableReference(name: string, role: string | undefined): string {
if (role === undefined) return `datatable://${name}`
if (!isDatatableRoleName(role)) {
throw new Error(
`Invalid data table role '${role}': only letters, digits, '_' and '-' are allowed`
)
}
return `datatable://${name}?role=${role}`
}
export type DatatableRowAction = 'migrations' | 'roles' | 'export' | 'import'
@@ -4,13 +4,14 @@
import Select from '$lib/components/select/Select.svelte'
import { workspaceStore } from '$lib/stores'
import {
createDatatableAccessResource,
createDatatablesResource,
createSchemasResource,
toDatatableItems,
toSchemaItems
} from './datatableUtils.svelte'
import { Button } from '../common'
import { getRawAppOperatingWorkspace } from './rawAppWorkspace'
import { appDatatableRole } from './dataTableRefUtils'
const getOpWs = getRawAppOperatingWorkspace()
let opWs = $derived(getOpWs?.() ?? $workspaceStore)
@@ -20,6 +21,8 @@
datatable: string | undefined
/** Currently selected schema */
schema: string | undefined
/** The role the app uses each data table through: schemas are listed as that role. */
roles?: Record<string, string>
/** Callback when either value changes */
onChange?: (datatable: string | undefined, schema: string | undefined) => void
/** Description text to show in the popover */
@@ -29,19 +32,28 @@
let {
datatable,
schema,
roles,
onChange,
description = 'Set the default datatable and schema for new tables. This is where AI will create new tables when needed.'
}: Props = $props()
const role = $derived(datatable ? appDatatableRole(roles, datatable) : undefined)
// Load available datatables and schemas using shared utilities
const datatables = createDatatablesResource(() => opWs)
const schemas = createSchemasResource(
const access = createDatatableAccessResource(
() => datatable,
() => role,
() => opWs
)
const datatableItems = $derived(toDatatableItems(datatables.current))
const schemaItems = $derived(toSchemaItems(schemas.current))
// Until the answer is for this data table and role, the schemas in hand belong to another.
const schemaItems = $derived(
access.current.datatable === datatable && access.current.role === role
? toSchemaItems(access.current.schemas)
: []
)
// Track datatable changes to reset schema
let previousDatatable = $state<string | undefined>(undefined)
@@ -82,6 +94,9 @@
placeholder="Select database"
size="sm"
/>
{#if role}
<span class="text-2xs text-tertiary">Used as role {role}</span>
{/if}
</div>
<div class="flex flex-col gap-1">
@@ -1,35 +1,46 @@
<script lang="ts">
import { workspaceStore } from '$lib/stores'
import { WorkspaceService } from '$lib/gen'
import { listUsableDatatableRoles } from '../datatableUsableRoles'
import Drawer from '../common/drawer/Drawer.svelte'
import DrawerContent from '../common/drawer/DrawerContent.svelte'
import Button from '../common/button/Button.svelte'
import Select from '../select/Select.svelte'
import { sendUserToast } from '$lib/toast'
import type { DataTableRef } from './dataTableRefUtils'
import { appDatatableRole, type DataTableRef } from './dataTableRefUtils'
import { untrack } from 'svelte'
import { resource } from 'runed'
import { ArrowLeft, Expand, LoaderCircle, Minimize, Plus, RefreshCcw } from 'lucide-svelte'
import { ArrowLeft, Expand, Minimize, Plus, RefreshCcw } from 'lucide-svelte'
import DBManagerContent from '../DBManagerContent.svelte'
import type { DbInput } from '../dbTypes'
import type { SelectedTable } from '../DBManager.svelte'
import { ADMIN_DATATABLE_ROLE, type DbInput } from '../dbTypes'
import type { PendingRowAction, SelectedTable } from '../DBManager.svelte'
import { getRawAppOperatingWorkspace } from './rawAppWorkspace'
import { useDbManagerTag } from '../dbManagerTag.svelte'
import DbWorkerTagButton from '../DbWorkerTagButton.svelte'
import type { DataTableTables } from '$lib/gen'
const getOpWs = getRawAppOperatingWorkspace()
let opWs = $derived(getOpWs?.() ?? $workspaceStore)
interface Props {
onAdd?: (ref: DataTableRef) => void
/** `roles` holds, for each added table's data table under roles, the role its tables were
* browsed as: the app uses the data table through it from then on. `roleChanged` names the
* data tables the app now uses through another role than before (its stored role, or the
* data table's default when it stored none): their existing refs are replaced, since the new
* role may not reach them. */
onAdd?: (refs: DataTableRef[], roles: Record<string, string>, roleChanged: Set<string>) => void
existingRefs?: DataTableRef[]
/** The role the app uses each data table through, by data table name */
roles?: Record<string, string>
/** Z-index offset for the drawer, useful when opening from within modals */
offset?: number
}
let { onAdd, existingRefs = [], offset = 0 }: Props = $props()
let { onAdd, existingRefs = [], roles = undefined, offset = 0 }: Props = $props()
let open = $state(false)
let selectedDatatable = $state<string | undefined>(undefined)
/** Role the manager connects as; undefined means the data table's default. */
let selectedRole = $state<string | undefined>(undefined)
// For DB manager
let dbManagerContent: DBManagerContent | undefined = $state()
@@ -39,10 +50,19 @@
// Multi-select mode: selected tables
let selectedTables = $state<SelectedTable[]>([])
/** The role each data table's selected tables were browsed as. */
let browsedRoles = $state<Record<string, string>>({})
// Survives the re-mount a data table switch causes.
let pendingAction = $state<PendingRowAction | undefined>(undefined)
// Selected schema/table from DBManager (for preview)
let selectedSchemaKey = $state<string | undefined>(undefined)
let selectedTableKey = $state<string | undefined>(undefined)
// What the manager opens on, set only when it (re-)mounts: the live selection above changes
// on every click, and feeding it to the input would reload the whole manager each time.
let openSchemaKey = $state<string | undefined>(undefined)
let openTableKey = $state<string | undefined>(undefined)
// Load available datatables from workspace
const datatables = resource<string[]>([], async () => {
@@ -55,32 +75,173 @@
}
})
export function openDrawer() {
// Auto-select first datatable if only one exists
if (datatables.current.length === 1) {
selectedDatatable = datatables.current[0]
} else if (datatables.current.length > 1 && datatables.current.includes('main')) {
selectedDatatable = 'main'
} else {
selectedDatatable = undefined
const usableRoles = resource(
() => [open, opWs, selectedDatatable] as const,
async ([isOpen, workspace, datatable]) => {
if (!isOpen || !workspace || !datatable) return undefined
try {
return {
datatable,
...(await listUsableDatatableRoles(workspace, datatable))
}
} catch (e) {
// Opens anyway: without a role the server connects as the default and says so if
// that is refused.
console.error('Failed to load datatable roles:', e)
return {
datatable,
permissioned: false,
roles: [] as string[],
default_role: ADMIN_DATATABLE_ROLE
}
}
}
)
// A resource keeps its previous value while it refetches, and roles are per data table.
const rolesOfCurrent = $derived(
usableRoles.current?.datatable === selectedDatatable ? usableRoles.current : undefined
)
// Mounting the manager fires its first queries, so it waits for the role: a round sent
// without one runs, and caches, as whatever the server defaults to.
const roleSettled = $derived(
selectedDatatable === undefined ||
(rolesOfCurrent !== undefined &&
(!rolesOfCurrent.permissioned ||
rolesOfCurrent.roles.length === 0 ||
selectedRole !== undefined))
)
$effect(() => {
const current = rolesOfCurrent
if (!current?.permissioned || selectedRole !== undefined) return
const effective = current.roles.includes(current.default_role)
? current.default_role
: current.roles[0]
const datatable = selectedDatatable
if (effective && datatable) untrack(() => connectAs(datatable, effective))
})
// Every data table with its schemas and tables: the tree is the picker. The privileges it
// reports are the connected role's, so the role picked on the open data table is asked too.
// Waits for the role like the manager does, and drops an answer for a selection that has
// since changed: it would describe another role.
let datatableTreeRun = 0
const datatableTree = resource(
() => [open, opWs, selectedDatatable, selectedRole, roleSettled] as const,
async ([isOpen, workspace, roleFor, role, settled]): Promise<DataTableTables[]> => {
if (!isOpen || !workspace) return []
if (!settled) return untrack(() => datatableTree.current)
const run = ++datatableTreeRun
try {
const result = await WorkspaceService.listDataTableTables({
workspace,
roleFor: role ? roleFor : undefined,
role
})
return run === datatableTreeRun ? result : untrack(() => datatableTree.current)
} catch (e) {
console.error('Failed to load datatable tables:', e)
return run === datatableTreeRun ? [] : untrack(() => datatableTree.current)
}
},
{ initialValue: [] }
)
/** The role a table of `datatable` is seen through right now: the connected data table's
* picked role, or the default role the tree lists any other one as. */
function roleSeenFor(datatable: string): string | undefined {
if (datatable === selectedDatatable) {
return rolesOfCurrent?.permissioned ? selectedRole : undefined
}
const entry = datatableTree.current.find((t) => t.datatable_name === datatable)
return entry?.permissioned ? entry.default_role : undefined
}
function defaultRoleOf(datatable: string): string | undefined {
if (datatable === selectedDatatable && rolesOfCurrent?.permissioned) {
return rolesOfCurrent.default_role
}
const entry = datatableTree.current.find((t) => t.datatable_name === datatable)
return entry?.permissioned ? entry.default_role : undefined
}
const tableDatatable = (t: SelectedTable) => t.datatable ?? selectedDatatable
/** Stamps each newly selected table with the role it was seen through. A data table's
* selections all come from one role, since the app uses it through one: picking a table under
* another role drops the ones picked under the previous, which that role may not reach. */
function setSelectedTables(next: SelectedTable[]) {
const isNew = (t: SelectedTable) =>
!selectedTables.some(
(s) =>
tableDatatable(s) === tableDatatable(t) && s.schema === t.schema && s.table === t.table
)
const added = next.filter(isNew)
const nextRoles = { ...browsedRoles }
let kept = next
for (const table of added) {
const dt = tableDatatable(table)
if (!dt) continue
const role = roleSeenFor(dt)
if (role === undefined) continue
if (nextRoles[dt] !== undefined && nextRoles[dt] !== role) {
kept = kept.filter((s) => tableDatatable(s) !== dt || added.includes(s))
}
nextRoles[dt] = role
}
const stillSelected = new Set(kept.map(tableDatatable))
selectedTables = kept
browsedRoles = Object.fromEntries(
Object.entries(nextRoles).filter(([dt]) => stillSelected.has(dt))
)
}
function selectDatatable(datatable: string, role?: string) {
// A row clicked under another data table has just set the selection it should open on.
openSchemaKey = selectedSchemaKey
openTableKey = selectedTableKey
selectedDatatable = datatable
// A data table opens as the role its picked tables were browsed as, else the one the app
// already uses it through.
connectAs(datatable, role ?? browsedRoles[datatable] ?? appDatatableRole(roles, datatable))
}
/** Connects to `datatable` as `role`. The tables picked on it under another role are dropped:
* they would be saved under a role other than the one on screen. */
function connectAs(datatable: string, role: string | undefined) {
selectedRole = role
const browsed = browsedRoles[datatable]
if (browsed !== undefined && role !== undefined && role !== browsed) {
selectedTables = selectedTables.filter((t) => (t.datatable ?? datatable) !== datatable)
const { [datatable]: _, ...rest } = browsedRoles
browsedRoles = rest
}
}
// Cleared before a data table is selected: a pick left unadded when the drawer last closed
// would otherwise decide the role it reopens as.
function resetSelection() {
selectedTables = []
browsedRoles = {}
selectedRole = undefined
}
export function openDrawer() {
resetSelection()
selectedSchemaKey = undefined
selectedTableKey = undefined
selectedTables = []
selectDatatable(datatables.current.includes('main') ? 'main' : datatables.current[0])
expand = false
open = true
}
let initialTableKey: string | undefined = $state<string | undefined>(undefined)
let initialSchemaKey: string | undefined = $state<string | undefined>(undefined)
export function openDrawerWithRef(ref: DataTableRef) {
selectedDatatable = ref.datatable
resetSelection()
selectedSchemaKey = ref.schema
selectedTableKey = ref.table
initialTableKey = ref.table
initialSchemaKey = ref.schema
selectedTables = []
selectDatatable(ref.datatable)
expand = false
open = true
}
@@ -88,49 +249,53 @@
export function closeDrawer() {
open = false
dbManagerContent?.clearReplResult()
// An action outlives the data table it was asked for otherwise.
pendingAction = undefined
}
function handleAddTables() {
if (!selectedDatatable) {
sendUserToast('Please select a data table first', true)
return
}
if (selectedTables.length === 0) {
sendUserToast('Please select at least one table', true)
return
}
// Add all selected tables
const refs: DataTableRef[] = []
for (const table of selectedTables) {
const ref: DataTableRef = {
datatable: selectedDatatable,
schema: table.schema,
table: table.table
}
onAdd?.(ref)
const datatable = table.datatable ?? selectedDatatable
if (!datatable) continue
refs.push({ datatable, schema: table.schema, table: table.table })
}
const added = new Set(refs.map((r) => r.datatable))
const addedRoles = Object.fromEntries(
Object.entries(browsedRoles).filter(([dt]) => added.has(dt))
)
const roleChanged = new Set(
Object.entries(addedRoles)
.filter(([dt, role]) => {
const usedAs = appDatatableRole(roles, dt) ?? defaultRoleOf(dt)
return usedAs !== undefined && usedAs !== role
})
.map(([dt]) => dt)
)
onAdd?.(refs, addedRoles, roleChanged)
const count = selectedTables.length
const count = refs.length
sendUserToast(`Added ${count} table${count > 1 ? 's' : ''} to app`)
selectedTables = []
browsedRoles = {}
}
const datatableItems = $derived(
datatables.current.map((dt) => ({
value: dt,
label: dt
}))
)
// Carries the picked schema/table, so a click on a row of another data table lands on that
// table once the manager re-mounts against it.
const dbInput: DbInput | undefined = $derived(
selectedDatatable
? {
type: 'database' as const,
resourceType: 'postgresql' as const,
resourcePath: `datatable://${selectedDatatable}`,
specificSchema: initialSchemaKey,
specificTable: initialTableKey
role: selectedRole,
specificSchema: openSchemaKey,
specificTable: openTableKey
}
: undefined
)
@@ -141,15 +306,13 @@
}
})
// Convert existingRefs to disabledTables format for the current datatable
const disabledTables = $derived(
existingRefs
.filter((ref) => ref.datatable === selectedDatatable && ref.schema && ref.table)
.map((ref) => ({ schema: ref.schema!, table: ref.table! }))
.filter((ref) => ref.schema && ref.table)
.map((ref) => ({ datatable: ref.datatable, schema: ref.schema!, table: ref.table! }))
)
// Can add: has tables selected
const canAdd = $derived(selectedDatatable && selectedTables.length > 0)
const canAdd = $derived(selectedTables.length > 0)
// Shares the drawer-set override with the Database Manager: same data table,
// same worker group needed to reach it.
@@ -175,37 +338,27 @@
noPadding
>
{#if dbInput && opWs}
{#key selectedDatatable}
<DBManagerContent
bind:this={dbManagerContent}
input={dbInput}
workspace={opWs}
bind:workerTag={() => workerTag.tag, (v) => (workerTag.tag = v)}
bind:hasReplResult
bind:selectedSchemaKey
bind:selectedTableKey
multiSelectMode={true}
bind:selectedTables
{disabledTables}
>
{#snippet dbSelector()}
{#if datatables.loading}
<div class="flex items-center gap-2 text-tertiary ml-2">
<LoaderCircle size={14} class="animate-spin" />
<span class="text-sm">Loading...</span>
</div>
{:else if datatables.current.length >= 1}
<Select
transformInputSelectedText={(s) => `Datatable: ${s}`}
items={datatableItems}
bind:value={selectedDatatable}
placeholder="Select data table"
size="md"
/>
{/if}
{/snippet}
</DBManagerContent>
{/key}
{#if roleSettled}
{#key `${selectedDatatable}~${selectedRole ?? ''}`}
<DBManagerContent
bind:this={dbManagerContent}
input={dbInput}
workspace={opWs}
bind:workerTag={() => workerTag.tag, (v) => (workerTag.tag = v)}
bind:hasReplResult
bind:selectedSchemaKey
bind:selectedTableKey
multiSelectMode={true}
bind:selectedTables={() => selectedTables, setSelectedTables}
{disabledTables}
datatableTree={datatableTree.current}
datatableTreeLoading={datatableTree.loading}
onSelectDatatable={(dt) => selectDatatable(dt)}
onSelectRole={(dt, role) => selectDatatable(dt, role)}
bind:pendingAction
/>
{/key}
{/if}
{:else}
<div class="flex items-center justify-center h-full text-tertiary">
<span>Select a data table to explore</span>
@@ -214,12 +367,11 @@
{#snippet actions()}
<Button
variant="contained"
color="blue"
variant="accent"
disabled={!canAdd}
on:click={handleAddTables}
startIcon={{ icon: Plus }}
size="xs"
unifiedSize="sm"
>
{#if selectedTables.length > 0}
Add {selectedTables.length} table{selectedTables.length > 1 ? 's' : ''}
@@ -240,8 +392,8 @@
loading={dbManagerContent?.isLoading() ?? false}
on:click={() => dbManagerContent?.refresh()}
startIcon={{ icon: RefreshCcw }}
size="xs"
color="light"
unifiedSize="sm"
variant="default"
disabled={!selectedDatatable}
>
Refresh
@@ -250,8 +402,9 @@
<Button
on:click={() => (expand = !expand)}
startIcon={{ icon: expand ? Minimize : Expand }}
size="xs"
color="light"
unifiedSize="sm"
variant="default"
iconOnly
/>
{/snippet}
</DrawerContent>
@@ -16,6 +16,8 @@
defaultDatatable?: string | undefined
/** Default schema for new tables */
defaultSchema?: string | undefined
/** The role the app uses each data table through, by data table name */
roles?: Record<string, string>
onAdd?: () => void
onRemove?: (index: number) => void
onSelect?: (ref: DataTableRef, index: number) => void
@@ -31,6 +33,7 @@
dataTableRefs = [],
defaultDatatable = undefined,
defaultSchema = undefined,
roles = undefined,
onAdd,
onRemove,
onSelect,
@@ -95,6 +98,7 @@
<DefaultDatabaseSelector
datatable={defaultDatatable}
schema={defaultSchema}
{roles}
onChange={onDefaultChange}
/>
{/if}
@@ -124,6 +128,14 @@
<div class="flex items-center gap-1.5 px-1 py-1 text-2xs text-tertiary">
<Database size={12} class="shrink-0" />
<span class="font-medium truncate">{datatableName}</span>
{#if roles?.[datatableName]}
<span
class="truncate font-mono"
title="The app's queries on this data table run as this role"
>
as {roles[datatableName]}
</span>
{/if}
{#if isDefaultDatatable}
<span title="Default datatable">
<Star size={10} class="shrink-0 text-primary" />
@@ -70,8 +70,10 @@
formatDataTableRef,
isDatatableTableAllowed,
type RawAppData,
DEFAULT_DATA
DEFAULT_DATA,
appDatatableRole
} from './dataTableRefUtils'
import { datatableReference } from '../dbTypes'
import { randomUUID } from '$lib/utils/uuid'
interface Props {
@@ -706,11 +708,23 @@
runnables = update.runnables
}
if (update.data !== undefined) {
data = update.data
replaceData(update.data)
}
historyManager.manualSnapshot(files ?? {}, runnables, summary, data, true)
}
/** Replaces `data` from outside the editor (history, YAML). The policy sync writes the policy
* into `data`, so the policy takes the new values first or it puts the old ones straight back. */
function replaceData(next: RawAppData) {
data = next
aiChatManager.datatableCreationPolicy = {
...aiChatManager.datatableCreationPolicy,
datatable: next.datatable,
schema: next.schema,
roles: next.roles
}
}
let jobs: string[] = $state([])
let jobsById: Record<string, JobById> = $state({})
@@ -878,7 +892,8 @@
aiChatManager.datatableCreationPolicy = {
enabled: data.datatable !== undefined,
datatable: data.datatable,
schema: data.schema
schema: data.schema,
roles: data.roles
}
// Start auto-snapshot
@@ -900,9 +915,15 @@
// Read the current policy from aiChatManager
const policy = aiChatManager.datatableCreationPolicy
// Only update if different to avoid infinite loops
if (data.datatable !== policy.datatable || data.schema !== policy.schema) {
if (
data.datatable !== policy.datatable ||
data.schema !== policy.schema ||
// By value: the policy holds its own proxy of the same map.
JSON.stringify(data.roles) !== JSON.stringify(policy.roles)
) {
data.datatable = policy.datatable
data.schema = policy.schema
data.roles = policy.roles
}
})
@@ -1079,10 +1100,32 @@
return []
}
const tables = await WorkspaceService.listDataTableTables({
workspace: opWorkspace
// A data table the app uses through a role is listed as that role, so the AI sees
// what the app's own queries reach.
const workspace = opWorkspace
const tables = await WorkspaceService.listDataTableTables({ workspace })
// Only data tables that still exist: `data.roles` can outlive a removed or renamed one,
// and the server answers a `role_for` naming nothing with a 404.
const roled = Object.entries(data.roles ?? {}).filter(([dt]) =>
tables.some((t) => t.datatable_name === dt)
)
const roledTables = await Promise.all(
roled.map(([roleFor, role]) =>
WorkspaceService.listDataTableTables({
workspace,
datatableName: roleFor,
roleFor,
role
})
)
)
const merged = tables.map((entry) => {
const i = roled.findIndex(([dt]) => dt === entry.datatable_name)
return i === -1
? entry
: (roledTables[i].find((t) => t.datatable_name === entry.datatable_name) ?? entry)
})
return filterDatatableTables(tables)
return filterDatatableTables(merged)
},
getDatatableTableSchema: async (
datatableName: string,
@@ -1106,7 +1149,8 @@
workspace: opWorkspace,
datatableName,
schemaName,
tableName
tableName,
role: appDatatableRole(data.roles, datatableName)
})
return schema.columns
},
@@ -1124,13 +1168,15 @@
}
try {
// The same role the app's runnables use, so a table the AI creates belongs to it.
const role = appDatatableRole(data.roles, datatableName)
const result = await runScriptAndPollResult(
{
workspace: opWorkspace,
requestBody: {
language: 'postgresql',
content: sql,
args: { database: `datatable://${datatableName}` }
args: { database: datatableReference(datatableName, role) }
}
},
writingJobOptions
@@ -1150,6 +1196,12 @@
const resourcePath = `datatable://${datatableName}`
delete $dbSchemas[resourcePath]
delete $dbSchemas[`${opWorkspace}:${resourcePath}`]
// The DB manager keys its cache by the role it connected as too.
for (const key of Object.keys($dbSchemas)) {
if (key.startsWith(`${opWorkspace}:${resourcePath}?role=`)) {
delete $dbSchemas[key]
}
}
}
}
@@ -2137,7 +2189,7 @@
files = structuredClone($state.snapshot(entry.files))
runnables = structuredClone($state.snapshot(entry.runnables))
summary = entry.summary
data = structuredClone($state.snapshot(entry.data))
replaceData(structuredClone($state.snapshot(entry.data)))
// If the open document survives into the new files, use the combined message
if (iframeDocument && isOpenableDocument(iframeDocument)) {
@@ -2368,6 +2420,20 @@
schema
}
}}
datatableRoles={data.roles}
onDatatableRolesChange={(roles, roleChanged) => {
// The default schema was picked among what the previous role reaches: after the
// user moves the app's default data table to another role, it is picked again.
const dt = data.datatable
const schemaStale = dt !== undefined && roleChanged.has(dt)
data.roles = roles
if (schemaStale) data.schema = undefined
aiChatManager.datatableCreationPolicy = {
...aiChatManager.datatableCreationPolicy,
roles,
...(schemaStale && { schema: undefined })
}
}}
{runnables}
{modules}
{historyManager}
@@ -40,6 +40,13 @@
/** Default schema for new tables */
defaultSchema?: string | undefined
onDefaultChange?: (datatable: string | undefined, schema: string | undefined) => void
/** The role the app uses each data table through, by data table name */
datatableRoles?: Record<string, string>
/** `roleChanged` names the data tables now used through another role than before. */
onDatatableRolesChange?: (
roles: Record<string, string> | undefined,
roleChanged: Set<string>
) => void
}
let {
@@ -60,15 +67,27 @@
onDataTableRefsChange,
defaultDatatable = undefined,
defaultSchema = undefined,
onDefaultChange
onDefaultChange,
datatableRoles = undefined,
onDatatableRolesChange
}: Props = $props()
let dataTableDrawer: RawAppDataTableDrawer | undefined = $state()
let selectedDataTableIndex: number | undefined = $state(undefined)
let sharedUiDrawer: RawAppSharedUiDrawer | undefined = $state()
function handleAddDataTable(ref: DataTableRef) {
onDataTableRefsChange?.([...dataTableRefs, ref])
function handleAddDataTables(
refs: DataTableRef[],
browsedRoles: Record<string, string>,
roleChanged: Set<string>
) {
onDataTableRefsChange?.([
...dataTableRefs.filter((r) => !roleChanged.has(r.datatable)),
...refs
])
if (Object.keys(browsedRoles).length > 0) {
onDatatableRolesChange?.({ ...datatableRoles, ...browsedRoles }, roleChanged)
}
}
function handleRemoveDataTable(index: number) {
@@ -153,6 +172,7 @@
{dataTableRefs}
{defaultDatatable}
{defaultSchema}
roles={datatableRoles}
onAdd={() => dataTableDrawer?.openDrawer()}
onRemove={handleRemoveDataTable}
onSelect={handleSelectDataTable}
@@ -161,8 +181,9 @@
/>
<RawAppDataTableDrawer
bind:this={dataTableDrawer}
onAdd={handleAddDataTable}
onAdd={handleAddDataTables}
existingRefs={dataTableRefs}
roles={datatableRoles}
/>
<RawAppSharedUiDrawer bind:this={sharedUiDrawer} />
@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte'
import { Sparkles, Plus, List, Ban, ExternalLinkIcon, Loader2 } from 'lucide-svelte'
import type { Policy } from '$lib/gen'
import { superadmin, userStore, workspaceStore } from '$lib/stores'
@@ -19,10 +20,17 @@
import { loadCopilot } from '$lib/components/copilot/loadCopilot'
import { react18Template, react19Template, svelte5Template } from './templates'
import type { Runnable } from './rawAppPolicy'
import { type DataTableRef, type RawAppData, formatDataTableRef } from './dataTableRefUtils'
import {
type DataTableRef,
type RawAppData,
formatDataTableRef,
withAppDatatableRole
} from './dataTableRefUtils'
import {
createDatatableAccessResource,
createDatatablesResource,
createSchemasResource,
createRolesResource,
rolesWorthPicking,
toDatatableItems,
toSchemaItems
} from './datatableUtils.svelte'
@@ -62,19 +70,134 @@
let appSummary = $state('')
let initialPrompt = $state('')
let preWhitelistedTables = $state<DataTableRef[]>([])
/** The role each pre-whitelisted table's data table was browsed as. */
let preWhitelistedRoles = $state<Record<string, string>>({})
let dataTableDrawer: RawAppDataTableDrawer | undefined = $state()
const getOpWs = getRawAppOperatingWorkspace()
let opWs = $derived(getOpWs?.() ?? $workspaceStore)
const datatables = createDatatablesResource(() => opWs)
const schemas = createSchemasResource(
const roles = createRolesResource(
() => selectedDatatable,
() => opWs
)
let selectedRole = $state<string | undefined>(undefined)
// Every reader waits for an answer stamped with the current selection: until then `current`
// belongs to the previous data table or role.
const loadedRoles = $derived(
roles.current.datatable === selectedDatatable ? roles.current.roles : []
)
const showRolePicker = $derived(rolesWorthPicking(loadedRoles))
// Saved explicitly rather than left to resolve: "whatever the default is then" moves the app
// the day an admin changes the default.
const effectiveRole = $derived(
selectedRole !== undefined && loadedRoles.includes(selectedRole) ? selectedRole : undefined
)
// An app uses one role per data table: the one picked above is what the table drawer browses
// as and what the list shows, or tables would be added under a role the app is not saved with.
const pickerRoles = $derived(
selectedDatatable !== undefined && effectiveRole !== undefined
? withAppDatatableRole(preWhitelistedRoles, selectedDatatable, effectiveRole)
: preWhitelistedRoles
)
const availableDatatables = $derived(datatables.current)
const availableSchemas = $derived(schemas.current)
// `undefined` while the list loads, so this is false until it has answered.
const hasNoDatatables = $derived(availableDatatables?.length === 0)
const rolesSettled = $derived(
hasNoDatatables ||
(selectedDatatable !== undefined && roles.current.datatable === selectedDatatable)
)
// A role is picked on one data table: two data tables can both define an `analyst` that
// means something different, so a name surviving the switch is not the role surviving it.
let rolesPickedOn = $state<string | undefined>(undefined)
$effect(() => {
const loaded = roles.current
if (loaded.datatable !== selectedDatatable) return
const switched = untrack(() => rolesPickedOn) !== selectedDatatable
const current = untrack(() => selectedRole)
if (switched || current === undefined || !loaded.roles.includes(current)) {
// Tables already picked on this data table were browsed as a role: keep that one.
const browsed = selectedDatatable
? untrack(() => preWhitelistedRoles)[selectedDatatable]
: undefined
pickRole(
browsed !== undefined && loaded.roles.includes(browsed)
? browsed
: loaded.roles.includes(loaded.defaultRole)
? loaded.defaultRole
: loaded.roles[0]
)
rolesPickedOn = selectedDatatable
}
})
/** Picks the app's role on the selected data table. Tables picked on it under another role are
* dropped: that role may reach them where this one does not. */
function pickRole(role: string | undefined) {
selectedRole = role
const dt = selectedDatatable
const browsed = dt ? preWhitelistedRoles[dt] : undefined
if (dt === undefined || role === undefined || browsed === undefined || browsed === role) return
preWhitelistedTables = preWhitelistedTables.filter((t) => t.datatable !== dt)
const { [dt]: _, ...rest } = preWhitelistedRoles
preWhitelistedRoles = rest
}
const access = createDatatableAccessResource(
() => selectedDatatable,
() => effectiveRole,
() => opWs,
() => rolesSettled && (loadedRoles.length === 0 || effectiveRole !== undefined)
)
// Under roles, and this caller may use none of them: the app would be saved with queries the
// server refuses.
const noUsableRole = $derived(
rolesSettled &&
selectedDatatable !== undefined &&
roles.current.permissioned &&
loadedRoles.length === 0
)
const rolesUnknown = $derived(rolesSettled && roles.current.failed)
const accessSettled = $derived(
hasNoDatatables ||
(rolesSettled &&
selectedDatatable !== undefined &&
access.current.datatable === selectedDatatable &&
access.current.role === effectiveRole)
)
const accessUnknown = $derived(accessSettled && access.current.failed)
const availableSchemas = $derived(accessSettled ? access.current.schemas : [])
const canCreateSchema = $derived(accessSettled && access.current.canCreateSchema)
// Only an app that keeps the data table is held back by it: with table creation off nothing
// saves it.
const blockedByRole = $derived(
(noUsableRole || rolesUnknown || accessUnknown) && tableCreationEnabled
)
// A role that cannot create schemas has nothing to name, so the mode goes back to the one
// every role has, once that is an answer.
$effect(() => {
if (accessSettled && !accessUnknown && schemaMode === 'new' && !canCreateSchema) {
schemaMode = 'none'
}
})
// Likewise an existing schema the current role no longer reaches is unpicked, so the select
// does not keep showing it.
$effect(() => {
if (
accessSettled &&
selectedSchema !== undefined &&
!availableSchemas.includes(selectedSchema)
) {
selectedSchema = undefined
}
})
let hasAutoSelected = false
$effect(() => {
@@ -115,12 +238,18 @@
const datatableItems = $derived(toDatatableItems(availableDatatables))
const schemaItems = $derived(toSchemaItems(availableSchemas))
// An existing schema counts only while the current role reaches it: one picked under another
// role would save an app that creates its tables where it cannot.
const effectiveSchema = $derived(
schemaMode === 'new' ? newSchemaName : schemaMode === 'existing' ? selectedSchema : undefined
schemaMode === 'new'
? newSchemaName
: schemaMode === 'existing' &&
selectedSchema !== undefined &&
availableSchemas.includes(selectedSchema)
? selectedSchema
: undefined
)
const hasNoDatatables = $derived(availableDatatables?.length === 0)
// copilotInfo is a global that stays empty until some ancestor's fetch lands, so
// `enabled` alone cannot tell "no providers" from "not loaded yet" and the modal
// would announce AI as unconfigured while it is merely unknown. Gate on the
@@ -140,7 +269,13 @@
async function start(withPrompt: boolean) {
const template = templates[selectedTemplateIndex]
if (schemaMode === 'new' && newSchemaName && selectedDatatable && opWs) {
if (
tableCreationEnabled &&
schemaMode === 'new' &&
newSchemaName &&
selectedDatatable &&
opWs
) {
try {
const { dbSchemaOpsWithPreviewScripts } = await import('$lib/components/dbOps')
const dbOps = dbSchemaOpsWithPreviewScripts({
@@ -148,7 +283,8 @@
input: {
type: 'database',
resourceType: 'postgresql',
resourcePath: `datatable://${selectedDatatable}`
resourcePath: `datatable://${selectedDatatable}`,
role: effectiveRole
}
})
await dbOps.onCreateSchema({ schema: newSchemaName })
@@ -159,14 +295,20 @@
}
const formattedTables = preWhitelistedTables.map(formatDataTableRef)
const data: RawAppData =
tableCreationEnabled && selectedDatatable
? {
tables: formattedTables,
datatable: selectedDatatable,
schema: effectiveSchema
}
: { tables: formattedTables, datatable: undefined, schema: undefined }
const keepsDatatable = tableCreationEnabled && selectedDatatable !== undefined
// The roles shown, for the data tables the app ends up using.
const usedDatatables = new Set(preWhitelistedTables.map((t) => t.datatable))
if (keepsDatatable) usedDatatables.add(selectedDatatable!)
const shownRoles = Object.entries(pickerRoles ?? {}).filter(([dt]) => usedDatatables.has(dt))
const appRoles = shownRoles.length > 0 ? Object.fromEntries(shownRoles) : undefined
const data: RawAppData = keepsDatatable
? {
tables: formattedTables,
datatable: selectedDatatable,
schema: effectiveSchema,
roles: appRoles
}
: { tables: formattedTables, datatable: undefined, schema: undefined, roles: appRoles }
const policy: Policy = {
on_behalf_of: $userStore?.username.includes('@')
@@ -259,15 +401,43 @@
<label class="text-xs text-emphasis font-semibold" for="datatable"
>Datatable</label
>
<Select
id="datatable"
disablePortal
items={datatableItems}
bind:value={selectedDatatable}
placeholder="Datatable"
size="sm"
class="w-40"
/>
<div class="flex flex-row items-center gap-2">
<Select
id="datatable"
disablePortal
items={datatableItems}
bind:value={selectedDatatable}
placeholder="Datatable"
size="sm"
class="w-40"
/>
{#if showRolePicker}
<!-- Reads as one phrase, "main as analyst", so the role needs no label. -->
<span class="text-xs text-secondary">as</span>
<Select
id="datatable-role"
disablePortal
items={loadedRoles.map((r) => ({ value: r, label: r }))}
bind:value={() => selectedRole, pickRole}
clearable={false}
placeholder="Role"
size="sm"
class="w-40"
/>
{/if}
{#if noUsableRole || rolesUnknown || accessUnknown}
<span
class="text-xs text-red-600 dark:text-red-400"
title={access.current.error}
>
{rolesUnknown
? 'could not read its roles'
: noUsableRole
? 'no role you can use'
: 'could not reach it'}
</span>
{/if}
</div>
</div>
<div>
<span class="text-xs text-emphasis font-semibold">Schema</span>
@@ -276,7 +446,21 @@
<ToggleButtonGroup bind:selected={schemaMode} noWFull>
{#snippet children({ item })}
<ToggleButton value="none" label="None" icon={Ban} {item} size="sm" />
<ToggleButton value="new" label="New" icon={Plus} {item} size="sm" />
<ToggleButton
value="new"
label="New"
icon={Plus}
disabled={!canCreateSchema}
tooltip={canCreateSchema
? undefined
: noUsableRole
? `You can use no role of ${selectedDatatable}`
: accessUnknown
? `Could not read what may be created in ${selectedDatatable}`
: `${effectiveRole ?? 'This connection'} cannot create schemas in ${selectedDatatable}`}
{item}
size="sm"
/>
<ToggleButton
value="existing"
label="Existing"
@@ -333,6 +517,7 @@
dataTableRefs={preWhitelistedTables}
defaultDatatable={selectedDatatable}
defaultSchema={effectiveSchema}
roles={pickerRoles}
standalone
hideDefaultSelector
onAdd={() => dataTableDrawer?.openDrawer()}
@@ -418,7 +603,11 @@
variant="default"
size="sm"
on:click={() => start(false)}
disabled={!templates[selectedTemplateIndex] || newSchemaAlreadyExists}
disabled={!templates[selectedTemplateIndex] ||
newSchemaAlreadyExists ||
!rolesSettled ||
!accessSettled ||
blockedByRole}
>
{$copilotInfo.workspaceDisabled ? 'Start' : 'Start without AI'}
</Button>
@@ -426,7 +615,10 @@
<Button
variant="accent"
on:click={() => start(true)}
disabled={!templates[selectedTemplateIndex] ||
disabled={!rolesSettled ||
!accessSettled ||
blockedByRole ||
!templates[selectedTemplateIndex] ||
!initialPrompt.trim() ||
newSchemaAlreadyExists}
startIcon={{ icon: Sparkles }}
@@ -444,7 +636,15 @@
bind:this={dataTableDrawer}
offset={10000}
existingRefs={preWhitelistedTables}
onAdd={(ref) => {
preWhitelistedTables = [...preWhitelistedTables, ref]
roles={pickerRoles}
onAdd={(refs, browsedRoles, roleChanged) => {
preWhitelistedTables = [
...preWhitelistedTables.filter((t) => !roleChanged.has(t.datatable)),
...refs
]
preWhitelistedRoles = { ...preWhitelistedRoles, ...browsedRoles }
if (selectedDatatable !== undefined && browsedRoles[selectedDatatable] !== undefined) {
selectedRole = browsedRoles[selectedDatatable]
}
}}
/>
@@ -1,5 +1,31 @@
import { describe, expect, it } from 'vitest'
import { buildDataTableWhitelist, isDatatableTableAllowed } from './dataTableRefUtils'
import {
buildDataTableWhitelist,
isDatatableTableAllowed,
sdkDatatableCall,
withAppDatatableRole
} from './dataTableRefUtils'
describe('app data table roles', () => {
it('writes the role the way each SDK takes it', () => {
expect(sdkDatatableCall('main', 'analyst', 'typescript')).toBe(
"wmill.datatable('main', { role: 'analyst' })"
)
expect(sdkDatatableCall('main', 'analyst', 'python')).toBe(
"wmill.datatable('main', role='analyst')"
)
expect(sdkDatatableCall('main', undefined, 'python')).toBe('wmill.datatable()')
})
it('keeps one role per data table, and no map once none is left', () => {
const roles = withAppDatatableRole(undefined, 'main', 'analyst')
expect(withAppDatatableRole(roles, 'other', 'operator')).toEqual({
main: 'analyst',
other: 'operator'
})
expect(withAppDatatableRole(roles, 'main', undefined)).toBeUndefined()
})
})
describe('datatable whitelist helpers', () => {
it('allows every datatable table when no refs are configured', () => {
@@ -16,6 +16,43 @@ export interface RawAppData {
datatable: string | undefined
/** The schema for table creation (if specified) */
schema: string | undefined
/** The role the app uses each data table through, by data table name. A data table without
* an entry is used as its default role, then `admin`. */
roles?: Record<string, string>
}
export function appDatatableRole(
roles: Record<string, string> | undefined,
datatable: string
): string | undefined {
return roles?.[datatable]
}
/** `roles` with `datatable` set to `role`, or without it when `role` is undefined. */
export function withAppDatatableRole(
roles: Record<string, string> | undefined,
datatable: string,
role: string | undefined
): Record<string, string> | undefined {
const next = { ...roles }
if (role === undefined) delete next[datatable]
else next[datatable] = role
return Object.keys(next).length > 0 ? next : undefined
}
/** The SDK call app code uses to reach a data table, in the language it is written in. A role
* is a keyword argument in Python and an option in TypeScript. */
export function sdkDatatableCall(
datatable: string,
role: string | undefined,
language: 'typescript' | 'python'
): string {
if (role === undefined) {
return datatable === 'main' ? 'wmill.datatable()' : `wmill.datatable('${datatable}')`
}
return language === 'python'
? `wmill.datatable('${datatable}', role='${role}')`
: `wmill.datatable('${datatable}', { role: '${role}' })`
}
/** Default data configuration */
@@ -1,9 +1,27 @@
import { resource } from 'runed'
import { workspaceStore, dbSchemas } from '$lib/stores'
import { workspaceStore } from '$lib/stores'
import { WorkspaceService } from '$lib/gen'
import { getDbSchemas } from '$lib/components/apps/components/display/dbtable/metadata'
import { listUsableDatatableRoles } from '$lib/components/datatableUsableRoles'
import { ADMIN_DATATABLE_ROLE } from '$lib/components/dbTypes'
import { get } from 'svelte/store'
/**
* `fetch` wrapped so that an answer for a request a newer one has replaced resolves to
* `stale()` instead: a resource keeps whichever answer lands last, and a slow answer for the
* previous data table or role would otherwise describe a selection that no longer exists.
*/
function latestOnly<A extends unknown[], T>(
fetch: (...args: A) => Promise<T>,
stale: () => T
): (...args: A) => Promise<T> {
let run = 0
return async (...args) => {
const mine = ++run
const result = await fetch(...args)
return mine === run ? result : stale()
}
}
/**
* Creates a resource that loads available datatables from the workspace.
* Pass a getter function that returns the workspace to create a reactive dependency.
@@ -21,42 +39,142 @@ export function createDatatablesResource(getWorkspace: () => string | undefined)
})
}
export type DatatableRoles = {
/** The data table this answers for: while a switch is in flight, `current` still holds the
* previous one's roles, which say nothing about the one now selected. */
datatable: string | undefined
/** Whether the data table is under roles. Without roles `roles` is empty because there is
* nothing to pick, which is not the same as a permissioned one this caller may use no role of. */
permissioned: boolean
/** The lookup failed, so an empty `roles` means nothing was learned. */
failed: boolean
roles: string[]
defaultRole: string
}
/**
* Creates a resource that loads schemas for a given datatable.
* The getDatatable getter is used as a reactive dependency - when it changes, schemas are refetched.
* Creates a resource that loads the roles the caller may use on a datatable, and the one it
* defaults to.
*/
export function createSchemasResource(
export function createRolesResource(
getDatatable: () => string | undefined,
getWorkspace: () => string | undefined = () => get(workspaceStore)
) {
return resource<string[]>([() => getDatatable() ?? '', () => getWorkspace() ?? ''], async () => {
const datatable = getDatatable()
const workspace = getWorkspace()
if (!datatable || !workspace) return []
const initialValue: DatatableRoles = {
datatable: undefined,
permissioned: false,
failed: false,
roles: [],
defaultRole: ADMIN_DATATABLE_ROLE
}
const rolesResource = resource(
() => [getDatatable() ?? '', getWorkspace() ?? ''] as const,
latestOnly(
async ([datatableName, workspace]: readonly [string, string]): Promise<DatatableRoles> => {
const empty = { ...initialValue, datatable: datatableName || undefined }
if (!datatableName || !workspace) return empty
try {
const res = await listUsableDatatableRoles(workspace, datatableName)
return {
...empty,
permissioned: res.permissioned,
roles: res.roles,
defaultRole: res.default_role
}
} catch (e) {
console.error('Failed to load datatable roles:', e)
return { ...empty, failed: true }
}
},
() => rolesResource.current
),
{ initialValue }
)
return rolesResource
}
const resourcePath = `datatable://${datatable}`
// Key the schema cache by workspace too: a datatable of the same name can
// exist in both the nav and the acting workspace, so `datatable://<name>`
// alone would let one workspace's schema be reused for the other.
const cacheKey = `${workspace}:${resourcePath}`
const schemas = get(dbSchemas)
let dbSchema = schemas[cacheKey]
export type DatatableAccess = {
/** What this answers for. Until both match the selection, the schemas and the right to
* create one belong to another data table or another role. */
datatable: string | undefined
role: string | undefined
/** The request failed, or the server kept the entry with an error (a role this caller may
* not use, an unreachable database). `canCreateSchema: false` is then no answer at all. */
failed: boolean
error: string | undefined
schemas: string[]
canCreateSchema: boolean
}
if (!dbSchema) {
try {
schemas[cacheKey] = await getDbSchemas('postgresql', resourcePath, workspace, (msg) =>
console.error('Schema error:', msg)
)
dbSchema = get(dbSchemas)[cacheKey]
} catch (e) {
console.error(`Failed to load schema for ${datatable}:`, e)
return []
}
}
/**
* Creates a resource that loads, for one data table read as one role, the schemas that role can
* reach and whether it may create more.
*/
export function createDatatableAccessResource(
getDatatable: () => string | undefined,
getRole: () => string | undefined,
getWorkspace: () => string | undefined = () => get(workspaceStore),
/** False while the role is still being settled: a listing sent before that is read as the
* data table's default role, which is not the one about to be asked for. */
getReady: () => boolean = () => true
) {
const initialValue: DatatableAccess = {
datatable: undefined,
role: undefined,
failed: false,
error: undefined,
schemas: [],
canCreateSchema: false
}
const accessResource = resource(
() => [getDatatable() ?? '', getRole() ?? '', getWorkspace() ?? '', getReady()] as const,
latestOnly(
async ([datatable, role, workspace, ready]: readonly [
string,
string,
string,
boolean
]): Promise<DatatableAccess> => {
const asked = {
...initialValue,
datatable: datatable || undefined,
role: role || undefined
}
if (!ready) return accessResource.current
if (!datatable || !workspace) return asked
try {
const tables = await WorkspaceService.listDataTableTables({
workspace,
datatableName: datatable,
roleFor: datatable,
role: role || undefined
})
const entry = tables.find((t) => t.datatable_name === datatable)
return {
...asked,
failed: entry === undefined || entry.error !== undefined,
error: entry?.error,
schemas: Object.keys(entry?.schemas ?? {}).sort(),
canCreateSchema: !!entry?.can_create_schema
}
} catch (e) {
console.error('Failed to load datatable access:', e)
return { ...asked, failed: true, error: (e as Error)?.message }
}
},
() => accessResource.current
),
{ initialValue }
)
return accessResource
}
if (!dbSchema?.schema) return []
return Object.keys(dbSchema.schema)
})
/**
* Whether naming a role says anything: a data table without roles has none to pick, and one
* whose single usable role is `admin` offers no choice.
*/
export function rolesWorthPicking(roles: string[]): boolean {
return roles.length > 1 || (roles.length === 1 && roles[0] !== ADMIN_DATATABLE_ROLE)
}
/**
@@ -27,6 +27,7 @@
import DropdownV2 from '../DropdownV2.svelte'
import MissingWorkerTagAlert from '../jobs/MissingWorkerTagAlert.svelte'
import { superadmin, userStore } from '$lib/stores'
import { parseMigrationRole, withMigrationRole } from '../datatableMigrationRole'
let {
workspace,
@@ -84,8 +85,9 @@
function startAddDownMigration() {
// Same transaction frame the new-migration modal starts from, so the down
// applies atomically.
downDraft = DOWN_TEMPLATE
// applies atomically. It rolls back as the role the up ran as.
const upRole = viewMigration ? parseMigrationRole(viewMigration.code_up) : undefined
downDraft = withMigrationRole(DOWN_TEMPLATE, upRole?.kind === 'role' ? upRole.role : undefined)
addingDown = true
}
@@ -167,6 +169,10 @@
loadMigrations()
}
export function open() {
openList()
}
// Open the list modal and the detail view for a specific migration. Used to
// jump to a just-created migration from the "See migration" toast action.
export async function openMigration(timestamp: number) {
@@ -5,77 +5,89 @@
import Toggle from '../Toggle.svelte'
import Tooltip from '../Tooltip.svelte'
import CloseButton from '../common/CloseButton.svelte'
import { KeyRound, Plus } from 'lucide-svelte'
import Checkbox from '../common/checkbox/Checkbox.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 { KeyRound } from 'lucide-svelte'
import {
FolderService,
GroupService,
SettingService,
UserService,
WorkspaceService,
type AclTarget,
type DatatableAclInfo,
type DatatablePermissions,
type InstanceDatatableRole
} from '$lib/gen'
import { superadmin } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import AclTargetPicker from '../datatableAcl/AclTargetPicker.svelte'
import { deepEqual } from 'fast-equals'
import { ADMIN_DATATABLE_ROLE, isDatatableRoleName } from '../dbTypes'
import PgAclEditor from '../datatableAcl/PgAclEditor.svelte'
const ADMIN_ROLE = 'admin'
import InstanceRolesButton from './InstanceRolesButton.svelte'
let {
workspace,
datatable,
disabled = false
disabled = false,
hideTrigger = false,
onSaved
}: {
workspace: string
datatable: string
disabled?: boolean
/** Mount the drawer without its button, for a caller that opens it with `open()`. */
hideTrigger?: boolean
/** Called once a save went through, so a caller showing the roles can read them again. */
onSaved?: () => void
} = $props()
let drawer: Drawer | undefined = $state(undefined)
// `id` is the instance role's catalog id (or the reserved `admin`), which is what the tenant
// lists are keyed by — so renaming a role instance-side moves nothing here. A row without an id
// names a role the instance does not define yet: it cannot be saved until a superadmin creates
// it, and takes the new role's id once they have.
type EditedRole = { id: string | undefined; name: string | undefined; tenants: string[] }
type Edited = { permissioned: boolean; roles: EditedRole[]; defaultRoleId: string }
let drawerOpen = $state(false)
let loading = $state(false)
let saving = $state(false)
let loadError = $state<string | undefined>(undefined)
let info = $state<DatatablePermissions | undefined>(undefined)
// Edited copy. `id` is the instance role's catalog id (or the reserved `admin`), which is what
// the tenant lists are keyed by — so renaming a role instance-side moves nothing here.
let permissioned = $state(false)
let defaultRole = $state(ADMIN_ROLE)
let rows = $state<{ id: string; name: string | undefined; tenants: string[] }[]>([])
let roles = $state<EditedRole[]>([])
let defaultRoleId = $state(ADMIN_DATATABLE_ROLE)
/** The last loaded state, to detect unsaved changes against. */
let saved = $state<Edited>({
permissioned: false,
roles: [],
defaultRoleId: ADMIN_DATATABLE_ROLE
})
// Tenants name principals of the workspace that governs the data table, which is not
// necessarily the one we are browsing from.
let tenantOptions = $state<{ value: string; label: string }[]>([])
let tenantItems = $state<{ value: string; label: string; group: string }[]>([])
const editable = $derived(!!info?.editable)
const governing = $derived(info?.governing_workspace_id)
const availableRoles: InstanceDatatableRole[] = $derived(info?.available_roles ?? [])
const unusedRoles = $derived(availableRoles.filter((r) => !rows.some((row) => row.id === r.id)))
// The instance catalog, read again after the instance roles drawer changes it.
let catalog = $state<InstanceDatatableRole[] | undefined>(undefined)
const availableRoles: InstanceDatatableRole[] = $derived(catalog ?? info?.available_roles ?? [])
const unusedRoles = $derived(availableRoles.filter((r) => !roles.some((row) => row.id === r.id)))
const pendingRoles = $derived(roles.filter((r) => r.id === undefined))
let instanceRoles: InstanceRolesButton | undefined = $state(undefined)
let aclSchema = $state<string | undefined>(undefined)
let aclTable = $state<string | undefined>(undefined)
const aclTarget: AclTarget = $derived(
aclSchema
? aclTable
? { kind: 'table', schema: aclSchema, table: aclTable }
: { kind: 'schema', schema: aclSchema }
: { kind: 'database' }
const roleKey = (role: EditedRole) => role.id ?? `pending:${role.name}`
const hasUnsavedChanges = $derived(
!deepEqual($state.snapshot(saved), {
permissioned,
roles: $state.snapshot(roles) as EditedRole[],
defaultRoleId
})
)
let aclSchemas = $state<string[]>([])
let aclSchemasLoaded = $state(false)
let aclTables = $state<string[]>([])
// The editor's read of a database lists its schemas, and of a schema its tables — which is what
// the picker offers, so the picker reads nothing of its own. A read for a target since left
// behind is dropped.
function onAclLoaded(target: AclTarget, loaded: DatatableAclInfo) {
if (JSON.stringify(target) !== JSON.stringify(aclTarget)) return
if (target.kind === 'database') {
aclSchemas = loaded.children
aclSchemasLoaded = true
} else if (target.kind === 'schema') aclTables = loaded.children
}
async function load() {
loading = true
@@ -85,16 +97,23 @@
workspace,
datatableName: datatable
})
info = res
permissioned = res.permissioned
defaultRole = res.default_role
rows = (res.roles ?? [])
.map((r) => ({ id: r.id, name: r.name, tenants: r.tenants ?? [] }))
.sort((a, b) => (a.id === ADMIN_ROLE ? -1 : b.id === ADMIN_ROLE ? 1 : 0))
if (rows.length === 0) {
rows = [{ id: ADMIN_ROLE, name: ADMIN_ROLE, tenants: [] }]
const loaded: EditedRole[] = res.roles
.map((r) => ({ id: r.id, name: r.name, tenants: [...(r.tenants ?? [])] }))
.sort(
(a, b) => Number(b.id === ADMIN_DATATABLE_ROLE) - Number(a.id === ADMIN_DATATABLE_ROLE)
)
// A data table never put under roles comes back with none; admin is what turning the toggle
// on starts from.
if (!loaded.some((r) => r.id === ADMIN_DATATABLE_ROLE)) {
loaded.unshift({ id: ADMIN_DATATABLE_ROLE, name: ADMIN_DATATABLE_ROLE, tenants: [] })
}
await loadTenantOptions(res.governing_workspace_id ?? workspace)
info = res
catalog = undefined
permissioned = res.permissioned
roles = loaded
defaultRoleId = res.default_role
saved = structuredClone({ permissioned, roles: loaded, defaultRoleId })
await loadTenantItems(res.governing_workspace_id ?? workspace)
} catch (e) {
loadError = e?.body ?? e?.message ?? String(e)
} finally {
@@ -102,35 +121,70 @@
}
}
async function loadTenantOptions(ws: string) {
async function loadTenantItems(ws: string) {
try {
const [users, groups, folders] = await Promise.all([
UserService.listUsernames({ workspace: ws }),
GroupService.listGroupNames({ workspace: ws }),
FolderService.listFolderNames({ workspace: ws })
])
tenantOptions = [
{ value: '*', label: 'Everyone in the workspace' },
...users.map((u) => ({ value: `u/${u}`, label: `u/${u}` })),
...groups.map((g) => ({ value: `g/${g}`, label: `g/${g}` })),
...folders.map((f) => ({ value: `f/${f}`, label: `f/${f}` }))
tenantItems = [
{ value: '*', label: 'Everyone', group: 'Anyone in the workspace' },
...users.map((u) => ({ value: `u/${u}`, label: u, group: 'Users' })),
...groups.map((g) => ({ value: `g/${g}`, label: g, group: 'Groups' })),
...folders.map((f) => ({ value: `f/${f}`, label: f, group: 'Folders' }))
]
} catch {
// A fork member may not be able to list the governing workspace's principals. The
// tenants they cannot name are still shown, they just cannot pick new ones.
tenantOptions = []
tenantItems = []
}
}
function addRole(id: string) {
const role = availableRoles.find((r) => r.id === id)
if (!role) return
rows = [...rows, { id: role.id, name: role.name, tenants: [] }]
roles.push({ id: role.id, name: role.name, tenants: [] })
}
function removeRole(id: string) {
rows = rows.filter((r) => r.id !== id)
if (defaultRole === id) defaultRole = ADMIN_ROLE
/** Adds a role by name: the instance's role of that name, or a pending row for one it does not
* define yet. */
function addRoleByName(typed: string) {
const name = typed.trim()
if (!isDatatableRoleName(name) || name.toLowerCase() === ADMIN_DATATABLE_ROLE) {
sendUserToast(
`'${name}' cannot be a data table role name: use letters, digits, '_' and '-'`,
true
)
return
}
if (roles.some((r) => r.name === name)) return
const existing = availableRoles.find((r) => r.name === name)
roles.push({ id: existing?.id, name, tenants: [] })
}
function removeRole(role: EditedRole) {
const key = roleKey(role)
roles = roles.filter((r) => roleKey(r) !== key)
if (role.id !== undefined && defaultRoleId === role.id) defaultRoleId = ADMIN_DATATABLE_ROLE
}
/** Reads the instance catalog again and gives each pending row the id of the role now defined
* under its name. */
async function refreshCatalog() {
let fresh: InstanceDatatableRole[]
try {
fresh = await SettingService.listInstanceDatatableRoles()
} catch (e) {
sendUserToast(e?.body ?? e?.message ?? String(e), true)
return
}
catalog = fresh
for (const row of roles) {
if (row.id !== undefined) continue
const created = fresh.find((r) => r.name === row.name)
if (created) row.id = created.id
}
}
async function save() {
@@ -141,12 +195,13 @@
datatableName: datatable,
requestBody: {
permissioned,
default_role: defaultRole,
roles: rows.map((r) => ({ id: r.id, tenants: r.tenants }))
default_role: defaultRoleId,
roles: roles.map((r) => ({ id: r.id!, tenants: $state.snapshot(r.tenants) }))
}
})
sendUserToast(msg)
await load()
onSaved?.()
} catch (e) {
sendUserToast(e?.body ?? e?.message ?? String(e), true)
} finally {
@@ -155,41 +210,48 @@
}
export function open() {
aclSchema = undefined
aclTable = undefined
aclSchemas = []
aclSchemasLoaded = false
aclTables = []
drawer?.openDrawer()
drawerOpen = true
load()
}
</script>
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: KeyRound }}
iconOnly
{disabled}
title={disabled ? 'Save settings first' : 'Roles: who may connect as which Postgres role'}
on:click={open}
/>
{#if !hideTrigger}
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: KeyRound }}
iconOnly
{disabled}
title={disabled ? 'Save settings first' : 'Roles: who may connect as which Postgres role'}
on:click={open}
/>
{/if}
<Drawer bind:this={drawer} size="700px">
<Drawer bind:open={drawerOpen} size="900px">
<DrawerContent
title="Roles for {datatable}"
on:close={() => drawer?.closeDrawer()}
title="Roles {datatable}"
on:close={() => (drawerOpen = false)}
tooltip="A data table role is a Postgres login. A job that names one connects as it, and Postgres decides what it may touch — grant it privileges under Access. Roles are defined for the whole instance; here you say who may use each one on this data table."
>
{#snippet titleExtra()}
<Badge color="blue" small>Beta</Badge>
{/snippet}
{#if loading}
<p class="text-sm text-secondary">Loading…</p>
{:else if loadError}
{#if loadError}
<Alert type="error" title="Could not load roles" size="xs">{loadError}</Alert>
{:else if loading && !info}
<span class="text-sm text-secondary">Loading…</span>
{:else}
<div class="flex flex-col gap-4">
<Toggle
bind:checked={permissioned}
disabled={!editable || !info?.supported}
options={{
right: 'Put this data table under roles',
rightTooltip:
'Off, every job connects as admin — the connection that owns every table. On, every job resolves to a role, and a caller no tenant covers is refused.'
}}
/>
{#if !info?.supported}
<Alert type="info" title="Not available on this data table" size="xs">
A data table role is a Postgres login on the Windmill instance's own database, so only a
@@ -219,126 +281,155 @@
</Alert>
{/if}
<Toggle
bind:checked={permissioned}
disabled={!editable || !info?.supported}
options={{
right: 'Put this data table under roles',
rightTooltip:
'Off, every job connects as admin — the connection that owns every table. On, every job resolves to a role, and a caller no tenant covers is refused.'
}}
/>
{#if permissioned}
{#if availableRoles.length === 0}
{#if editable && availableRoles.length === 0}
<Alert type="warning" title="No role defined on this instance" size="xs">
Only <span class="font-mono">admin</span> can be used until a superadmin adds a data table
role, from Instance roles at the top of the data tables settings page.
Only <span class="font-mono">admin</span> can be used until a superadmin creates a data
table role. Type a name below to add one.
</Alert>
{/if}
<div class="flex flex-col gap-3">
{#each rows as row (row.id)}
<div class="flex flex-col gap-1 border rounded-md p-3 bg-surface-secondary">
<div class="flex items-center gap-2">
<Badge color={row.id === ADMIN_ROLE ? 'blue' : 'gray'}>
{row.name ?? row.id}
</Badge>
{#if row.id === ADMIN_ROLE}
<Tooltip>
The connection every data table resolved to before roles. It owns every
existing object, so it is always available and cannot be removed.
</Tooltip>
{:else if !row.name}
<span class="text-xs text-secondary italic">
no longer defined on this instance
</span>
{/if}
{#if defaultRole === row.id}
<Badge color="green">Default</Badge>
{:else if editable}
<Button
unifiedSize="2xs"
variant="subtle"
on:click={() => (defaultRole = row.id)}
>
Make default
</Button>
{/if}
<div class="grow"></div>
{#if editable && row.id !== ADMIN_ROLE}
<CloseButton small on:close={() => removeRole(row.id)} />
{/if}
</div>
<MultiSelect
items={tenantOptions}
bind:value={row.tenants}
disabled={!editable}
placeholder="Nobody yet — add a user, group or folder"
size="sm"
/>
</div>
{/each}
</div>
{#if editable && unusedRoles.length > 0}
<div class="flex items-center gap-2">
<Plus size={14} class="text-secondary" />
<Select
items={unusedRoles.map((r) => ({
value: r.id,
label: r.enabled ? r.name : `${r.name} (disabled)`
}))}
placeholder="Add a role"
bind:value={
() => undefined,
(id) => {
if (id) addRole(id)
}
}
class="w-64"
size="sm"
/>
</div>
{/if}
{/if}
{#if editable}
<div class="flex justify-end">
<Button unifiedSize="sm" variant="accent" loading={saving} on:click={save}>
Save roles
</Button>
</div>
{/if}
{#if info?.supported}
<div class="flex flex-col gap-3 border-t pt-4">
<div class="flex flex-col gap-0.5">
<span class="text-sm font-semibold text-emphasis">Access</span>
<span class="text-xs text-secondary">
What each role may do in Postgres, on the database, a schema or a table. Every
change shows the SQL it runs before running it.
</span>
</div>
<AclTargetPicker
schemas={aclSchemas}
schemasLoading={!aclSchemasLoaded}
tables={aclTables}
bind:schema={
() => aclSchema,
(s) => {
aclSchema = s
aclTables = []
}
}
bind:table={aclTable}
/>
{#key JSON.stringify(aclTarget)}
<PgAclEditor {workspace} {datatable} target={aclTarget} onLoaded={onAclLoaded} />
{/key}
</div>
<DataTable>
<Head>
<tr>
<Cell head first>
Role
<Tooltip>
admin is the connection the data table used before roles, so it owns every
existing object and cannot be removed. Every other role is a login defined for
the whole instance, with only the privileges granted to it under Access.
</Tooltip>
</Cell>
<Cell head>
Tenants
<Tooltip>
Users, groups and folders allowed to connect as this role. Workspace admins can
use every role.
</Tooltip>
</Cell>
<Cell head>
Default
<Tooltip>
The role a job gets when it names none — no `-- role` annotation, no `?role=` in
the reference. Callers still have to be one of its tenants.
</Tooltip>
</Cell>
<Cell head last />
</tr>
</Head>
<tbody class="divide-y bg-surface-tertiary">
{#each roles as role (roleKey(role))}
{@const isAdmin = role.id === ADMIN_DATATABLE_ROLE}
<Row>
<Cell first class="w-56 align-top">
<div class="flex flex-col gap-0.5 pt-1.5">
<span class="font-mono text-xs text-emphasis">{role.name ?? role.id}</span>
{#if !role.name}
<span class="text-2xs text-secondary italic">
no longer defined on this instance
</span>
{:else if role.id === undefined}
<Alert type="warning" title="This role does not exist yet" size="xs">
{#if $superadmin}
<div class="flex flex-col items-start gap-1">
<span>Create it on the instance to use it here.</span>
<Button
unifiedSize="xs"
variant="default"
on:click={() => instanceRoles?.open(role.name)}
>
Create it
</Button>
</div>
{:else}
Only a superadmin can create it on the instance.
{/if}
</Alert>
{/if}
</div>
</Cell>
<Cell class="align-top">
<MultiSelect
items={tenantItems}
bind:value={role.tenants}
groupBy={(item) => item.group}
disabled={!editable}
placeholder="Nobody — add users, groups or folders"
/>
</Cell>
<Cell class="w-20 align-top">
<div class="flex justify-center pt-2">
<Checkbox
checked={role.id !== undefined && defaultRoleId === role.id}
disabled={!editable || role.id === undefined}
title="Use this role when a job names none"
onChange={() => {
if (role.id !== undefined) defaultRoleId = role.id
}}
/>
</div>
</Cell>
<Cell last class="w-10 align-top">
{#if editable && !isAdmin}
<CloseButton small on:close={() => removeRole(role)} />
{/if}
</Cell>
</Row>
{/each}
{#if editable}
<Row class="!border-0">
<Cell colspan={4} class="pt-2 pb-2">
<div class="flex justify-center">
<Select
items={unusedRoles.map((r) => ({
value: r.id,
label: r.enabled ? r.name : `${r.name} (disabled)`
}))}
placeholder="+ Add a role"
bind:value={
() => undefined,
(id) => {
if (id) addRole(id)
}
}
onCreateItem={addRoleByName}
class="w-64"
/>
</div>
</Cell>
</Row>
{/if}
</tbody>
</DataTable>
{/if}
</div>
{#if info?.supported && !hasUnsavedChanges}
<div class="mt-6 pt-6 border-t">
<PgAclEditor {workspace} {datatable} target={{ kind: 'database' }} />
</div>
{/if}
{/if}
{#snippet actions()}
{#if editable}
<Button
variant="accent"
unifiedSize="md"
disabled={!hasUnsavedChanges || loading || !!loadError || pendingRoles.length > 0}
title={pendingRoles.length > 0
? 'Create the roles that do not exist yet, or remove them'
: undefined}
loading={saving}
on:click={save}
>
Save
</Button>
{/if}
{/snippet}
</DrawerContent>
</Drawer>
{#if $superadmin}
<InstanceRolesButton bind:this={instanceRoles} hideTrigger onChanged={refreshCatalog} />
{/if}
@@ -13,11 +13,22 @@
import { SettingService, type InstanceDatatableRole } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
let {
initialName = '',
onChanged
}: {
/** Prefills the name of the role to add. */
initialName?: string
/** Called after every change to the catalog, whether or not it went through. */
onChanged?: () => void
} = $props()
let roles = $state<InstanceDatatableRole[]>([])
let loading = $state(true)
let loadError = $state<string | undefined>(undefined)
let busy = $state(false)
let newName = $state('')
// svelte-ignore state_referenced_locally
let newName = $state(initialName)
/** Which role's name is being edited, and to what. */
let renaming = $state<{ id: string; name: string } | undefined>(undefined)
@@ -48,6 +59,7 @@
// holds, so a failed flip has to snap back rather than sit there claiming it landed.
await load()
busy = false
onChanged?.()
}
}
@@ -64,12 +64,11 @@
</script>
<script lang="ts">
import { Plus, PlugZap } from 'lucide-svelte'
import { History, KeyRound, Plus, PlugZap, Trash2 } from 'lucide-svelte'
import DropdownV2 from '../DropdownV2.svelte'
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'
@@ -103,7 +102,7 @@
import DataTablePermissionsButton from './DataTablePermissionsButton.svelte'
import InstanceRolesButton from './InstanceRolesButton.svelte'
import { deepEqual } from 'fast-equals'
import { clone } from '$lib/utils'
import { clone, onlyAlphaNumAndUnderscore } from '$lib/utils'
import SettingsFooter from './SettingsFooter.svelte'
import Alert from '../common/alert/Alert.svelte'
import MissingWorkerTagAlert from '../jobs/MissingWorkerTagAlert.svelte'
@@ -284,6 +283,9 @@
}
let confirmationModal = createAsyncConfirmationModal()
// Each mounts its own modal or drawer; the row menu opens them.
let migrationsButtons = $state<Record<string, DataTableMigrationsButton | undefined>>({})
let permissionsButtons = $state<Record<string, DataTablePermissionsButton | undefined>>({})
let dirtyMap = $derived.by(() => {
const map: Record<string, boolean> = {}
for (let i = 0; i < tempSettings.dataTables.length; i++) {
@@ -471,15 +473,17 @@
<Cell class="whitespace-nowrap">
<div class="flex gap-2">
<DataTableMigrationsButton
bind:this={migrationsButtons[dataTable.name]}
hideTrigger
workspace={$workspaceStore ?? ''}
datatable={dataTable.name}
disabled={!!dirtyMap[dataTable.name]}
/>
{#if $enterpriseLicense}
{#if $enterpriseLicense && !isCloudHosted()}
<DataTablePermissionsButton
bind:this={permissionsButtons[dataTable.name]}
hideTrigger
workspace={$workspaceStore ?? ''}
datatable={dataTable.name}
disabled={!!dirtyMap[dataTable.name]}
/>
{/if}
<Button
@@ -515,9 +519,41 @@
</div>
</Cell>
<Cell class="w-12">
{#if !dataTable.reference}
<CloseButton small on:close={() => removeDataTable(dataTableIndex)} />
{/if}
<DropdownV2
items={() => [
{
displayName: 'Migrations',
icon: History,
// Both act on the saved data table, which unsaved edits are not.
disabled: !!dirtyMap[dataTable.name],
tooltip: dirtyMap[dataTable.name] ? 'Save the settings first' : undefined,
action: () => migrationsButtons[dataTable.name]?.open()
},
...($enterpriseLicense && !isCloudHosted()
? [
{
displayName: 'Roles',
icon: KeyRound,
disabled: !!dirtyMap[dataTable.name],
tooltip: dirtyMap[dataTable.name] ? 'Save the settings first' : undefined,
action: () => permissionsButtons[dataTable.name]?.open()
}
]
: []),
// A fork's pointer entry is written by forking and kept by the server, not this form.
...(dataTable.reference
? []
: [
{
displayName: 'Remove',
icon: Trash2,
type: 'delete' as const,
action: () => removeDataTable(dataTableIndex)
}
])
]}
btnId={'datatable-settings-actions-' + onlyAlphaNumAndUnderscore(dataTable.name)}
/>
</Cell>
</Row>
{/each}
@@ -189,11 +189,19 @@
(v) => (datatableBehaviors[dt.name] = v)
}
items={[
{ value: 'keep_original', label: 'Keep original' },
{ value: 'schema_only', label: 'Clone schema only' },
...(!isCloudHosted() && $userStore?.is_admin
? [{ value: 'schema_and_data', label: 'Clone schema and data' }]
: [])
{
value: 'keep_original',
label: dt.permissioned ? 'Keep original (under roles)' : 'Keep original'
},
// A copy of a data table under roles is refused by the server, so it is not offered.
...(dt.permissioned
? []
: [
{ value: 'schema_only', label: 'Clone schema only' },
...(!isCloudHosted() && $userStore?.is_admin
? [{ value: 'schema_and_data', label: 'Clone schema and data' }]
: [])
])
]}
/>
</div>
@@ -3,17 +3,34 @@
import { Users } from 'lucide-svelte'
import DataTableRolesSection from './DataTableRolesSection.svelte'
let {
hideTrigger = false,
onChanged
}: {
/** Mount the drawer without its button, for a caller that opens it with `open()`. */
hideTrigger?: boolean
/** Called after every change to the instance roles. */
onChanged?: () => void
} = $props()
let drawer: Drawer | undefined = $state(undefined)
let prefill = $state('')
// Remounts the section on each open, so the prefilled name is the one just asked for.
let openCount = $state(0)
/** Opens the drawer, with `name` prefilled as the role to add. */
export function open(name = '') {
prefill = name
openCount++
drawer?.openDrawer()
}
</script>
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: Users }}
on:click={() => drawer?.openDrawer()}
>
Instance roles
</Button>
{#if !hideTrigger}
<Button unifiedSize="sm" variant="default" startIcon={{ icon: Users }} on:click={() => open()}>
Instance roles
</Button>
{/if}
<Drawer bind:this={drawer} size="700px">
<DrawerContent
@@ -24,6 +41,8 @@
{#snippet titleExtra()}
<Badge color="blue" small>Beta</Badge>
{/snippet}
<DataTableRolesSection />
{#key openCount}
<DataTableRolesSection initialName={prefill} {onChanged} />
{/key}
</DrawerContent>
</Drawer>
@@ -8,12 +8,17 @@
import TextInput from '../text_input/TextInput.svelte'
import SimpleEditor from '../SimpleEditor.svelte'
import { WorkspaceService, type DatatableMigration } from '$lib/gen'
import { listUsableDatatableRoles } from '../datatableUsableRoles'
import { sendUserToast } from '$lib/toast'
import { tick } from 'svelte'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
import { createAsyncConfirmationModal } from '../common/confirmationModal/asyncConfirmationModal.svelte'
import Portal from '$lib/components/Portal.svelte'
import { fetchPendingMigrations, outOfOrderRunMessage } from './datatableMigrationUtils'
import Select from '../select/Select.svelte'
import { parseMigrationRole, withMigrationRole } from '../datatableMigrationRole'
import { ADMIN_DATATABLE_ROLE } from '../dbTypes'
import { resource } from 'runed'
let {
workspace,
@@ -50,6 +55,8 @@
let tab = $state('up')
let name = $state('')
let nameInput = $state<TextInput>()
let upEditor = $state<SimpleEditor | undefined>()
let downEditor = $state<SimpleEditor | undefined>()
// A valid migration name is non-empty and limited to letters, digits, '_' and '-'.
const MIGRATION_NAME_RE = /^[a-zA-Z0-9_-]+$/
let nameInvalid = $derived(!MIGRATION_NAME_RE.test(name.trim()))
@@ -60,6 +67,96 @@
const confirmationModal = createAsyncConfirmationModal()
// The role lives in the SQL as its `-- role <name>` annotation, so the code is the single
// source of truth and the Select is a view onto it: reading parses, writing rewrites the
// annotation.
const usableRoles = resource(
() => [workspace, datatable] as const,
async ([ws, dt]) => {
try {
return await listUsableDatatableRoles(ws, dt)
} catch (e) {
console.error('Failed to load data table roles:', e)
return null
}
}
)
// Not a valid role name, so it cannot collide with one.
const NO_ROLE = '(no role)'
let declaredUp = $derived(parseMigrationRole(codeUp))
let declaredDown = $derived(enableDown ? parseMigrationRole(codeDown) : undefined)
let malformedLine = $derived(
declaredUp.kind === 'malformed'
? declaredUp.line
: declaredDown?.kind === 'malformed'
? declaredDown.line
: undefined
)
const roleOf = (d: typeof declaredUp) => (d.kind === 'role' ? d.role : undefined)
// A rollback runs as the role its own SQL names: under another role than the up migration it
// typically cannot touch what the up created.
let sqlProblem = $derived(
malformedLine !== undefined
? malformedMessage(malformedLine)
: declaredDown !== undefined && roleOf(declaredDown) !== roleOf(declaredUp)
? `The down migration runs as ${roleOf(declaredDown) ?? 'admin (no role)'} but the up migration as ${roleOf(declaredUp) ?? 'admin (no role)'}: make their role annotations match`
: undefined
)
let selectedRole = $derived(declaredUp.kind === 'role' ? declaredUp.role : NO_ROLE)
let permissioned = $derived(!!usableRoles.current?.permissioned)
// No annotation runs as admin, which the server allows exactly to those who may use `admin`.
let adminUsable = $derived(!!usableRoles.current?.roles.includes(ADMIN_DATATABLE_ROLE))
let roleItems = $derived.by(() => {
const usable = usableRoles.current
if (!usable?.permissioned) return []
const names = usable.roles.filter((r) => r !== ADMIN_DATATABLE_ROLE)
// A role the SQL names but the caller cannot use is still shown, or the picker would
// misreport what the migration runs as.
if (declaredUp.kind === 'role' && !names.includes(declaredUp.role)) {
names.push(declaredUp.role)
}
const items = names.map((r) => ({
value: r,
label: r === usable.default_role ? `${r} (default)` : r
}))
if (adminUsable || declaredUp.kind === 'none') {
items.push({
value: NO_ROLE,
label: 'No role — runs as admin with full access'
})
}
return items
})
function setRole(value: string | undefined) {
const role = value === NO_ROLE ? undefined : value
codeUp = withMigrationRole(codeUp, role)
// Up and down agree: a rollback run as another role could fail on objects it does not own.
if (enableDown) codeDown = withMigrationRole(codeDown, role)
// Assigning the bound value does not repaint the editor, and its next keystroke would write
// the stale text back.
upEditor?.setCode(codeUp)
if (enableDown) downEditor?.setCode(codeDown)
}
// Set by `open` when the SQL names no role yet: the data table's default is written once its
// roles are known.
let applyDefaultRole = $state(false)
$effect(() => {
// `undefined` until the first answer lands; `null` when it failed.
const usable = usableRoles.current
if (!applyDefaultRole || !isOpen || usableRoles.loading || usable === undefined) return
applyDefaultRole = false
if (!usable?.permissioned || declaredUp.kind !== 'none') return
const role =
usable.default_role !== ADMIN_DATATABLE_ROLE && usable.roles.includes(usable.default_role)
? usable.default_role
: usable.default_role === ADMIN_DATATABLE_ROLE && adminUsable
? undefined
: usable.roles.find((r) => r !== ADMIN_DATATABLE_ROLE)
if (role !== undefined) setRole(role)
})
// Frame the migration body in an explicit transaction so it applies atomically.
function wrapInTransaction(body: string): string {
return `BEGIN;\n\n${body}\n\nEND;`
@@ -72,15 +169,35 @@
}
const PLACEHOLDER = wrapInTransaction('-- Add your migration here')
function malformedMessage(line: string): string {
return `Malformed role annotation \`${line}\`: write it as \`-- role <name>\`, or pick the role above`
}
export function open(prefill?: { name?: string; codeUp?: string; codeDown?: string }) {
// Roles and the default can have changed since the last open (the roles drawer sits next
// to this modal), and the default role is written from this answer.
usableRoles.refetch()
name = prefill?.name ?? ''
// Start from the transaction template; when prefilled from detected DDL,
// wrap that DDL in the same BEGIN; ... END; frame.
codeUp = prefill?.codeUp
? wrapInTransaction(ensureTrailingSemicolon(prefill.codeUp))
: PLACEHOLDER
// wrap that DDL in the same BEGIN; ... END; frame. A role the prefill declares is taken
// out first and put back on top: below `BEGIN;` it would not be read.
const prefillRole = prefill?.codeUp ? parseMigrationRole(prefill.codeUp) : undefined
if (prefill?.codeUp) {
const wrapped = wrapInTransaction(
ensureTrailingSemicolon(withMigrationRole(prefill.codeUp, undefined))
)
codeUp =
prefillRole?.kind === 'role'
? withMigrationRole(wrapped, prefillRole.role)
: prefillRole?.kind === 'malformed'
? `${prefillRole.line}\n${wrapped}`
: wrapped
} else {
codeUp = PLACEHOLDER
}
codeDown = prefill?.codeDown ?? PLACEHOLDER
enableDown = (prefill?.codeDown ?? '') !== ''
applyDefaultRole = prefillRole === undefined || prefillRole.kind === 'none'
tab = 'up'
isOpen = true
// Focus the name field once the modal content has rendered.
@@ -96,6 +213,10 @@
sendUserToast("Invalid migration name: use only letters, digits, '_' and '-'", true)
return
}
if (sqlProblem !== undefined) {
sendUserToast(sqlProblem, true)
return
}
if (run) {
// A new migration gets the highest timestamp, so any still-pending
// migration is earlier: running only this one applies it out of order.
@@ -176,29 +297,65 @@
closeOnOutsideClick={false}
>
<div class="flex flex-col gap-3 w-full grow min-h-0">
<TextInput
bind:this={nameInput}
bind:value={name}
error={nameInvalid}
inputProps={{ placeholder: 'Migration name (e.g. add_index_to_customers)' }}
/>
<div class="flex gap-2 items-center">
<TextInput
bind:this={nameInput}
bind:value={name}
error={nameInvalid}
class="grow"
inputProps={{ placeholder: 'Migration name (e.g. add_index_to_customers)' }}
/>
{#if permissioned}
<Select
transformInputSelectedText={(s) => `Role: ${s}`}
items={roleItems}
bind:value={() => selectedRole, (r) => setRole(r)}
placeholder="Role"
class="w-72"
/>
{/if}
</div>
{#if sqlProblem !== undefined}
<p class="text-xs text-red-500">{sqlProblem}</p>
{:else if permissioned && usableRoles.current?.roles.length === 0}
<p class="text-xs text-secondary">
You can't use any role of this data table, so a migration you create can't be run.
</p>
{/if}
<Tabs bind:selected={tab} class="grow min-h-0">
<Tab value="up" label="Up" />
<Tab value="down" label="Down" />
{#snippet content()}
<TabContent value="up" class="h-80 border rounded-md overflow-hidden">
<SimpleEditor class="h-full" lang="sql" bind:code={codeUp} />
<SimpleEditor bind:this={upEditor} class="h-full" lang="sql" bind:code={codeUp} />
</TabContent>
<TabContent value="down" class="h-80">
<div class="flex flex-col gap-2 h-full">
<Toggle
bind:checked={enableDown}
bind:checked={
() => enableDown,
(checked) => {
enableDown = checked
// The down editor is created by this toggle, so it reads the rewritten text.
if (checked) {
codeDown = withMigrationRole(
codeDown,
declaredUp.kind === 'role' ? declaredUp.role : undefined
)
}
}
}
options={{ right: 'Enable down migration' }}
size="sm"
/>
{#if enableDown}
<div class="grow min-h-0 border rounded-md overflow-hidden">
<SimpleEditor class="h-full" lang="sql" bind:code={codeDown} />
<SimpleEditor
bind:this={downEditor}
class="h-full"
lang="sql"
bind:code={codeDown}
/>
</div>
{/if}
</div>
@@ -209,7 +366,7 @@
<Button
variant="accent"
size="sm"
disabled={creating}
disabled={creating || sqlProblem !== undefined}
on:click={() => create(true)}
dropdownItems={[
{
@@ -497,7 +497,8 @@
aiChatManager.datatableCreationPolicy = {
enabled: !!result.data.datatable,
datatable: result.data.datatable,
schema: result.data.schema
schema: result.data.schema,
roles: result.data.roles
}
if (withPrompt && result.prompt) {
const prompt = result.prompt