From f68e4882e43a42aacdd4cadfdf2b0da7f19c099f Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Fri, 3 Jul 2026 21:29:36 +0200 Subject: [PATCH] feat: deploy datatable migrations on merge with explicit opt-in error --- cli/windmill-utils-internal/src/deploy.ts | 101 ++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/cli/windmill-utils-internal/src/deploy.ts b/cli/windmill-utils-internal/src/deploy.ts index 990fd47edd..7237183ec3 100644 --- a/cli/windmill-utils-internal/src/deploy.ts +++ b/cli/windmill-utils-internal/src/deploy.ts @@ -21,6 +21,7 @@ export type DeployKind = | "resource_type" | "folder" | "schedule" + | "datatable_migration" | "http_trigger" | "websocket_trigger" | "kafka_trigger" @@ -161,6 +162,24 @@ export interface DeployProvider { requestBody: any; }): Promise; deleteFolder(p: { workspace: string; name: string }): Promise; + // Datatable migrations. In the diff, an item's `path` is + // `/_` (see `parseDatatableMigrationDeployPath`). + listDatatableMigrations(p: { workspace: string }): Promise; + upsertDatatableMigration(p: { + workspace: string; + datatableName: string; + requestBody: { + timestamp: number; + name: string; + code_up: string; + code_down?: string; + }; + }): Promise; + deleteDatatableMigration(p: { + workspace: string; + datatableName: string; + timestamp: number; + }): Promise; // Triggers — per-kind dispatch is delegated to the implementor so the shared // module doesn't need to know about each of the 9 trigger services. existsTriggerByKind( @@ -299,6 +318,40 @@ function toError(e: unknown): string { return err.body || err.message || String(e); } +// A datatable-migration diff item's path is `/_` +// (mirrors the backend, e.g. `mydt/20260101000001_create_users`). +function parseDatatableMigrationDeployPath(path: string): { + datatable: string; + timestamp: number; + name: string; +} { + const slash = path.indexOf("/"); + const underscore = slash >= 0 ? path.indexOf("_", slash + 1) : -1; + if (slash < 0 || underscore < 0) { + throw new Error(`Invalid datatable migration path: ${path}`); + } + const datatable = path.slice(0, slash); + const timestamp = Number(path.slice(slash + 1, underscore)); + const name = path.slice(underscore + 1); + if (!datatable || !Number.isFinite(timestamp) || !name) { + throw new Error(`Invalid datatable migration path: ${path}`); + } + return { datatable, timestamp, name }; +} + +// The backend rejects `upsertDatatableMigration` when the target data table +// hasn't opted in to migrations. Turn that opaque 400 into an explicit, +// deploy-context message (falls back to the original error otherwise). +function asMigrationsDisabledError(e: unknown, datatable: string): unknown { + const msg = (e as { body?: string; message?: string })?.body ?? '' + if (typeof msg === "string" && /migrations are not enabled/i.test(msg)) { + return new Error( + `Data table '${datatable}' has not opted in to migrations on the target workspace; enable migrations for it there before deploying its migrations.` + ); + } + return e; +} + // --------------------------------------------------------------------------- // checkItemExists // --------------------------------------------------------------------------- @@ -325,6 +378,12 @@ export async function checkItemExists( return provider.existsFolder({ workspace, name: folderName(path) }); } else if (kind === "schedule") { return provider.existsSchedule({ workspace, path }); + } else if (kind === "datatable_migration") { + const { datatable, timestamp } = parseDatatableMigrationDeployPath(path); + const migrations = await provider.listDatatableMigrations({ workspace }); + return (migrations as { datatable: string; timestamp: number }[]).some( + (m) => m.datatable === datatable && m.timestamp === timestamp + ); } else if (isTriggerKind(kind)) { return provider.existsTriggerByKind(kind, { workspace, path }); } @@ -639,6 +698,41 @@ export async function deployItem( requestBody, }); } + } else if (kind === "datatable_migration") { + const { datatable, timestamp } = parseDatatableMigrationDeployPath(path); + const migrations = await provider.listDatatableMigrations({ + workspace: workspaceFrom, + }); + const migration = ( + migrations as { + datatable: string; + timestamp: number; + name: string; + code_up: string; + code_down?: string; + }[] + ).find((m) => m.datatable === datatable && m.timestamp === timestamp); + if (!migration) { + throw new Error( + `Datatable migration ${path} not found in ${workspaceFrom}` + ); + } + try { + await provider.upsertDatatableMigration({ + workspace: workspaceTo, + datatableName: datatable, + requestBody: { + timestamp: migration.timestamp, + name: migration.name, + code_up: migration.code_up, + ...(migration.code_down != null + ? { code_down: migration.code_down } + : {}), + }, + }); + } catch (e) { + throw asMigrationsDisabledError(e, datatable); + } } else { throw new Error(`Unknown kind: ${kind}`); } @@ -684,6 +778,13 @@ export async function deleteItemInWorkspace( await provider.deleteFolder({ workspace, name: folderName(path) }); } else if (kind === "schedule") { await provider.deleteSchedule({ workspace, path }); + } else if (kind === "datatable_migration") { + const { datatable, timestamp } = parseDatatableMigrationDeployPath(path); + await provider.deleteDatatableMigration({ + workspace, + datatableName: datatable, + timestamp, + }); } else if (isTriggerKind(kind)) { await provider.deleteTriggerByKind(kind, { workspace, path }); } else {