feat(raw apps): pick the data table role when setting up an app

This commit is contained in:
Diego Imbert
2026-09-01 03:24:58 +02:00
parent a4e6eb5e4d
commit 9e6c5cb524
6 changed files with 105 additions and 11 deletions
@@ -647,12 +647,14 @@ export class AIChatManager {
scriptEditorGetLintErrors = $state<(() => ScriptLintResult) | undefined>(undefined)
flowAiChatHelpers = $state<FlowAIChatHelpers | undefined>(undefined)
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's queries run as. */
datatableCreationPolicy = $state<{
enabled: boolean
datatable: string | undefined
schema: string | undefined
}>({ enabled: false, datatable: undefined, schema: undefined })
role?: string | undefined
}>({ enabled: false, datatable: undefined, schema: undefined, role: undefined })
pendingNewCode = $state<string | undefined>(undefined)
apiTools = $state<Tool<any>[]>([])
aiChatInput = $state<AIChatInput | null>(null)
@@ -921,9 +921,14 @@ 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}')`
// `wmill.datatable()` for 'main' without a role — the defaults — and the name
// and role spelled out otherwise. A role names the privileges the app's own
// queries run with, so it has to be in the code the model writes.
const datatableCall = policy.role
? `wmill.datatable('${datatableName}', '${policy.role}')`
: datatableName === 'main'
? 'wmill.datatable()'
: `wmill.datatable('${datatableName}')`
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.
@@ -878,7 +878,8 @@
aiChatManager.datatableCreationPolicy = {
enabled: data.datatable !== undefined,
datatable: data.datatable,
schema: data.schema
schema: data.schema,
role: data.role
}
// Start auto-snapshot
@@ -900,9 +901,14 @@
// 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 ||
data.role !== policy.role
) {
data.datatable = policy.datatable
data.schema = policy.schema
data.role = policy.role
}
})
@@ -22,7 +22,9 @@
import { type DataTableRef, type RawAppData, formatDataTableRef } from './dataTableRefUtils'
import {
createDatatablesResource,
createRolesResource,
createSchemasResource,
rolesWorthPicking,
toDatatableItems,
toSchemaItems
} from './datatableUtils.svelte'
@@ -72,6 +74,25 @@
() => selectedDatatable,
() => opWs
)
const roles = createRolesResource(
() => selectedDatatable,
() => opWs
)
let selectedRole = $state<string | undefined>(undefined)
const availableRoles = $derived(roles.current.roles)
const showRolePicker = $derived(rolesWorthPicking(availableRoles))
// The picked role belongs to the data table it was picked on, and the one it
// defaults to is what the app gets without saying anything.
$effect(() => {
const available = roles.current.roles
if (selectedRole === undefined || !available.includes(selectedRole)) {
selectedRole = available.includes(roles.current.defaultRole)
? roles.current.defaultRole
: available[0]
}
})
const availableDatatables = $derived(datatables.current)
const availableSchemas = $derived(schemas.current)
@@ -148,7 +169,8 @@
input: {
type: 'database',
resourceType: 'postgresql',
resourcePath: `datatable://${selectedDatatable}`
resourcePath: `datatable://${selectedDatatable}`,
role: showRolePicker ? selectedRole : undefined
}
})
await dbOps.onCreateSchema({ schema: newSchemaName })
@@ -164,9 +186,10 @@
? {
tables: formattedTables,
datatable: selectedDatatable,
schema: effectiveSchema
schema: effectiveSchema,
role: showRolePicker ? selectedRole : undefined
}
: { tables: formattedTables, datatable: undefined, schema: undefined }
: { tables: formattedTables, datatable: undefined, schema: undefined, role: undefined }
const policy: Policy = {
on_behalf_of: $userStore?.username.includes('@')
@@ -269,6 +292,23 @@
class="w-40"
/>
</div>
{#if showRolePicker}
<div class="flex flex-col gap-1">
<label class="text-xs text-emphasis font-semibold" for="datatable-role"
>Role</label
>
<Select
id="datatable-role"
disablePortal
items={availableRoles.map((r) => ({ value: r, label: r }))}
bind:value={selectedRole}
clearable={false}
placeholder="Role"
size="sm"
class="w-40"
/>
</div>
{/if}
<div>
<span class="text-xs text-emphasis font-semibold">Schema</span>
<div class="flex flex-row gap-1 w-full items-center">
@@ -16,13 +16,17 @@ export interface RawAppData {
datatable: string | undefined
/** The schema for table creation (if specified) */
schema: string | undefined
/** The data table role the app's queries and table creation run as; absent
* means the data table's default one. */
role?: string
}
/** Default data configuration */
export const DEFAULT_DATA: RawAppData = {
tables: [],
datatable: undefined,
schema: undefined
schema: undefined,
role: undefined
}
/**
@@ -2,6 +2,7 @@ import { resource } from 'runed'
import { workspaceStore, dbSchemas } from '$lib/stores'
import { WorkspaceService } from '$lib/gen'
import { getDbSchemas } from '$lib/components/apps/components/display/dbtable/metadata'
import { ADMIN_DATATABLE_ROLE } from '$lib/components/dbTypes'
import { get } from 'svelte/store'
/**
@@ -59,6 +60,42 @@ export function createSchemasResource(
})
}
/**
* Creates a resource that loads the roles the caller may use on a datatable,
* and the one it defaults to.
*/
export function createRolesResource(
getDatatable: () => string | undefined,
getWorkspace: () => string | undefined = () => get(workspaceStore)
) {
return resource(
() => [getDatatable() ?? '', getWorkspace() ?? ''] as const,
async ([datatableName, workspace]): Promise<{ roles: string[]; defaultRole: string }> => {
if (!datatableName || !workspace) return { roles: [], defaultRole: ADMIN_DATATABLE_ROLE }
try {
const res = await WorkspaceService.listUsableDatatableRoles({ workspace, datatableName })
return {
roles: res.enabled ? res.roles : [],
defaultRole: res.default_role
}
} catch (e) {
console.error('Failed to load datatable roles:', e)
return { roles: [], defaultRole: ADMIN_DATATABLE_ROLE }
}
},
{ initialValue: { roles: [], defaultRole: ADMIN_DATATABLE_ROLE } }
)
}
/**
* Whether naming a role says anything here: a data table without permissions has
* none to pick, and one whose single role is the implicit `admin` has no choice
* to offer.
*/
export function rolesWorthPicking(roles: string[]): boolean {
return roles.length > 1 || (roles.length === 1 && roles[0] !== ADMIN_DATATABLE_ROLE)
}
/**
* Converts datatables array to Select items format
*/