Files
windmill/frontend/src/lib/components/dbOps.ts
T
e47aedac0a feat: add SQL migrations for data tables (#9693)
* feat: add datatable_migrations table

* feat: add route to run datatable migrations

* feat: sync datatable migrations as .up.sql/.down.sql files

* feat: add datatable migrate up/down commands and post-push run prompt

* feat: add datatable migrate new command to scaffold migrations

* feat: add datatable migrations management UI

* feat: prompt to create migration on DDL in datatable SQL editors

* feat: support running a single specific datatable migration

* feat: view migration content, run single migration, fix stacked modal

* feat: per-row revert button with out-of-order warning

* fix: avoid migrations list flicker on refresh after an action

* feat: generate initial datatable migration via pg_dump

* fix: surface datatable migration API error details in toasts

* fix: revert created migration if create-and-run fails to run

* fix: include postgres error detail in migration run/rollback failures

* feat: sync datatable migrations as files via the workspace export

* refactor: move datatable migrations to migrations/datatable/ path

* fix: drop redundant datatable_migration label in sync output

* fix: exclude datatable migration sql files from script metadata generation

* feat: run datatable migrations as user-permissioned labeled jobs

* feat: reject invalid datatable migrations on sync push

* feat: datatable migrate up/down default to all datatables, --datatable to target one

* fix: surface postgres error detail when datatable migrations fail to run

* chore: regenerate CLI docs for datatable migrate commands

* feat: default new datatable migration to a BEGIN/END transaction template

* fix: validate datatable migration name and datatable at the API boundary

* fix: ensure detected DDL ends with semicolon when wrapped in transaction

* fix: re-prompt instead of stripping DDL when new-migration modal is cancelled

* feat: refresh datatable schema after running a migration from the SQL REPL

* feat: record db manager DDL on data tables as migrations

* feat: make datatable migrations opt-in per data table

* fix: make migration view editor read-only so its code can scroll

* fix: don't re-prompt DDL guard when creating a migration without running

* feat: generate down migrations for db manager DDL (postgres)

* fix: correct down migration for db manager alters (no double-wrap, serial)

* feat: explain migrations purpose with a tooltip in the migrations modal

* compare paeg

* feat: add datatable_migration kind to workspace diff pipeline

* chore: point ee-repo-ref at datatable_migration git-sync companion

* fix: harden datatable migration version allocation and initial-migration bookkeeping, add tests

* feat: deploy and run datatable migrations on workspace merge

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Refactor + handle datatable setting delete/rename

* refactor: move datatable migration rename/delete cascade into module

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(windmill-utils-internal): bump to 1.7.1 for datatable migration deploy provider methods

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(db-manager): add Migrations button to top bar, make Refresh icon-only

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* BEGIN/END placeholder in down migration

* feat: autofocus migration name input and flag it red when empty

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(datatable-migrations): allow non-admins to create/run/revert migrations, gate only opt in/out

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* border nits

* refresh db manager schema on migrations

* BEGIN/END scaffold in CLI

* feat(cli): push local datatable migrations before running on migrate up

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: flag invalid migration name with red border, not just empty

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: drop random slug from auto-generated migration names

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: offer revert-and-delete when deleting an installed migration

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: record fork merge as a migration when target datatable opts in

* nit

* clone migrations on fork

* windmill-utils-internal

* fix(datatable-migrations): serialize run/rollback with a per-db advisory lock

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(db-manager): fail closed when migrations-status check errors on DDL apply

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: fix generate_initial migration ordering comment to match code

* chore(datatable-migrations): remove unused update_datatable_migrations endpoint

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: run DDL migration guard on the script editor Test button

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* split

* ee-repo-ref

* chore(frontend): sync package-lock with package.json (@emnapi deps)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(datatable-migrations): never resolve instance credentials into migration job args

datatable_database_arg eagerly resolved instance data-table credentials
(including the shared instance-wide Postgres password) and passed them as the
migration job's plaintext `database` arg, landing in v2_job.args. Since the
run route has no admin gate, a non-admin could run a migration and read
args.database to recover the password, granting cross-workspace psql access to
all instance data-table DBs.

Pass a `datatable://<name>` reference for both resource-backed and instance
data tables instead; the pg executor already resolves it to real credentials
server-side at run time, so nothing sensitive is ever stored in the job args.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* nit

* fix: handle dollar-quoting and comments when splitting SQL statements

* feat: deploy datatable migrations on merge with explicit opt-in error

* fix(frontend): sync package-lock with npm 11 peer-dep resolution

npm ci failed with 'Missing: @emnapi/core@1.11.2 / @emnapi/runtime@1.11.2 from
lock file'. @napi-rs/wasm-runtime declares @emnapi/core|runtime ^1.7.1 as
peerDependencies while @rolldown/binding-wasm32-wasi pins them to exactly
1.10.0. Newer npm (bundled with node 24 in CI) installs the peer deps at the
highest match (1.11.2) alongside rolldown's nested 1.10.0, so the ideal tree
needs both versions; the committed lock only had 1.10.0.

Regenerate the lock with npm 11.18 so it carries both 1.11.2 (top-level, for
the peer deps) and 1.10.0 (nested, for rolldown's pin). Verified npm ci passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* nit npm publish

* fix: fail closed on migrations-status error in fork schema merge

* nit CI emnapi/core version

* prevent initial_datatable_migration if migrations already exist

* fix(datatable-migrations): validate persisted data table names as path segments

edit_datatable_config only validated rename segments, not the actual
settings.datatables keys, so a data table could be saved directly under a name
like '..' or one containing '/'. Since new tables default to
migrations_enabled = true, generate_initial_datatable_migration would then
insert a migration row and the sync export would build
migrations/datatable/<name>/... paths from that name, producing malformed or
directory-escaping export paths.

Validate every persisted data table name in edit_datatable_config (alongside
the existing rename checks) and add validate_datatable_path_segment to
generate_initial_datatable_migration for defense in depth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: scope datatable _wm_migrations by data table and cascade renames/deletes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(system_prompts): resolve nested local command groups in CLI docs generator

The CLI docs generator anchored on the first `new Command()` in a file and
never resolved locally-defined command groups passed as
`.command("name", localCmd)`. For datatable this flattened the nested
`migrate` group: it emitted `datatable new/up/down` plus a bare
`datatable migrate`, and mislabeled the datatable command with the migrate
group's description. jobs was broken the same way (its description was pull's,
and pull/push rendered empty).

Anchor block extraction on the `export default`ed command, recurse into
locally-defined `const x = new Command()` groups mounted as subcommands, and
render nested sub-subcommands. Regenerated docs now show
`datatable migrate new/up/down` and `jobs pull/push` with their real
options.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor: drop unreleased _wm_migrations legacy-upgrade handling

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: return datatable migration SQL from getItemValue for the diff drawer

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(frontend): use windmill-utils-internal 1.8.2 for migration diff drawer

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* nit

* nit

* fix: handle datatable migration renames on push and dedupe timestamps

* fix: reject rewriting an already-applied datatable migration on upsert

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): add missing @emnapi/core and @emnapi/runtime lockfile entries

Resolves npm ci EUSAGE failure: the optional cpu:wasm32 @rolldown/binding-wasm32-wasi
declares deps on @emnapi/core@1.11.2 and @emnapi/runtime@1.11.2 that had no resolved
lockfile entries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): datatable migrate up/down default to main datatable, not all

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: fail closed when applied status unreadable on datatable migration rewrite

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: surface full error detail in Database Manager DDL/query errors

* "See migration" button in the toast

* feat: add Enter shortcut to Create-a-migration in the DDL guard

* fix(frontend): warn before running a newly-created datatable migration out of order

The row-level Run action warns when earlier migrations are still pending, but
the create-and-run paths ran a just-created migration with `only` directly,
applying it ahead of older pending migrations without that confirmation.

Reuse the same "Run migration out of order" confirmation across all
create-and-run paths via a shared helper (datatableMigrationUtils):
- NewDataTableMigrationModal "Create and run" (and the DDL guard path)
- DatatableSchemaDiff fork→parent merge
- dbOps schema ops (DB manager create/alter/drop) — the pure factory throws a
  MigrationRunCancelled sentinel on decline, which DBTableEditor treats as a
  silent cancel

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: keep renamed datatable migrations visible in compare view

* fix: record per-migration deployment on datatable migrations disable

* fix(cli): run deployed datatable migrations after workspace merge

The merge command upserted datatable_migration definitions into the target
workspace and reported the item as successfully deployed, but never ran the
migrations. For forked datatables backed by separate databases, this left the
target schema unchanged until someone manually ran `wmill datatable migrate up`,
while the CLI reported a successful merge.

Collect the datatable migrations deployed (not deleted) into the target and,
after the deploy loop, offer to run them via the existing offerToRunNewMigrations
helper — the same post-deploy run prompt the push/sync path uses (interactive
only; `--yes`/non-TTY skip the mutating run, matching push behavior). Export
parseDatatableMigrationDeployPath so the merge path can parse the deployed items.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(backend): serialize datatable migration edits/deletes with the run lock

A migration run snapshots a migration's code_up from datatable_migrations and
only records its version in the data table's _wm_migrations after the job
succeeds. upsert_datatable_migration checked _wm_migrations before allowing an
edit but took no lock, so a concurrent edit could read "not applied yet",
rewrite code_up/code_down, and then the in-flight run would record the version
for the old SQL — leaving _wm_migrations pointing at SQL that was never applied
(migrate up then skips it; rollback runs a down that doesn't match).

Serialize definition rewrites and deletes with the same per-database advisory
lock the run/rollback paths use:
- Factor the connect+advisory-lock into lock_datatable_migration_runs and the
  applied-versions read into read_applied_versions_on_client.
- run_datatable_migrations now snapshots the definitions AFTER taking the lock,
  so code_up can't change between snapshot and version-record.
- upsert (when changing an existing def) and delete take the lock across the
  applied-check and the write; delete now rejects deleting an already-applied
  migration (would orphan its _wm_migrations record), symmetric with upsert.
  Both fail closed if the data table database is unreachable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): stack the out-of-order migration confirm above the DB editor preview

Creating a table on a migrations-enabled data table opened the DB table editor's
"Confirm running the following" preview modal, whose confirm triggers applyDdl,
which then asks for out-of-order confirmation. Both are ConfirmationModals with a
hardcoded z-[9999]; the out-of-order one lives in DBManagerContent (mounted before
the editor), so it rendered behind the still-open preview modal.

Add an optional zIndexClass prop to ConfirmationModal (default z-[9999],
backward-compatible) and give the DB-manager out-of-order confirm z-[10000] so it
stacks on top.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 27672e37df5d9dfde94f19963d5ffcdf8dd5448c

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

Previous ee-repo-ref: 6c287041cd7edd4a77a4bc07ad0e156cec32cce4

New ee-repo-ref: 27672e37df5d9dfde94f19963d5ffcdf8dd5448c

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-07-07 08:25:16 +00:00

595 lines
20 KiB
TypeScript

import {
getLanguageByResourceType,
ColumnIdentity,
type ColumnDef,
type TableMetadata
} from './apps/components/display/dbtable/utils'
import { runScriptAndPollResult } from './jobs/utils'
import type { DBSchema, SQLSchema } from '$lib/stores'
import { stringifySchema } from './copilot/lib'
import type { DbInput, DbType } from './dbTypes'
import { assert } from '$lib/utils'
import { WorkspaceService } from '$lib/gen'
import { pendingMigrations } from './workspaceSettings/datatableMigrationUtils'
import {
buildTableEditorValues,
type TableEditorValues
} from './apps/components/display/dbtable/tableEditor'
import { type AlterTableValues } from './apps/components/display/dbtable/queries/alterTable'
import {
transformForeignKeys,
transformSnowflakeForeignKeys,
type RawForeignKey
} from './apps/components/display/dbtable/queries/relationalKeys'
export type IDbTableOps = {
dbType: DbType
tableKey: string
colDefs: ColumnDef[]
getRows: (params: {
offset: number
limit: number
quicksearch: string
order_by: string
is_desc: boolean
}) => Promise<unknown[]>
getCount: (params: { quicksearch: string }) => Promise<number>
onUpdate?: (
row: { values: object },
colDef: { field: string; datatype: string },
newValue: string
) => Promise<void>
onDelete?: (row: { values: object }) => Promise<void>
onInsert?: (row: { values: object }) => Promise<void>
}
export function dbTableOpsWithPreviewScripts({
input,
tableKey,
colDefs,
workspace,
whereClause,
version
}: {
input: DbInput
tableKey: string
colDefs: ColumnDef[]
workspace: string
// Optional raw SQL predicate AND-ed into the read queries (count + rows).
// Caller-trusted — build it with escaped values.
whereClause?: string
// DuckLake time-travel: when set, reads are pinned to this catalog snapshot
// via `AT (VERSION => n)` (DuckDB/ducklake only). Read-only by nature.
version?: number
}): IDbTableOps {
const dbType = getDbType(input)
const language = getLanguageByResourceType(dbType)
const dbArg = getDatabaseArg(input)
const ducklake = input.type === 'ducklake' ? input.ducklake : undefined
function makeMarker(op: string, payload: Record<string, unknown>): string {
if (ducklake) payload.ducklake = ducklake
return `-- WM_INTERNAL_DB_${op} ${JSON.stringify(payload)}`
}
return {
dbType,
tableKey,
colDefs,
getCount: async ({ quicksearch }) => {
const content = makeMarker('COUNT', {
table: tableKey,
columnDefs: colDefs,
...(whereClause ? { whereClause } : {}),
...(version != undefined ? { version } : {})
})
const result = await runScriptAndPollResult({
workspace,
requestBody: { args: { ...dbArg, quicksearch }, language, content }
})
const count = result?.[0].count as number
return count
},
getRows: async (params) => {
const content = makeMarker('SELECT', {
table: tableKey,
columnDefs: colDefs,
fixPgIntTypes: true,
...(whereClause ? { whereClause } : {}),
...(version != undefined ? { version } : {})
})
let items = (await runScriptAndPollResult({
workspace,
requestBody: { args: { ...dbArg, ...params }, language, content }
})) as unknown[]
if (!items || !Array.isArray(items)) {
throw 'items is not an array'
}
return items
},
onUpdate: async ({ values }, colDef, newValue) => {
const content = makeMarker('UPDATE', {
table: tableKey,
column: colDef,
columns: colDefs
})
await runScriptAndPollResult({
workspace,
requestBody: {
args: { ...dbArg, value_to_update: newValue, ...values },
language,
content
}
})
},
onDelete: async ({ values }) => {
const content = makeMarker('DELETE', { table: tableKey, columns: colDefs })
await runScriptAndPollResult({
workspace,
requestBody: { args: { ...dbArg, ...values }, language, content }
})
},
onInsert: async ({ values }) => {
const content = makeMarker('INSERT', { table: tableKey, columns: colDefs })
await runScriptAndPollResult({
workspace,
requestBody: { args: { ...dbArg, ...values }, language, content }
})
}
}
}
export type DucklakeSnapshot = {
snapshot_id: number
// DuckLake returns this as microseconds-since-epoch serialized as a string
// (TIMESTAMP); callers must convert before formatting.
snapshot_time: string | number
}
/**
* Column metadata of a ducklake table *at a specific snapshot*. The catalog's
* `information_schema` only reflects the current schema, so a time-travel read
* pinned to an older version must enumerate the columns that existed *then* —
* otherwise a column added in a later snapshot would break the `SELECT … AT
* (VERSION => n)`. `DESCRIBE SELECT * FROM … AT (VERSION => n)` gives exactly
* that. Returns minimal `ColumnDef`s (field + datatype) — enough for the
* read-only preview's SELECT/COUNT and grid headers.
*/
export async function fetchDucklakeColumnsAtVersion({
workspace,
ducklake,
tableKey,
version
}: {
workspace: string
ducklake: string
tableKey: string
version: number
}): Promise<ColumnDef[]> {
// Quote each identifier part (schema.table) so a dotted/odd table name can't
// break the statement, and double single-quotes in the catalog name so it
// can't break out of the ATTACH string literal (mirrors the backend's
// `escape_sql_literal`). `version` is a number — injection-safe.
const quoted = tableKey
.split('.')
.map((p) => `"${p.replace(/"/g, '""')}"`)
.join('.')
const ducklakeLit = ducklake.replace(/'/g, "''")
const content =
`ATTACH 'ducklake://${ducklakeLit}' AS __dlv__; USE __dlv__; ` +
`DESCRIBE SELECT * FROM ${quoted} AT (VERSION => ${version});`
const rows = (await runScriptAndPollResult({
workspace,
requestBody: { args: {}, language: 'duckdb', content }
})) as { column_name: string; column_type: string }[]
if (!Array.isArray(rows)) return []
return rows.map((r) => ({
field: r.column_name,
datatype: r.column_type,
defaultvalue: '',
isprimarykey: false,
isidentity: ColumnIdentity.No,
isnullable: 'YES' as const,
isenum: false
}))
}
/**
* List a ducklake table's time-travel history, newest first. DuckLake snapshots
* are catalog-wide commits; passing `table` (schema-qualified, e.g.
* `main.events_daily`) scopes the list to snapshots where the table exists —
* otherwise an `AT (VERSION => n)` read could target a version predating the
* table's creation and error. Runs the `DUCKLAKE_SNAPSHOTS` marker as a duckdb
* preview job (server-side SQL build + ATTACH), so no raw SQL is constructed in
* the client.
*/
export async function fetchDucklakeSnapshots({
workspace,
ducklake,
table
}: {
workspace: string
ducklake: string
table?: string
}): Promise<DucklakeSnapshot[]> {
const content = `-- WM_INTERNAL_DB_DUCKLAKE_SNAPSHOTS ${JSON.stringify({
ducklake,
...(table ? { table } : {})
})}`
const rows = await runScriptAndPollResult({
workspace,
requestBody: { args: {}, language: 'duckdb', content }
})
return Array.isArray(rows) ? (rows as DucklakeSnapshot[]) : []
}
export type IDbSchemaOps = {
onDelete: (params: { tableKey: string; schema?: string }) => Promise<void>
onCreate: (params: { values: TableEditorValues; schema?: string }) => Promise<void>
previewCreateSql: (params: { values: TableEditorValues; schema?: string }) => Promise<string>
onAlter: (params: {
values: AlterTableValues
/** Reverse diff (new → old), used to generate the down migration. */
reverse?: AlterTableValues
schema?: string
}) => Promise<void>
previewAlterSql: (params: { values: AlterTableValues; schema?: string }) => Promise<string>
onCreateSchema: (params: { schema: string }) => Promise<void>
onDeleteSchema: (params: { schema: string }) => Promise<void>
onFetchTableEditorDefinition: (params: {
table: string
schema?: string
colDefs: TableMetadata
}) => Promise<TableEditorValues>
}
/** Thrown by a schema op when the user declines the out-of-order run warning.
* Callers should treat it as a silent cancel (no error toast). */
export class MigrationRunCancelled extends Error {
constructor() {
super('Migration run cancelled')
this.name = 'MigrationRunCancelled'
}
}
export function dbSchemaOpsWithPreviewScripts({
workspace,
input,
confirmRunOutOfOrder
}: {
workspace: string
input: DbInput
/** Asked before running a just-created migration ahead of `pendingCount`
* still-pending earlier ones. Return false to abort (throws MigrationRunCancelled). */
confirmRunOutOfOrder?: (pendingCount: number) => Promise<boolean>
}): IDbSchemaOps {
const dbType = getDbType(input)
const dbArg = getDatabaseArg(input)
const language = getLanguageByResourceType(dbType)
const ducklake = input.type === 'ducklake' ? input.ducklake : undefined
// When managing a data table, schema changes are recorded as migrations
// instead of being run ad-hoc, so the manager stays the source of truth.
const datatableName =
input.type === 'database' && input.resourcePath.startsWith('datatable://')
? input.resourcePath.slice('datatable://'.length)
: undefined
function makeMarker(op: string, payload: Record<string, unknown>): string {
if (ducklake) payload.ducklake = ducklake
return `-- WM_INTERNAL_DB_${op} ${JSON.stringify(payload)}`
}
// Auto-generated migration name, e.g. `create_customers`. The server allocates
// a unique timestamp (bumping on collision), so the name itself need not be unique.
function migrationName(op: string, target: string): string {
const safe = target.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '')
return safe ? `${op}_${safe}` : op
}
// A dropped SERIAL column reports its default as `nextval()` of an owned
// sequence that is dropped along with the column. When the down re-adds such a
// column, recreate it as its SERIAL type so a fresh sequence is created
// instead of referencing the gone one.
const SERIAL_FOR: Record<string, string> = {
BIGINT: 'BIGSERIAL',
INT8: 'BIGSERIAL',
INTEGER: 'SERIAL',
INT: 'SERIAL',
INT4: 'SERIAL',
SMALLINT: 'SMALLSERIAL',
INT2: 'SMALLSERIAL'
}
function reverseSerialFix(reverse: AlterTableValues): AlterTableValues {
return {
...reverse,
operations: reverse.operations.map((op) => {
if (op.kind !== 'addColumn' || !/nextval\s*\(/i.test(op.column.defaultValue ?? '')) {
return op
}
const serial = SERIAL_FOR[(op.column.datatype ?? '').toUpperCase()]
return serial
? { ...op, column: { ...op.column, datatype: serial, defaultValue: undefined } }
: op
})
}
}
// Frame a single (or multi-) statement body in an explicit, `;`-terminated
// transaction, matching the data table migration convention. Some expanded
// markers (e.g. ALTER TABLE) already come wrapped in their own transaction, so
// avoid nesting BEGIN/COMMIT in that case.
function wrapMigration(sql: string): string {
const t = sql.trim()
if (/^BEGIN\b/i.test(t)) return t
return `BEGIN;\n\n${t.endsWith(';') ? t : `${t};`}\n\nEND;`
}
// Apply a DDL marker. For a data table that has migrations enabled this
// creates a migration and runs it (rolling the record back if the run fails);
// otherwise it runs ad-hoc via the internal-db job as before. `downContent`,
// when provided, is expanded into the migration's down SQL (Postgres only).
async function applyDdl(migName: string, content: string, downContent?: string): Promise<void> {
// A DDL edit on a migrations-enabled data table must be captured as a
// migration. Don't swallow a status-check failure by defaulting to ad-hoc:
// that would run the change untracked (schema drift) — exactly what this
// feature prevents. Let the error propagate (fail closed); only fall back to
// ad-hoc when there's no data table, or `enabled === false` is returned.
const status = datatableName
? await WorkspaceService.getDatatableMigrationsStatus({ workspace, datatableName })
: undefined
if (!datatableName || !status?.enabled) {
await runScriptAndPollResult({ workspace, requestBody: { args: dbArg, content, language } })
return
}
// The new migration gets the highest timestamp, so any still-pending
// migration is earlier: running only this one applies it out of order.
// Warn like the row-level Run action does (skipped if no confirm hook).
if (confirmRunOutOfOrder) {
const pending = pendingMigrations(status.migrations).length
if (pending > 0 && !(await confirmRunOutOfOrder(pending))) {
throw new MigrationRunCancelled()
}
}
const codeUp = wrapMigration(await expandMarker(workspace, language, content))
// 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)
}
const created = await WorkspaceService.createDatatableMigration({
workspace,
datatableName,
requestBody: { name: migName, code_up: codeUp, ...(codeDown ? { code_down: codeDown } : {}) }
})
try {
await WorkspaceService.runDatatableMigrations({
workspace,
datatableName,
only: created.timestamp
})
} catch (e) {
await WorkspaceService.deleteDatatableMigration({
workspace,
datatableName,
timestamp: created.timestamp
}).catch(() => {})
throw e
}
}
return {
onDelete: async ({ tableKey, schema }) => {
const content = makeMarker('DROP_TABLE', { table: tableKey, schema })
await applyDdl(migrationName('drop', tableKey), content)
},
onCreate: async ({ values, schema }) => {
const content = makeMarker('CREATE_TABLE', {
name: values.name,
columns: values.columns,
foreignKeys: values.foreignKeys,
schema
})
const downContent = makeMarker('DROP_TABLE', { table: values.name, schema })
await applyDdl(migrationName('create', values.name), content, downContent)
},
previewCreateSql: async ({ values, schema }) => {
const content = makeMarker('CREATE_TABLE', {
name: values.name,
columns: values.columns,
foreignKeys: values.foreignKeys,
schema
})
return expandMarker(workspace, language, content)
},
onAlter: async ({ values, reverse, schema }) => {
const content = makeMarker('ALTER_TABLE', {
name: values.name,
operations: values.operations,
schema
})
// The down is the same alter run in the opposite direction.
const downContent = reverse
? makeMarker('ALTER_TABLE', {
name: reverse.name,
operations: reverseSerialFix(reverse).operations,
schema
})
: undefined
await applyDdl(migrationName('alter', values.name), content, downContent)
},
previewAlterSql: async ({ values, schema }) => {
const content = makeMarker('ALTER_TABLE', {
name: values.name,
operations: values.operations,
schema
})
return expandMarker(workspace, language, content)
},
onCreateSchema: async ({ schema }) => {
const content = makeMarker('CREATE_SCHEMA', { schema })
const downContent = makeMarker('DROP_SCHEMA', { schema })
await applyDdl(migrationName('create_schema', schema), content, downContent)
},
onDeleteSchema: async ({ schema }) => {
const content = makeMarker('DROP_SCHEMA', { schema })
const downContent = makeMarker('CREATE_SCHEMA', { schema })
await applyDdl(migrationName('drop_schema', schema), content, downContent)
},
onFetchTableEditorDefinition: async ({ table, schema, colDefs }) => {
let foreignKeys: import('./apps/components/display/dbtable/tableEditor').TableEditorForeignKey[] =
[]
let pk_constraint_name: string | undefined
// Fetch foreign keys (not supported for BigQuery)
if (dbType !== 'bigquery') {
try {
const fkContent = makeMarker('FOREIGN_KEYS', { table, schema })
const fkResult = await runScriptAndPollResult({
workspace,
requestBody: { args: dbArg, content: fkContent, language }
})
let rawForeignKeys: RawForeignKey[]
if (dbType === 'snowflake') {
rawForeignKeys = transformSnowflakeForeignKeys(fkResult as any[])
} else {
rawForeignKeys = fkResult as RawForeignKey[]
if (rawForeignKeys && Array.isArray(rawForeignKeys)) {
rawForeignKeys = rawForeignKeys.map((fk) => {
const lowerFk: any = {}
Object.keys(fk).forEach((key) => {
lowerFk[key.toLowerCase()] = fk[key]
})
return lowerFk
})
}
}
if (rawForeignKeys && Array.isArray(rawForeignKeys)) {
foreignKeys = transformForeignKeys(rawForeignKeys)
}
} catch (e) {
console.warn('Failed to fetch foreign keys:', e)
}
}
// Fetch primary key constraint name (not supported for BigQuery/MySQL)
if (dbType !== 'bigquery' && dbType !== 'mysql') {
try {
const pkContent = makeMarker('PRIMARY_KEY_CONSTRAINT', { table, schema })
const pkResult = (await runScriptAndPollResult({
workspace,
requestBody: { args: dbArg, content: pkContent, language }
})) as { constraint_name?: string; CONSTRAINT_NAME?: string }[]
if (pkResult && Array.isArray(pkResult) && pkResult.length > 0) {
const pkRecord: any = pkResult[0]
pk_constraint_name = pkRecord?.constraint_name || pkRecord?.CONSTRAINT_NAME || ''
}
} catch (e) {
console.warn('Failed to fetch primary key constraint:', e)
}
}
return buildTableEditorValues({
tableName: table,
metadata: colDefs,
foreignKeys,
pk_constraint_name
})
}
}
}
export async function getDucklakeSchema({
workspace,
ducklake
}: {
workspace: string
ducklake: string
}): Promise<DBSchema> {
let result = await runScriptAndPollResult({
workspace,
requestBody: {
language: 'duckdb',
content: `ATTACH 'ducklake://${ducklake}' AS __ducklake__; ${DUCKLAKE_GET_SCHEMA_QUERY}`,
args: {}
}
})
let schemas = Array.isArray(result) && result.length && (result?.[0]?.['result'] ?? {})
// Safety for agent workers (duckdb ffi lib used to return JSON as stringified json)
if (typeof schemas === 'string') schemas = JSON.parse(schemas)
if (!schemas) throw new Error('Failed to get Ducklake schema: ' + JSON.stringify(result))
assert('schemas is an object', typeof schemas === 'object')
let schema: Omit<SQLSchema, 'stringified'> = {
schema: schemas,
publicOnly: false,
lang: 'ducklake'
}
return { ...schema, stringified: stringifySchema(schema) }
}
// Returns every schema in the ducklake (including empty ones, e.g. freshly created)
// as a nested map { schema: { table: { column: {...} } } }.
const DUCKLAKE_GET_SCHEMA_QUERY = `
SELECT json_group_object(schema_name, COALESCE(schema_data, json_object())) AS result FROM (
SELECT
s.schema_name,
(
SELECT json_group_object(table_name, table_data) FROM (
SELECT
c.table_name,
json_group_object(
c.column_name,
json_object(
'type', c.data_type,
'default', c.column_default,
'required', c.is_nullable == 'NO'
)
) AS table_data
FROM information_schema.columns c
WHERE c.table_catalog = '__ducklake__' AND c.table_schema = s.schema_name
GROUP BY c.table_name
)
) AS schema_data
FROM information_schema.schemata s
WHERE s.catalog_name = '__ducklake__'
)`
export function getDbType(input: DbInput): DbType {
switch (input.type) {
case 'database':
return input.resourceType
case 'ducklake':
return 'duckdb'
}
}
export function getDatabaseArg(input: DbInput | undefined) {
if (input?.type === 'database') {
if (input.resourcePath.startsWith('datatable://')) {
return { database: input.resourcePath }
} else {
return { database: '$res:' + input.resourcePath }
}
}
return {}
}
async function expandMarker(workspace: string, language: string, content: string): Promise<string> {
const response = await fetch(`/api/w/${workspace}/internal_db/expand_marker`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ language, content })
})
if (!response.ok) {
throw new Error(await response.text())
}
const result = (await response.json()) as { code: string }
return result.code
}