Merge remote-tracking branch 'origin/main' into tl/workspace-to-hub

This commit is contained in:
Diego Imbert
2026-07-07 11:35:22 +02:00
158 changed files with 8827 additions and 1012 deletions
+19
View File
@@ -1,5 +1,24 @@
# Changelog
## [1.751.0](https://github.com/windmill-labs/windmill/compare/v1.750.0...v1.751.0) (2026-07-06)
### Features
* add cosmetic dev/staging label for dev workspaces ([#9959](https://github.com/windmill-labs/windmill/issues/9959)) ([fd8e64d](https://github.com/windmill-labs/windmill/commit/fd8e64d11fea3ffdb7858100c67cb2e9ca841ed6))
* **auth:** add runtime NO_AUTH mode for authentication bypass ([#9962](https://github.com/windmill-labs/windmill/issues/9962)) ([91e1b08](https://github.com/windmill-labs/windmill/commit/91e1b087a206efb7189824b4184e1f3f4cda7211))
* **frontend:** custom skills — detail modal, batch manage, shared validation ([#9847](https://github.com/windmill-labs/windmill/issues/9847)) ([2e14302](https://github.com/windmill-labs/windmill/commit/2e14302e4abbad595584806bff12548d520fcb58))
* **pipelines:** auto-derive cascade edges from ducklake/s3 reads (+ muted-read badge) ([#9963](https://github.com/windmill-labs/windmill/issues/9963)) ([3dcd394](https://github.com/windmill-labs/windmill/commit/3dcd3949a14199b106506994ea31ca3de7e636b3))
### Bug Fixes
* **ai:** centralize Anthropic Messages API routing across completion paths ([#9960](https://github.com/windmill-labs/windmill/issues/9960)) ([cc2f638](https://github.com/windmill-labs/windmill/commit/cc2f638de6cebeffb9fee1d4835a0cfd565af86c))
* **assets:** responsive layout for small screens ([#9961](https://github.com/windmill-labs/windmill/issues/9961)) ([45946d1](https://github.com/windmill-labs/windmill/commit/45946d1185c0bd07948d4d8454880c2801571f9d))
* **cli:** quote non-identifier property names in resource-type namespace ([#9964](https://github.com/windmill-labs/windmill/issues/9964)) ([dc6b997](https://github.com/windmill-labs/windmill/commit/dc6b99775b550e7433fee8a159c30eaf296500c5))
* critical alerts modal mute toggles no longer close popover or fail to save ([#9969](https://github.com/windmill-labs/windmill/issues/9969)) ([6587019](https://github.com/windmill-labs/windmill/commit/6587019d263374ee5707d258f5d8eec7e73c690d))
* **frontend:** theme-aware code block background in prose markdown ([#9968](https://github.com/windmill-labs/windmill/issues/9968)) ([9821596](https://github.com/windmill-labs/windmill/commit/9821596251cff698958ffbfbd11fffa6a7988c6c))
## [1.750.0](https://github.com/windmill-labs/windmill/compare/v1.749.0...v1.750.0) (2026-07-06)
+18 -17
View File
@@ -1,7 +1,7 @@
ARG DEBIAN_IMAGE=debian:bookworm-slim
ARG RUST_IMAGE=rust:1.93-slim-bookworm
ARG DEBIAN_IMAGE=debian:trixie-slim
ARG RUST_IMAGE=rust:1.93-slim-trixie
FROM debian:bookworm-slim AS nsjail
FROM debian:trixie-slim AS nsjail
WORKDIR /nsjail
@@ -9,12 +9,12 @@ RUN apt-get -y update \
&& apt-get install -y \
bison=2:3.8.* \
flex=2.6.* \
g++=4:12.2.* \
gcc=4:12.2.* \
git=1:2.39.* \
g++=4:14.2.* \
gcc=4:14.2.* \
git=1:2.47.* \
libprotobuf-dev=3.21.* \
libnl-route-3-dev=3.7.* \
make=4.3-4.1 \
make=4.4.* \
pkg-config=1.8.* \
protobuf-compiler=3.21.*
@@ -44,7 +44,7 @@ FROM rust_base AS windmill_duckdb_ffi_internal_builder
WORKDIR /windmill-duckdb-ffi-internal
RUN apt-get update && apt-get install -y clang=1:14.0-55.* libclang-dev=1:14.0-55.* cmake=3.25.* && \
RUN apt-get update && apt-get install -y clang=1:19.0* libclang-dev=1:19.0* cmake=3.31.* && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
@@ -98,7 +98,7 @@ ARG features=""
COPY --from=planner /windmill/recipe.json recipe.json
RUN apt-get update && apt-get install -y libxml2-dev=2.9.* libxmlsec1-dev=1.2.* libkrb5-dev libsasl2-dev libcurl4-openssl-dev clang=1:14.0-55.* libclang-dev=1:14.0-55.* cmake=3.25.* && \
RUN apt-get update && apt-get install -y libxml2-dev=2.12.* libxmlsec1-dev=1.2.* libkrb5-dev libsasl2-dev libcurl4-openssl-dev clang=1:19.0* libclang-dev=1:19.0* cmake=3.31.* && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
@@ -135,7 +135,6 @@ FROM ${DEBIAN_IMAGE}
ARG TARGETPLATFORM
ARG POWERSHELL_VERSION=7.5.0
ARG POWERSHELL_DEB_VERSION=7.5.0-1
ARG KUBECTL_VERSION=1.28.7
ARG HELM_VERSION=3.14.3
# NOTE: If changing, also change go version in workspace dependencies template at WorkspaceDependenciesEditor.svelte
@@ -183,12 +182,14 @@ RUN if [ "$WITH_GIT" = "true" ]; then \
&& rm -rf /var/lib/apt/lists/*; \
else echo 'Building the image without git'; fi;
# PowerShell ships as a tarball: the upstream .deb depends on libicu<=74 which no longer exists in trixie
RUN if [ "$WITH_POWERSHELL" = "true" ]; then \
if [ "$TARGETPLATFORM" = "linux/amd64" ]; then apt-get update -y && apt install libicu72 -y && wget -O 'pwsh.deb' "https://github.com/PowerShell/PowerShell/releases/download/v${POWERSHELL_VERSION}/powershell_${POWERSHELL_DEB_VERSION}.deb_amd64.deb" && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && \
dpkg --install 'pwsh.deb' && \
rm 'pwsh.deb'; \
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then apt-get update -y && apt install libicu72 -y && wget -O powershell.tar.gz "https://github.com/PowerShell/PowerShell/releases/download/v${POWERSHELL_VERSION}/powershell-${POWERSHELL_VERSION}-linux-arm64.tar.gz" && apt-get clean \
case "$TARGETPLATFORM" in \
"linux/amd64") pwsh_arch=x64 ;; \
"linux/arm64") pwsh_arch=arm64 ;; \
*) pwsh_arch="" ;; \
esac; \
if [ -n "$pwsh_arch" ]; then apt-get update -y && apt install libicu76 -y && wget -O powershell.tar.gz "https://github.com/PowerShell/PowerShell/releases/download/v${POWERSHELL_VERSION}/powershell-${POWERSHELL_VERSION}-linux-${pwsh_arch}.tar.gz" && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && \
mkdir -p /opt/microsoft/powershell/7 && \
tar zxf powershell.tar.gz -C /opt/microsoft/powershell/7 && \
@@ -292,7 +293,7 @@ RUN bun install -g windmill-cli \
RUN curl -fsSL https://claude.ai/install.sh | bash \
&& cp /root/.local/share/claude/versions/* /usr/bin/claude
COPY --from=php:8.3.30-cli-bookworm /usr/local/bin/php /usr/bin/php
COPY --from=php:8.3.30-cli-trixie /usr/local/bin/php /usr/bin/php
COPY --from=composer:2.9.5 /usr/bin/composer /usr/bin/composer
# add the docker client to call docker from a worker if enabled
@@ -303,7 +304,7 @@ ENV CARGO_HOME="/tmp/windmill/cache/cargo"
ENV LD_LIBRARY_PATH="."
# nsjail runtime deps and binary
RUN apt-get update && apt-get install -y --no-install-recommends libprotobuf32 libnl-route-3-200 libnl-3-200 \
RUN apt-get update && apt-get install -y --no-install-recommends libprotobuf32t64 libnl-route-3-200 libnl-3-200 \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY --from=nsjail /nsjail/nsjail /bin/nsjail
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM datatable_migrations WHERE workspace_id = $1 AND datatable = ANY($2::text[])",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"TextArray"
]
},
"nullable": []
},
"hash": "00e9fc3fed9379264881c58df9404ab3569a0611b33c261602d62e9c847c583e"
}
@@ -46,11 +46,11 @@
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true
]
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM datatable_migrations WHERE workspace_id = $1 AND datatable = $2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "15c0584e9eb078442f6928adb894324c47e010e3554c14e755a7e045453745bb"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT ws.datatable->'datatables' AS datatable_name\n FROM workspace_settings ws\n WHERE ws.workspace_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "datatable_name",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "16a67b92dbd32024838983184e6974f2ce577b78decd6b821096c9f2f252ae8b"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE datatable_migrations SET datatable = $3 WHERE workspace_id = $1 AND datatable = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Varchar"
]
},
"nullable": []
},
"hash": "2c543583bcf7bcaf2f422638ce0741a2e193f18ffa11d08e8ca49cef5c3b850c"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM datatable_migrations WHERE workspace_id = $1 AND datatable = $2 AND timestamp = $3 RETURNING name",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Int8"
]
},
"nullable": [
false
]
},
"hash": "3c057e0284f1219865b8f7dc6eb3c1293c4b72d0b52abeefd1e954617984a2d8"
}
@@ -0,0 +1,36 @@
{
"db_name": "PostgreSQL",
"query": "SELECT name, code_up, code_down FROM datatable_migrations WHERE workspace_id = $1 AND datatable = $2 AND timestamp = $3",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "code_up",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "code_down",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Int8"
]
},
"nullable": [
false,
false,
true
]
},
"hash": "44c4e9848bc97b66a49d5454d30d40dfe1ffe51839884f3e9e9360d9e17b5afd"
}
@@ -0,0 +1,19 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace\n (id, name, owner, parent_workspace_id, is_dev_workspace, dev_workspace_label)\n VALUES ($1, $2, $3, $4, $5, $6)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Varchar",
"Bool",
"Varchar"
]
},
"nullable": []
},
"hash": "530a797e67ff352471f1b34f260dd530a653081619e6d3132bf07996520b1e25"
}
@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM datatable_migrations WHERE workspace_id = $1 AND datatable = $2 RETURNING timestamp, name",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "timestamp",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "564f4bdf43135c603518ef35a3e95bde7f479877018980cffe1c1e9d34aad59e"
}
@@ -15,7 +15,7 @@
]
},
"nullable": [
true
null
]
},
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM datatable_migrations WHERE workspace_id = $1 AND datatable = $2 AND timestamp = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Int8"
]
},
"nullable": []
},
"hash": "5b67c3af6477d7029d118ac259a7535d4d962c328a060f33c07191ac3e033e82"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT ws.datatable->'datatables'->$2 FROM workspace_settings ws WHERE ws.workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "5cca3509374b1ddd8707c930bc382734e4ca6cad2d849f75a8f8a249f83df83f"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace SET dev_workspace_label = $1 WHERE id = $2 AND is_dev_workspace RETURNING id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Varchar",
"Text"
]
},
"nullable": [
false
]
},
"hash": "63d6d968905cf82fb3bb0577d41a29a8f88010891fcf3eaf4761a57461f97703"
}
@@ -0,0 +1,30 @@
{
"db_name": "PostgreSQL",
"query": "SELECT name, code_down FROM datatable_migrations WHERE workspace_id = $1 AND datatable = $2 AND timestamp = $3",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "code_down",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Int8"
]
},
"nullable": [
false,
true
]
},
"hash": "6ac00d65b3b7707cba9456171d4332c696e12b52066a5ae2ab10fe3b5bd0f3d0"
}
@@ -0,0 +1,19 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO datatable_migrations (workspace_id, datatable, timestamp, name, code_up, code_down) VALUES ($1, $2, $3, $4, $5, $6)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Int8",
"Varchar",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "760908f44cafb500e4ba2515d1b030390d9510e73e9b7df347b697d6dc7aefe6"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT (ws.datatable->'datatables'->$2->>'migrations_enabled')::boolean FROM workspace_settings ws WHERE ws.workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "bool",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "798dc8ce1c80b5ebd8120f55b6c9f909bf4babb29910514753cb822dde4e7ca9"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, name, dev_workspace_label FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "dev_workspace_label",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
true
]
},
"hash": "868985685d95197efc534bb2f3e0c956bea94dffb46b3b838c096f04b66d6c52"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace (id, name, owner, deleted, premium, parent_workspace_id, is_dev_workspace, dev_workspace_label)\n SELECT $1, $2, owner, false, premium,\n CASE WHEN $4 THEN parent_workspace_id ELSE NULL END, $5,\n CASE WHEN $5 THEN dev_workspace_label ELSE NULL END\n FROM workspace WHERE id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Text",
"Bool",
"Bool"
]
},
"nullable": []
},
"hash": "8ed229e88dc49b0ba7328d48f991493fcdbab4ae91cae271ea911f5b14ecf0d4"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT MAX(timestamp) FROM datatable_migrations WHERE workspace_id = $1 AND datatable = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "max",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "95e370a82ef46d9310f77a99b437a20a5773113e290d69a016363543067baf14"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET datatable = jsonb_set(datatable, ARRAY['datatables', $2::text, 'migrations_enabled'], 'false'::jsonb) WHERE workspace_id = $1 AND jsonb_exists(datatable->'datatables', $2) RETURNING 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "9eea74f68a0c99d5ce6601fd854c1d18df0ab30f528ef4ce7910c43dd44f4cfb"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace SET parent_workspace_id = $1, is_dev_workspace = true, dev_workspace_label = $3 WHERE id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Varchar"
]
},
"nullable": []
},
"hash": "9f567f04f67ce3b197eaa641eaf2d0bbe1f5aff27187505778b6abe2c87a5d01"
}
@@ -0,0 +1,46 @@
{
"db_name": "PostgreSQL",
"query": "SELECT datatable, timestamp, name, code_up, code_down FROM datatable_migrations WHERE workspace_id = $1 ORDER BY datatable, timestamp ASC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "datatable",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "timestamp",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "code_up",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "code_down",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
true
]
},
"hash": "a8cec061756c7be61af829f4a5207ba6333ad5feb4557a9341427e9e68608025"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO datatable_migrations (workspace_id, datatable, timestamp, name, code_up, code_down)\n SELECT $2, datatable, timestamp, name, code_up, code_down\n FROM datatable_migrations WHERE workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Varchar"
]
},
"nullable": []
},
"hash": "aef927110ef4cff51d3faabb0c389730dd215f4e90d105f0e86025f0ac42f020"
}
@@ -0,0 +1,70 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id,\n workspace.is_dev_workspace, workspace.dev_workspace_label,\n CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings,\n usr.disabled\n FROM workspace\n JOIN usr ON usr.workspace_id = workspace.id\n JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id\n WHERE usr.email = $1 AND workspace.deleted = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "color",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "parent_workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "is_dev_workspace",
"type_info": "Bool"
},
{
"ordinal": 6,
"name": "dev_workspace_label",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "operator_settings",
"type_info": "Jsonb"
},
{
"ordinal": 8,
"name": "disabled",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
true,
true,
false,
true,
null,
false
]
},
"hash": "af19b9e3deb4f5c9e6ba77963a5da5c60aa6878b2c77f6028a68d64f797c3322"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2))",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "pg_advisory_xact_lock",
"type_info": "Void"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "b3200181380df2f645bfce270dc1cf520939fa4fa4b65371d11db7416ba0b14c"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO datatable_migrations (workspace_id, datatable, timestamp, name, code_up, code_down) VALUES ($1, $2, $3, 'initial', $4, NULL)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Int8",
"Text"
]
},
"nullable": []
},
"hash": "b96c1a720654ae8ae52e8098cc18d887920beaec97696a17da020a2adfb052f0"
}
@@ -0,0 +1,19 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO datatable_migrations (workspace_id, datatable, timestamp, name, code_up, code_down) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (workspace_id, datatable, timestamp) DO UPDATE SET name = EXCLUDED.name, code_up = EXCLUDED.code_up, code_down = EXCLUDED.code_down",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Int8",
"Varchar",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "c5639d48c92d6863f16f97de91eae590d0ed2f4b7831956bb429a47c478f7c1f"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT ws.datatable->'datatables' FROM workspace_settings ws WHERE ws.workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "c9d9c08505e69790154876f50f90d503a92e44e291a76a53ef09ded619edfacb"
}
@@ -0,0 +1,30 @@
{
"db_name": "PostgreSQL",
"query": "SELECT datatable, timestamp FROM datatable_migrations WHERE workspace_id = $1 AND datatable = ANY($2) AND timestamp = ANY($3)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "datatable",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "timestamp",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"TextArray",
"Int8Array"
]
},
"nullable": [
false,
false
]
},
"hash": "cf2e74dcd0992f22eb3b62995fd0099ee5f46dafba8f323cf002e329ac69d1ac"
}
@@ -0,0 +1,41 @@
{
"db_name": "PostgreSQL",
"query": "SELECT timestamp, name, code_up, code_down FROM datatable_migrations WHERE workspace_id = $1 AND datatable = $2 ORDER BY timestamp ASC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "timestamp",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "code_up",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "code_down",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
true
]
},
"hash": "e5d8d46b9431033fa7572a880ee0e74e14afde3c02000a88e696d7b6adcad0d6"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET datatable = jsonb_set(datatable, ARRAY['datatables', $2::text, 'migrations_enabled'], 'true'::jsonb) WHERE workspace_id = $1 AND jsonb_exists(datatable->'datatables', $2) RETURNING 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "ec8e502f9c2926e578e02cd164a7a3e43ef12b97d55353f86a9a368617efccca"
}
@@ -0,0 +1,46 @@
{
"db_name": "PostgreSQL",
"query": "SELECT datatable, timestamp, name, code_up, code_down FROM datatable_migrations WHERE workspace_id = $1 ORDER BY datatable, timestamp",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "datatable",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "timestamp",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "code_up",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "code_down",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
true
]
},
"hash": "f89b024949f29eb5142744635914b94fd84c1bb64e9659f798df2888848894bd"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job SET labels = (\n SELECT array_agg(DISTINCT l)\n FROM unnest(coalesce(labels, ARRAY[]::TEXT[]) || $2) l\n ) WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"TextArray"
]
},
"nullable": []
},
"hash": "fc59d9b911529de75c466593252358581f5716b3b58dc2e6b9b9282c50b1b66b"
}
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT timestamp, name, code_up FROM datatable_migrations WHERE workspace_id = $1 AND datatable = $2 ORDER BY timestamp ASC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "timestamp",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "code_up",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "fcb0cf55f5c9047066a6fdd62bf6f85238cbf189ccf18191493d6357e269d12d"
}
+93 -92
View File
@@ -2491,18 +2491,18 @@ dependencies = [
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
version = "0.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
@@ -2510,27 +2510,27 @@ dependencies = [
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.12"
version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
[[package]]
name = "crossterm_winapi"
@@ -13746,7 +13746,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-nats",
@@ -13828,7 +13828,7 @@ dependencies = [
[[package]]
name = "windmill-ai"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"async-stream",
"async-trait",
@@ -13861,7 +13861,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -13874,7 +13874,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"argon2",
@@ -14012,7 +14012,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14035,7 +14035,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14050,7 +14050,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14076,7 +14076,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -14086,7 +14086,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14103,7 +14103,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"axum 0.8.9",
"base64 0.22.1",
@@ -14125,7 +14125,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14148,7 +14148,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14164,7 +14164,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14185,7 +14185,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14206,7 +14206,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14220,7 +14220,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-nats",
@@ -14255,7 +14255,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14280,7 +14280,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"axum 0.8.9",
"flate2",
@@ -14298,7 +14298,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14320,7 +14320,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14340,7 +14340,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14377,7 +14377,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14405,7 +14405,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"lazy_static",
"serde",
@@ -14417,7 +14417,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"argon2",
"axum 0.8.9",
@@ -14442,7 +14442,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14456,7 +14456,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14473,6 +14473,7 @@ dependencies = [
"sqlx",
"strum",
"tokio",
"tokio-postgres",
"tracing",
"uuid",
"windmill-api-auth",
@@ -14490,7 +14491,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"chrono",
"lazy_static",
@@ -14504,7 +14505,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14523,7 +14524,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -14625,7 +14626,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"chrono",
"itertools 0.14.0",
@@ -14644,7 +14645,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"regex",
"serde",
@@ -14659,7 +14660,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -14683,7 +14684,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"futures",
@@ -14700,7 +14701,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -14716,7 +14717,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-trait",
@@ -14737,7 +14738,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-trait",
@@ -14768,7 +14769,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -14793,7 +14794,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-stream",
@@ -14827,7 +14828,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"futures",
@@ -14845,7 +14846,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -14854,7 +14855,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -14866,7 +14867,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"serde_json",
@@ -14878,7 +14879,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"gosyn",
@@ -14890,7 +14891,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -14902,7 +14903,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"serde_json",
@@ -14914,7 +14915,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -14925,7 +14926,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -14936,7 +14937,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -14948,7 +14949,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -14959,7 +14960,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -14981,7 +14982,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"serde_json",
@@ -14993,7 +14994,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15007,7 +15008,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -15024,7 +15025,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15037,7 +15038,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"serde",
@@ -15049,7 +15050,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15067,7 +15068,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -15083,7 +15084,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -15099,7 +15100,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"serde",
@@ -15110,7 +15111,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -15149,7 +15150,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"const_format",
@@ -15188,7 +15189,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -15199,7 +15200,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -15233,7 +15234,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15257,7 +15258,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15290,7 +15291,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15323,7 +15324,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15343,7 +15344,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15377,7 +15378,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15413,7 +15414,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15436,7 +15437,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15460,7 +15461,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-nats",
@@ -15484,7 +15485,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15519,7 +15520,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15547,7 +15548,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15572,7 +15573,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"bitflags 2.13.0",
@@ -15591,7 +15592,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-once-cell",
@@ -15701,7 +15702,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"bytes",
"futures",
@@ -16427,18 +16428,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.52"
version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.52"
version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71"
dependencies = [
"proc-macro2",
"quote",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.750.0"
version = "1.751.0"
authors.workspace = true
edition.workspace = true
@@ -87,7 +87,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
version = "1.750.0"
version = "1.751.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
0de2412ff0734b11e12ba378c9bcc373ff9ae800
27672e37df5d9dfde94f19963d5ffcdf8dd5448c
@@ -0,0 +1 @@
DROP TABLE datatable_migrations;
@@ -0,0 +1,21 @@
-- SQL migrations defined per data table within a workspace.
-- `datatable` is the target data table name, `name` is the migration name
-- (e.g. add_index_to_customers), and `timestamp` is the migration version
-- (YYYYMMDDHHMMSS), recorded as `version` in the data table's `_wm_migrations`
-- table once applied.
CREATE TABLE datatable_migrations (
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
datatable VARCHAR(255) NOT NULL,
timestamp BIGINT NOT NULL,
name VARCHAR(255) NOT NULL,
code_up TEXT NOT NULL,
code_down TEXT,
PRIMARY KEY (workspace_id, datatable, timestamp)
);
-- No standalone index: the (workspace_id, datatable, timestamp) primary-key btree
-- already serves both `WHERE workspace_id = $1` and `WHERE workspace_id = $1 AND
-- datatable = $2` lookups via its leading columns.
GRANT ALL ON datatable_migrations TO windmill_user;
GRANT ALL ON datatable_migrations TO windmill_admin;
@@ -0,0 +1 @@
ALTER TABLE workspace DROP COLUMN dev_workspace_label;
@@ -0,0 +1,4 @@
-- Cosmetic display label for a dev workspace: NULL/'dev' render as "dev", 'staging' renders as "stg".
-- Only meaningful when is_dev_workspace = true; changes nothing about behavior (locking, promote and
-- compare all key off is_dev_workspace / parent_workspace_id). The value is validated in the handler.
ALTER TABLE workspace ADD COLUMN dev_workspace_label VARCHAR;
+24 -24
View File
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6272,7 +6272,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6284,7 +6284,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"convert_case",
"serde",
@@ -6293,7 +6293,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6305,7 +6305,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6317,7 +6317,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6329,7 +6329,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6341,7 +6341,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6353,7 +6353,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6364,7 +6364,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6375,7 +6375,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6387,7 +6387,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6398,7 +6398,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6420,7 +6420,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6432,7 +6432,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6446,7 +6446,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6463,7 +6463,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6476,7 +6476,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"serde",
@@ -6488,7 +6488,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6506,7 +6506,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6522,7 +6522,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6538,7 +6538,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6570,7 +6570,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"serde",
@@ -6581,7 +6581,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.750.0"
version = "1.751.0"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.750.0"
version = "1.751.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
@@ -528,6 +528,16 @@ pub struct PipelineAnnotations {
pub column_lineage: Vec<ColumnLineage>,
pub macros: bool,
pub use_libs: Vec<String>,
// `// mute <asset>` — suppress the auto-derived cascade edge for a read
// that would otherwise trigger this script (a lookup / slowly-changing
// dimension you read every run but don't want to re-run on). Only Asset
// specs are stored; native trigger kinds are never auto-derived, so
// muting them is meaningless.
pub mute: Vec<TriggerSpec>,
// `// mute all` — opt out of auto-derivation entirely for this script.
// Falls back to explicit-`// on`-only semantics. Explicit `// on` edges
// are unaffected.
pub mute_all: bool,
}
impl ParseAssetsOutput {
@@ -956,6 +966,23 @@ pub fn parse_pipeline_annotations(code: &str) -> PipelineAnnotations {
continue;
}
// `// mute all` opts out of auto-derived cascade edges entirely;
// `// mute <asset>` suppresses the one edge. Only asset refs are
// muteable — native trigger kinds are never auto-derived. Checked
// before the generic `on`/asset shorthand (a complete word, so
// prose like `// muted for now` never matches).
if let Some(after_kw) = consume_keyword(rest, "mute") {
let arg = after_kw.trim();
if arg == "all" {
out.mute_all = true;
} else if let Some(spec @ TriggerSpec::Asset { .. }) = parse_trigger_spec(arg) {
if !out.mute.contains(&spec) {
out.mute.push(spec);
}
}
continue;
}
// `data_test` is checked before `on`/asset shorthands and is a complete
// word (so it never collides with the `// test:` CI annotation, which
// has no whitespace after `test`). Accumulates — every well-formed line
@@ -769,5 +769,109 @@
"tag": null,
"retry": null
}
},
{
"name": "mute suppresses a single ducklake read edge",
"code": "// pipeline\n// mute ducklake://main.orders\nselect 1",
"expected": {
"in_pipeline": true,
"asset_triggers": [],
"native_triggers": [],
"partition": null,
"freshness": null,
"tag": null,
"retry": null,
"mute": [
"ducklake:main.orders"
]
}
},
{
"name": "mute all opts out of all auto-derivation",
"code": "// pipeline\n// mute all\nselect 1",
"expected": {
"in_pipeline": true,
"asset_triggers": [],
"native_triggers": [],
"partition": null,
"freshness": null,
"tag": null,
"retry": null,
"mute_all": true
}
},
{
"name": "mute accumulates in order and dedups",
"code": "// pipeline\n// mute ducklake://main.a\n// mute s3://raw/b\n// mute ducklake://main.a\nselect 1",
"expected": {
"in_pipeline": true,
"asset_triggers": [],
"native_triggers": [],
"partition": null,
"freshness": null,
"tag": null,
"retry": null,
"mute": [
"ducklake:main.a",
"s3object:raw/b"
]
}
},
{
"name": "mute of a native trigger kind is dropped (only assets are muteable)",
"code": "// pipeline\n// mute kafka\nselect 1",
"expected": {
"in_pipeline": true,
"asset_triggers": [],
"native_triggers": [],
"partition": null,
"freshness": null,
"tag": null,
"retry": null
}
},
{
"name": "mute prose without an asset ref never false-positives",
"code": "// pipeline\n// muted for now\nselect 1",
"expected": {
"in_pipeline": true,
"asset_triggers": [],
"native_triggers": [],
"partition": null,
"freshness": null,
"tag": null,
"retry": null
}
},
{
"name": "mute all coexists with explicit on edges",
"code": "// pipeline\n// mute all\n// on ducklake://main.orders\nselect 1",
"expected": {
"in_pipeline": true,
"asset_triggers": [
"ducklake:main.orders"
],
"native_triggers": [],
"partition": null,
"freshness": null,
"tag": null,
"retry": null,
"mute_all": true
}
},
{
"name": "on asset ref strips trailing key=value opts",
"code": "// pipeline\n// on ducklake://main.orders debounce=60s\nselect 1",
"expected": {
"in_pipeline": true,
"asset_triggers": [
"ducklake:main.orders"
],
"native_triggers": [],
"partition": null,
"freshness": null,
"tag": null,
"retry": null
}
}
]
@@ -53,6 +53,13 @@ struct Expected {
// `// use <lib_path>` accumulation, declaration order, deduped. Absent === [].
#[serde(default)]
use_libs: Vec<String>,
// `// mute <asset>` accumulation as `kind:path`, declaration order, deduped.
// Absent === [].
#[serde(default)]
mute: Vec<String>,
// `// mute all` marker. Absent === false.
#[serde(default)]
mute_all: bool,
}
#[derive(Deserialize)]
@@ -251,5 +258,18 @@ fn pipeline_annotation_fixtures_match() {
assert_eq!(got.macros, f.expected.macros, "{ctx}: macros");
assert_eq!(got.use_libs, f.expected.use_libs, "{ctx}: use_libs");
let mute: Vec<String> = got
.mute
.iter()
.filter_map(|t| match t {
TriggerSpec::Asset { asset_kind, path, .. } => {
Some(format!("{}:{}", kind_str(*asset_kind), path))
}
_ => None,
})
.collect();
assert_eq!(mute, f.expected.mute, "{ctx}: mute");
assert_eq!(got.mute_all, f.expected.mute_all, "{ctx}: mute_all");
}
}
+10
View File
@@ -578,6 +578,7 @@ fn print_help() {
println!(" JSON_FMT = false Output logs in JSON instead of logfmt");
println!(" METRICS_ADDR = None (EE only) Prometheus metrics addr at /metrics; set \"true\" to use :8001");
println!(" SUPERADMIN_SECRET = None Virtual superadmin token (server)");
println!(" NO_AUTH = false Bypass all auth; every request acts as the admin@windmill.dev superadmin (only behind a trusted gateway; ignored when CLOUD_HOSTED)");
println!(" LICENSE_KEY = None (EE only) Enterprise license key (workers require valid key)");
println!(" RUN_UPDATE_CA_CERTIFICATE_AT_START = false Run system CA update at startup");
println!(" RUN_UPDATE_CA_CERTIFICATE_PATH = /usr/sbin/update-ca-certificates Path to CA update tool");
@@ -641,6 +642,15 @@ async fn windmill_main() -> anyhow::Result<()> {
println!("Running in MCP mode");
}
if *windmill_common::worker::NO_AUTH {
println!("############################################################");
println!("# NO_AUTH mode is ENABLED: authentication is fully #");
println!("# bypassed and every request is treated as the #");
println!("# admin@windmill.dev superadmin. Only run this behind a #");
println!("# trusted authenticating gateway on a private network. #");
println!("############################################################");
}
#[cfg(all(not(target_env = "msvc"), feature = "jemalloc"))]
println!("jemalloc enabled");
+42 -15
View File
@@ -131,6 +131,13 @@ impl AuthCache {
w_id: Option<String>,
token: &str,
) -> Option<OptJobAuthed> {
// In no-auth mode there are no real tokens: resolve directly as the
// admin superadmin so direct cache callers (e.g. get_all_runnables,
// which re-validates the request token per workspace) don't reject the
// fabricated token.
if is_no_auth() {
return Some(OptJobAuthed { authed: no_auth_admin_authed(), job_id: None });
}
let key = (
w_id.as_ref().unwrap_or(&"".to_string()).to_string(),
token.to_string(),
@@ -598,6 +605,12 @@ where
let tokened = Self { token };
parts.extensions.insert(tokened.clone());
Ok(tokened)
} else if is_no_auth() {
// In `--no-auth` mode requests carry no token, but handlers that
// also require Tokened (e.g. global_whoami) must still resolve.
let tokened = Self { token: "no_auth".to_string() };
parts.extensions.insert(tokened.clone());
Ok(tokened)
} else {
BRUTE_FORCE_COUNTER.increment().await;
Err((StatusCode::UNAUTHORIZED, "Unauthorized".to_owned()))
@@ -677,6 +690,30 @@ fn maybe_get_workspace_id_from_path(path_vec: &[&str]) -> Option<String> {
workspace_id
}
/// `--no-auth` mode: compiled-in `oss` builds, or the `NO_AUTH` runtime flag on
/// any build (the runtime flag is force-disabled on CLOUD_HOSTED). When on,
/// every request resolves as the admin superadmin so a fronting gateway can
/// handle authentication instead.
pub fn is_no_auth() -> bool {
cfg!(feature = "no_auth") || *windmill_common::worker::NO_AUTH
}
/// The synthetic superadmin identity returned for every request in no-auth mode.
fn no_auth_admin_authed() -> ApiAuthed {
ApiAuthed {
email: "admin@windmill.dev".to_string(),
username: "admin".to_string(),
is_admin: true,
is_operator: false,
groups: Vec::new(),
folders: Vec::new(),
scopes: None,
username_override: None,
token_prefix: None,
read_only: false,
}
}
/// Resolves OptJobAuthed from request parts.
/// Takes ownership of Parts and returns them back.
#[allow(unreachable_code, unused_mut)]
@@ -687,21 +724,11 @@ pub async fn resolve_opt_job_authed(
return Ok((OptJobAuthed::default(), parts));
};
#[cfg(feature = "no_auth")]
{
let authed = ApiAuthed {
email: "admin@windmill.dev".to_string(),
username: "admin".to_string(),
is_admin: true,
is_operator: false,
groups: Vec::new(),
folders: Vec::new(),
scopes: None,
username_override: None,
token_prefix: None,
read_only: false,
};
return Ok((OptJobAuthed { authed, job_id: None }, parts));
if is_no_auth() {
return Ok((
OptJobAuthed { authed: no_auth_admin_authed(), job_id: None },
parts,
));
}
let already_authed = parts.extensions.get::<OptJobAuthed>().cloned();
+2 -2
View File
@@ -31,8 +31,8 @@ use scopes::ScopeDefinition;
// Re-export key auth types and functions
pub use auth::{
get_end_user_email, invalidate_token_from_cache, AuthCache, ExpiringAuthCache, OptTokened,
Tokened, TruncatedTokenWithEmail, AUTH_CACHE,
get_end_user_email, invalidate_token_from_cache, is_no_auth, AuthCache, ExpiringAuthCache,
OptTokened, Tokened, TruncatedTokenWithEmail, AUTH_CACHE,
};
// ------------ ApiAuthed & OptJobAuthed types ------------
+73 -4
View File
@@ -45,8 +45,9 @@ use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap;
use windmill_common::{
assets::{
clear_script_triggers, clear_static_asset_usage, clear_static_asset_usage_by_script_hash,
insert_script_trigger, parse_duration_secs, parse_pipeline_annotations,
replace_static_asset_usage, trigger_spec_to_row, AssetUsageKind, TriggerSpec,
derive_pipeline_asset_trigger_refs, insert_script_trigger, parse_duration_secs,
parse_pipeline_annotations, replace_static_asset_usage, trigger_spec_to_row,
AssetUsageKind, ScriptTriggerKind, TriggerSpec,
},
error::{self, to_anyhow},
min_version::{MIN_VERSION_SUPPORTS_DEBOUNCING, MIN_VERSION_SUPPORTS_DEBOUNCING_V2},
@@ -1457,11 +1458,22 @@ async fn create_script_internal<'c>(
// fire and the view would be an orphan node in the lineage graph.
for (target_kind, path) in m.write_targets() {
let kind = windmill_common::assets::asset_kind_from_parser(target_kind);
if !a.iter().any(|x| x.kind == kind && x.path == path) {
use windmill_common::assets::AssetUsageAccessType;
if let Some(existing) = a.iter_mut().find(|x| x.kind == kind && x.path == path) {
// The body reads its own managed target (an incremental/merge
// model `SELECT`ing from the table it materializes). The runtime
// still generates the write, so the effective access is RW — mark
// it so, otherwise it stays a plain read and (a) auto-derives a
// self-cascade edge back to this producer and (b) shows as a muted
// read on the canvas. Both are wrong: it's the script's own output.
if existing.access_type != Some(AssetUsageAccessType::W) {
existing.access_type = Some(AssetUsageAccessType::RW);
}
} else {
a.push(windmill_common::assets::AssetWithAltAccessType {
path,
kind,
access_type: Some(windmill_common::assets::AssetUsageAccessType::W),
access_type: Some(AssetUsageAccessType::W),
alt_access_type: None,
columns: None,
});
@@ -2035,6 +2047,63 @@ async fn create_script_internal<'c>(
.await?;
}
// Auto-derived cascade edges: within a `// pipeline`, a read of a ducklake
// table or s3 object wires the cascade edge straight from the FROM clause,
// so `// on <asset>` is only needed for edges inference can't see (dynamic
// SQL) or to carry per-edge opts. `// mute <asset>` / `// mute all` opt out.
// Explicit `// on` asset edges (inserted just above) win the dedup — they
// carry the per-edge debounce, so a derived row must not shadow them.
if in_pipeline && !pipeline_annotations.mute_all {
let asset_ref = |spec: &TriggerSpec| {
trigger_spec_to_row(spec)
.filter(|(k, _)| *k == ScriptTriggerKind::Asset)
.map(|(_, r)| r)
};
let explicit_refs: std::collections::HashSet<String> =
pipeline_triggers.iter().filter_map(asset_ref).collect();
let muted_refs: std::collections::HashSet<String> = pipeline_annotations
.mute
.iter()
.filter_map(asset_ref)
.collect();
let derived = derive_pipeline_asset_trigger_refs(
effective_assets.as_deref().unwrap_or(&[]),
&explicit_refs,
&muted_refs,
pipeline_annotations.mute_all,
);
// Derived edges have no per-`// on` opts, so they take the script-level
// `// debounce` default and `// retry` policy — same as writing a bare
// `// on <asset>` would.
let derived_debounce_s = pipeline_debounce_default
.as_deref()
.and_then(parse_duration_secs);
let derived_retry_count = pipeline_annotations
.retry
.as_ref()
.map(|r| r.count.min(i16::MAX as u32) as i16);
let derived_retry_delay_s = pipeline_annotations
.retry
.as_ref()
.and_then(|r| r.delay.as_deref())
.and_then(parse_duration_secs);
for trigger_ref in derived {
insert_script_trigger(
&mut *tx,
&w_id,
AssetUsageKind::Script,
&ns.path,
ScriptTriggerKind::Asset,
&trigger_ref,
pipeline_join_all,
derived_debounce_s,
derived_retry_count,
derived_retry_delay_s,
)
.await?;
}
}
// Schedule annotations (`// on schedule`) are marker-only — the binding
// lives on the schedule row's own `script_path` field, which the user
// creates separately via the schedule editor. No script-create-time
+4 -2
View File
@@ -1942,8 +1942,10 @@ async fn login(
Extension(argon2): Extension<Arc<Argon2<'_>>>,
Json(Login { email, password }): Json<Login>,
) -> Result<String> {
#[cfg(feature = "no_auth")]
{
// In `--no-auth` mode there is no real login; the frontend never needs a
// session cookie because every request already resolves as the admin
// superadmin (see resolve_opt_job_authed).
if windmill_api_auth::is_no_auth() {
return Ok("no_auth".to_string());
}
@@ -47,6 +47,7 @@ serde_json.workspace = true
sha2.workspace = true
sqlx.workspace = true
tokio.workspace = true
tokio-postgres.workspace = true
tracing.workspace = true
uuid.workspace = true
strum.workspace = true
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,4 @@
pub mod datatable_migrations;
pub mod deployment_requests;
pub mod workspaces;
pub mod workspaces_extra;
+224 -29
View File
@@ -132,6 +132,7 @@ pub fn workspaced_service() -> Router {
get(get_datatable_table_schema),
)
.route("/edit_datatable_config", post(edit_datatable_config))
.merge(crate::datatable_migrations::routes())
.route("/git_sync_enabled", get(get_git_sync_enabled))
.route("/edit_git_sync_config", post(edit_git_sync_config))
.route("/edit_git_sync_repository", post(edit_git_sync_repository))
@@ -156,6 +157,7 @@ pub fn workspaced_service() -> Router {
.route("/create_fork", post(create_workspace_fork))
.route("/attach_dev_workspace", post(attach_dev_workspace))
.route("/detach_dev_workspace", post(detach_dev_workspace))
.route("/set_dev_workspace_label", post(set_dev_workspace_label))
.route("/get_dev_workspace", get(get_dev_workspace))
.route("/change_workspace_name", post(change_workspace_name))
.route("/change_workspace_color", post(change_workspace_color))
@@ -429,6 +431,12 @@ pub struct DucklakeSettings {
#[derive(Deserialize, Debug)]
struct EditDataTableConfig {
settings: DataTableSettings,
// Data table renames (old -> new) and deletions, tracked client-side by a
// stable id, so we can cascade or drop each data table's migrations.
#[serde(default)]
renames: Vec<crate::datatable_migrations::DatatableRename>,
#[serde(default)]
deleted_datatables: Vec<String>,
}
#[derive(Deserialize, Serialize, Debug)]
@@ -472,6 +480,10 @@ struct CreateWorkspaceFork {
/// the team can work in it. Defaults off; the dev-workspace UI defaults it on.
#[serde(default)]
copy_members: bool,
/// Cosmetic display label for the dev workspace: 'dev' | 'staging'. Purely visual (badge text +
/// wording); ignored for non-dev forks. None defaults to 'dev'.
#[serde(default)]
dev_workspace_label: Option<String>,
}
#[derive(Deserialize)]
@@ -501,6 +513,7 @@ struct UserWorkspace {
pub operator_settings: Option<Option<serde_json::Value>>,
pub parent_workspace_id: Option<String>,
pub is_dev_workspace: bool,
pub dev_workspace_label: Option<String>,
pub disabled: bool,
}
@@ -678,6 +691,20 @@ async fn exists_workspace(
struct DevWorkspaceInfo {
id: String,
name: String,
dev_workspace_label: Option<String>,
}
/// Normalize/validate the cosmetic dev-workspace display label. None or 'dev' both render as "dev";
/// 'staging' renders as "stg". Anything else is rejected. Stored explicitly ('dev'/'staging') so it
/// round-trips, but a NULL column is treated as 'dev' on the read side too.
fn normalize_dev_workspace_label(label: Option<String>) -> Result<Option<String>> {
match label.as_deref() {
None | Some("dev") => Ok(Some("dev".to_string())),
Some("staging") => Ok(Some("staging".to_string())),
Some(other) => Err(Error::BadRequest(format!(
"invalid dev workspace label '{other}' (expected 'dev' or 'staging')"
))),
}
}
/// This workspace's active canonical dev workspace, if any. The create-fork UI and the dev-workspace
@@ -691,7 +718,7 @@ async fn get_dev_workspace(
) -> JsonResult<Option<DevWorkspaceInfo>> {
let dev = sqlx::query_as!(
DevWorkspaceInfo,
"SELECT id, name FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false",
"SELECT id, name, dev_workspace_label FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false",
&w_id
)
.fetch_optional(&db)
@@ -1953,8 +1980,8 @@ pub(crate) async fn resolve_pg_source_checked(
}
/// A temporary file for pg_dump output that is automatically deleted when dropped.
struct DumpFile {
path: std::path::PathBuf,
pub(crate) struct DumpFile {
pub(crate) path: std::path::PathBuf,
}
impl DumpFile {
@@ -2001,7 +2028,11 @@ impl Drop for DumpFile {
/// Run pg_dump against a PgDatabase, writing output to a temp file on disk.
/// Returns a DumpFile handle; the file is deleted when the handle is dropped.
async fn pg_dump_database(pg_db: &PgDatabase, schema_only: bool) -> Result<DumpFile> {
pub(crate) async fn pg_dump_database(
pg_db: &PgDatabase,
schema_only: bool,
exclude_tables: &[&str],
) -> Result<DumpFile> {
let dump_file = DumpFile::new()?;
let host = &pg_db.host;
@@ -2014,6 +2045,9 @@ async fn pg_dump_database(pg_db: &PgDatabase, schema_only: bool) -> Result<DumpF
if schema_only {
cmd.arg("--schema-only");
}
for table in exclude_tables {
cmd.arg(format!("--exclude-table={table}"));
}
cmd.arg("--host")
.arg(host)
.arg("--port")
@@ -2237,7 +2271,7 @@ async fn import_pg_database(
}
windmill_common::validate_dbname(&target_pg.dbname)?;
let dump_file = pg_dump_database(&source_pg, schema_only).await?;
let dump_file = pg_dump_database(&source_pg, schema_only, &[]).await?;
pg_import_dump(&target_pg, &dump_file).await?;
Ok(format!(
@@ -2259,7 +2293,7 @@ async fn export_pg_schema(
Json(req): Json<ExportPgSchemaRequest>,
) -> Result<String> {
let pg = resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.source).await?;
let dump_file = pg_dump_database(&pg, true).await?;
let dump_file = pg_dump_database(&pg, true, &[]).await?;
tokio::fs::read_to_string(&dump_file.path)
.await
.map_err(|e| Error::internal_err(format!("Failed to read dump file: {}", e)))
@@ -2396,13 +2430,57 @@ async fn edit_datatable_config(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
ApiAuthed { is_admin, username, email, .. }: ApiAuthed,
Json(new_config): Json<EditDataTableConfig>,
Json(mut new_config): Json<EditDataTableConfig>,
) -> Result<String> {
require_admin(is_admin, &username)?;
let is_superadmin = require_super_admin(&db, &email).await.is_ok();
let mut tx = db.begin().await?;
let old_datatables: HashMap<String, DataTable> = serde_json::from_value(
sqlx::query_scalar!(
"SELECT ws.datatable->'datatables' FROM workspace_settings ws WHERE ws.workspace_id = $1",
&w_id
)
.fetch_one(&db)
.await?
.unwrap_or(serde_json::Value::Null),
)
.unwrap_or_default();
// Validate every persisted data table name and rename segment before
// touching anything, since they become directory segments in migration
// storage/export keys (`migrations/datatable/<name>/...`).
for name in new_config.settings.datatables.keys() {
crate::datatable_migrations::validate_datatable_path_segment(name)?;
}
for r in &new_config.renames {
crate::datatable_migrations::validate_datatable_path_segment(&r.from)?;
crate::datatable_migrations::validate_datatable_path_segment(&r.to)?;
}
// Map new name -> old name so a renamed data table inherits the previous
// flag instead of being treated as brand new.
let rename_src: HashMap<&str, &str> = new_config
.renames
.iter()
.map(|r| (r.to.as_str(), r.from.as_str()))
.collect();
// Migrations opt-in is owned by the enable/disable endpoints, not this config
// form: preserve each existing data table's flag, and default brand-new data
// tables to enabled.
for (name, dt) in new_config.settings.datatables.iter_mut() {
let lookup = rename_src
.get(name.as_str())
.copied()
.unwrap_or(name.as_str());
dt.migrations_enabled = match old_datatables.get(lookup) {
Some(old) => old.migrations_enabled,
None => Some(true),
};
}
let args_for_audit = format!("{:?}", new_config.settings);
audit_log(
&mut *tx,
@@ -2417,19 +2495,6 @@ async fn edit_datatable_config(
// Check that non-superadmins are not abusing Instance databases
if !is_superadmin {
let old_datatables = sqlx::query_scalar!(
r#"
SELECT ws.datatable->'datatables' AS datatable_name
FROM workspace_settings ws
WHERE ws.workspace_id = $1
"#,
&w_id
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(serde_json::Value::Null);
let old_datatables: HashMap<String, DataTable> =
serde_json::from_value(old_datatables).unwrap_or_default();
for (name, dt) in new_config.settings.datatables.iter() {
if dt.database.resource_type == DataTableCatalogResourceType::Instance {
let old_dt = old_datatables.get(name);
@@ -2458,6 +2523,15 @@ async fn edit_datatable_config(
.execute(&mut *tx)
.await?;
crate::datatable_migrations::cascade_datatable_migration_renames_and_deletes(
&db,
&mut tx,
&w_id,
&new_config.renames,
&new_config.deleted_datatables,
)
.await?;
tx.commit().await?;
Ok(format!("Edit datatable config for workspace {}", &w_id))
@@ -3697,7 +3771,7 @@ async fn user_workspaces(
let workspaces = sqlx::query_as!(
UserWorkspace,
"SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id,
workspace.is_dev_workspace,
workspace.is_dev_workspace, workspace.dev_workspace_label,
CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings,
usr.disabled
FROM workspace
@@ -4048,6 +4122,15 @@ async fn clone_workspace_data(
// Clone workspace settings (merge with existing basic settings)
update_workspace_settings(tx, source_workspace_id, target_workspace_id).await?;
// Clone data table migration definitions (the settings above carry the data
// table config; this carries their migration history).
crate::datatable_migrations::clone_datatable_migrations(
tx,
source_workspace_id,
target_workspace_id,
)
.await?;
// Clone workspace environment variables
clone_workspace_env(tx, source_workspace_id, target_workspace_id).await?;
@@ -5187,6 +5270,8 @@ async fn create_workspace_fork_branch(
// that second call. Validating early lets a bad request fail before any branch is created.
if nw.is_dev_workspace {
validate_dev_workspace_id(&nw.id)?;
// Reject a bad cosmetic label before any git branch is created (acted on in create_workspace_fork).
normalize_dev_workspace_label(nw.dev_workspace_label.clone())?;
ensure_dev_parent_is_root(&db, &w_id).await?;
// Reject before creating any git branch if the parent already has a dev workspace,
// otherwise the deferred branch-creation job leaves a dangling branch on the synced repos.
@@ -5418,6 +5503,12 @@ async fn create_workspace_fork(
validate_fork_workspace_id(&nw.id)?;
}
validate_workspace_name(&nw.name)?;
// Cosmetic label only applies to dev workspaces; a non-dev fork stores NULL.
let dev_workspace_label = if nw.is_dev_workspace {
normalize_dev_workspace_label(nw.dev_workspace_label.clone())?
} else {
None
};
// Check the id conflict before the CE workspace-count limit so that
// re-using a taken (possibly archived) fork id reports the actual
// conflict instead of a misleading "maximum number of workspaces" error.
@@ -5495,13 +5586,14 @@ async fn create_workspace_fork(
sqlx::query!(
"INSERT INTO workspace
(id, name, owner, parent_workspace_id, is_dev_workspace)
VALUES ($1, $2, $3, $4, $5)",
(id, name, owner, parent_workspace_id, is_dev_workspace, dev_workspace_label)
VALUES ($1, $2, $3, $4, $5, $6)",
forked_id,
nw.name,
authed.email,
parent_workspace_id,
nw.is_dev_workspace,
dev_workspace_label,
)
.execute(&mut *tx)
.await?;
@@ -5634,6 +5726,9 @@ struct AttachDevWorkspace {
lock_prod_deploy: bool,
#[serde(default)]
lock_prod_forking: bool,
/// Cosmetic display label for the attached dev workspace: 'dev' | 'staging'. None defaults to 'dev'.
#[serde(default)]
dev_workspace_label: Option<String>,
}
#[derive(Deserialize)]
@@ -5687,6 +5782,7 @@ async fn attach_dev_workspace(
// The id is interpolated into a `wm-fork/<branch>/<id>` branch name like any fork.
validate_dev_workspace_id(&dev_w_id)?;
let dev_workspace_label = normalize_dev_workspace_label(req.dev_workspace_label.clone())?;
let dev = sqlx::query!(
r#"SELECT parent_workspace_id, deleted FROM workspace WHERE id = $1"#,
@@ -5754,9 +5850,10 @@ async fn attach_dev_workspace(
let mut tx = db.begin().await?;
sqlx::query!(
"UPDATE workspace SET parent_workspace_id = $1, is_dev_workspace = true WHERE id = $2",
"UPDATE workspace SET parent_workspace_id = $1, is_dev_workspace = true, dev_workspace_label = $3 WHERE id = $2",
&prod_w_id,
&dev_w_id
&dev_w_id,
dev_workspace_label,
)
.execute(&mut *tx)
.await?;
@@ -5822,6 +5919,51 @@ async fn attach_dev_workspace(
))
}
#[derive(Deserialize)]
struct SetDevWorkspaceLabel {
#[serde(default)]
dev_workspace_label: Option<String>,
}
/// Change the cosmetic display label ('dev' | 'staging') of the current workspace, which must itself
/// be a dev workspace. Purely visual (badge text + wording); requires admin of the dev workspace.
async fn set_dev_workspace_label(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Json(req): Json<SetDevWorkspaceLabel>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
let label = normalize_dev_workspace_label(req.dev_workspace_label)?;
let mut tx = db.begin().await?;
let updated = sqlx::query_scalar!(
"UPDATE workspace SET dev_workspace_label = $1 WHERE id = $2 AND is_dev_workspace RETURNING id",
label,
&w_id,
)
.fetch_optional(&mut *tx)
.await?;
if updated.is_none() {
return Err(Error::BadRequest(format!(
"Workspace '{w_id}' is not a dev workspace"
)));
}
audit_log(
&mut *tx,
&authed,
"workspaces.set_dev_workspace_label",
ActionKind::Update,
&w_id,
label.as_deref(),
None,
)
.await?;
tx.commit().await?;
Ok(format!("Updated dev workspace label for {w_id}"))
}
/// Reverse [`attach_dev_workspace`] / clear the dev designation: unset the dev flag and remove the
/// prod lock. The workspace keeps its `parent_workspace_id` (it remains an ordinary fork).
async fn detach_dev_workspace(
@@ -7339,6 +7481,7 @@ pub struct CompareSummary {
pub folders_changed: usize,
pub schedules_changed: usize,
pub triggers_changed: usize,
pub datatable_migrations_changed: usize,
pub conflicts: usize, // Items that are both ahead and behind
}
@@ -7530,6 +7673,15 @@ async fn compare_workspaces(
compare_two_folders(&db, &source_workspace_id, &fork_workspace_id, &item.path)
.await?,
),
"datatable_migration" => Some(
crate::datatable_migrations::compare_two_datatable_migration(
&db,
&source_workspace_id,
&fork_workspace_id,
&item.path,
)
.await?,
),
// Triggers and schedules are diffed against a hardcoded ignore list
// (mode/enabled/server_id/last_server_ping/edited_at/by/error/extra_perms/permissioned_as/email)
// so that fork-clones — which differ from the parent only in the runtime
@@ -7652,6 +7804,10 @@ async fn compare_workspaces(
.iter()
.filter(|s| s.kind.ends_with("_trigger"))
.count(),
datatable_migrations_changed: visible_diffs
.iter()
.filter(|s| s.kind == "datatable_migration")
.count(),
conflicts: visible_diffs
.iter()
.filter(|s| s.ahead > 0 && s.behind > 0)
@@ -7957,6 +8113,45 @@ async fn query_visible_items<'c>(
.fetch_all(&mut **tx)
.await?
}
"datatable_migration" => {
// Match by (datatable, timestamp), not the full path: a migration
// keeps its identity across a rename, so the candidate path's
// `name` segment can differ from the stored one. Parse each
// `<datatable>/<timestamp>_<name>` candidate, probe existence by
// (datatable, timestamp), and return the *original* candidate path
// so the visibility set stays keyed by the diff's path.
let parsed: Vec<(String, i64, String)> = paths_vec
.iter()
.filter_map(|p| {
let (dt, rest) = p.split_once('/')?;
let ts = rest.split_once('_')?.0.parse::<i64>().ok()?;
Some((dt.to_string(), ts, p.clone()))
})
.collect();
if parsed.is_empty() {
vec![]
} else {
let dts: Vec<String> = parsed.iter().map(|(d, _, _)| d.clone()).collect();
let tss: Vec<i64> = parsed.iter().map(|(_, t, _)| *t).collect();
let existing: HashSet<(String, i64)> = sqlx::query!(
"SELECT datatable, timestamp FROM datatable_migrations \
WHERE workspace_id = $1 AND datatable = ANY($2) AND timestamp = ANY($3)",
workspace_id,
&dts,
&tss,
)
.fetch_all(&mut **tx)
.await?
.into_iter()
.map(|r| (r.datatable, r.timestamp))
.collect();
parsed
.into_iter()
.filter(|(d, t, _)| existing.contains(&(d.clone(), *t)))
.map(|(_, _, p)| p)
.collect()
}
}
k if TRIGGER_OR_SCHEDULE_TABLES.contains(&k) => {
// SAFETY: `kind` comes from a hardcoded allowlist
// TRIGGER_OR_SCHEDULE_TABLES, not user input.
@@ -8024,10 +8219,10 @@ async fn existing_runnables(
}
#[derive(Debug)]
struct ItemComparison {
has_changes: bool,
exists_in_source: bool,
exists_in_fork: bool,
pub(crate) struct ItemComparison {
pub(crate) has_changes: bool,
pub(crate) exists_in_source: bool,
pub(crate) exists_in_fork: bool,
}
async fn compare_two_scripts(
@@ -91,9 +91,10 @@ pub(crate) async fn change_workspace_id(
.await?;
}
sqlx::query!(
"INSERT INTO workspace (id, name, owner, deleted, premium, parent_workspace_id, is_dev_workspace)
"INSERT INTO workspace (id, name, owner, deleted, premium, parent_workspace_id, is_dev_workspace, dev_workspace_label)
SELECT $1, $2, owner, false, premium,
CASE WHEN $4 THEN parent_workspace_id ELSE NULL END, $5
CASE WHEN $4 THEN parent_workspace_id ELSE NULL END, $5,
CASE WHEN $5 THEN dev_workspace_label ELSE NULL END
FROM workspace WHERE id = $3",
&rw.new_id,
&rw.new_name,
@@ -1095,7 +1096,10 @@ pub(crate) async fn delete_workspace(
// effort: failures are logged — the workspace row is already gone, and broken storage
// credentials must not have made it undeletable.
for e in cleanup_fork_ducklake_namespaces(&db, &w_id, fork_ducklake_cleanups).await {
tracing::warn!("deleted workspace {w_id}: ducklake namespace cleanup: {}", e.msg);
tracing::warn!(
"deleted workspace {w_id}: ducklake namespace cleanup: {}",
e.msg
);
}
if let Some(parent) = dev_lock_parent {
+400 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.750.0
version: 1.751.0
title: Windmill API
contact:
@@ -1205,6 +1205,9 @@ paths:
type: boolean
lock_prod_forking:
type: boolean
dev_workspace_label:
type: string
enum: [dev, staging]
required:
- dev_workspace_id
responses:
@@ -1263,10 +1266,40 @@ paths:
type: string
name:
type: string
dev_workspace_label:
type: string
nullable: true
description: "Cosmetic display label ('dev' | 'staging'); null defaults to 'dev'"
required:
- id
- name
/w/{workspace}/workspaces/set_dev_workspace_label:
post:
summary: set the cosmetic display label (dev/staging) of this dev workspace
operationId: setDevWorkspaceLabel
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
dev_workspace_label:
type: string
enum: [dev, staging]
responses:
"200":
description: dev workspace label updated
content:
text/plain:
schema:
type: string
/workspaces/exists:
post:
summary: exists workspace
@@ -4598,6 +4631,22 @@ paths:
properties:
settings:
$ref: "#/components/schemas/DataTableSettings"
renames:
description: data tables renamed in this save, so their migrations cascade
type: array
items:
type: object
required: [from, to]
properties:
from:
type: string
to:
type: string
deleted_datatables:
description: data tables removed in this save, so their migrations are deleted
type: array
items:
type: string
responses:
"200":
description: status
@@ -4605,6 +4654,307 @@ paths:
application/json:
schema: {}
/w/{workspace}/workspaces/run_datatable_migrations/{datatable_name}:
post:
summary: run pending datatable migrations against a datatable
operationId: runDatatableMigrations
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: datatable_name
in: path
required: true
schema:
type: string
- name: up_to
in: query
required: false
description: only apply pending migrations up to and including this version
schema:
type: integer
format: int64
- name: only
in: query
required: false
description: apply only this specific migration version, ignoring others
schema:
type: integer
format: int64
responses:
"200":
description: applied migrations
content:
application/json:
schema:
type: object
required: [applied]
properties:
applied:
type: array
items:
type: object
required: [version, name]
properties:
version:
type: integer
format: int64
name:
type: string
/w/{workspace}/workspaces/rollback_datatable_migrations/{datatable_name}:
post:
summary: roll back the most recently applied migration on a datatable
operationId: rollbackDatatableMigrations
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: datatable_name
in: path
required: true
schema:
type: string
- name: only
in: query
required: false
description: roll back this specific applied migration version instead of the latest
schema:
type: integer
format: int64
responses:
"200":
description: rolled back migrations
content:
application/json:
schema:
type: object
required: [rolled_back]
properties:
rolled_back:
type: array
items:
type: object
required: [version, name]
properties:
version:
type: integer
format: int64
name:
type: string
/w/{workspace}/workspaces/list_datatable_migrations:
get:
summary: list datatable migrations for a workspace
operationId: listDatatableMigrations
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: datatable migrations
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/DatatableMigration"
/w/{workspace}/workspaces/datatable_migrations_status/{datatable_name}:
get:
summary: list a datatable's migrations with their applied status
operationId: getDatatableMigrationsStatus
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: datatable_name
in: path
required: true
schema:
type: string
responses:
"200":
description: migrations with status
content:
application/json:
schema:
type: object
required: [enabled, migrations]
properties:
enabled:
type: boolean
migrations:
type: array
items:
$ref: "#/components/schemas/DatatableMigrationWithStatus"
error:
type: string
/w/{workspace}/workspaces/enable_datatable_migrations/{datatable_name}:
post:
summary: opt a datatable in to migrations (admins / super admins only)
operationId: enableDatatableMigrations
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: datatable_name
in: path
required: true
schema:
type: string
responses:
"200":
description: status
content:
text/plain:
schema:
type: string
/w/{workspace}/workspaces/disable_datatable_migrations/{datatable_name}:
post:
summary: opt a datatable out of migrations, deleting all of them (admins / super admins only)
operationId: disableDatatableMigrations
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: datatable_name
in: path
required: true
schema:
type: string
responses:
"200":
description: status
content:
text/plain:
schema:
type: string
/w/{workspace}/workspaces/create_datatable_migration/{datatable_name}:
post:
summary: create a single datatable migration (version generated server-side)
operationId: createDatatableMigration
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: datatable_name
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name, code_up]
properties:
name:
type: string
code_up:
type: string
code_down:
type: string
responses:
"200":
description: created migration
content:
application/json:
schema:
$ref: "#/components/schemas/DatatableMigration"
/w/{workspace}/workspaces/delete_datatable_migration/{datatable_name}/{timestamp}:
delete:
summary: delete a single datatable migration definition
operationId: deleteDatatableMigration
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: datatable_name
in: path
required: true
schema:
type: string
- name: timestamp
in: path
required: true
schema:
type: integer
format: int64
responses:
"200":
description: status
content:
text/plain:
schema:
type: string
/w/{workspace}/workspaces/upsert_datatable_migration/{datatable_name}:
post:
summary: insert or update a single datatable migration at an explicit version
operationId: upsertDatatableMigration
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: datatable_name
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [timestamp, name, code_up]
properties:
timestamp:
type: integer
format: int64
name:
type: string
code_up:
type: string
code_down:
type: string
responses:
"200":
description: status
content:
text/plain:
schema:
type: string
/w/{workspace}/workspaces/generate_initial_datatable_migration/{datatable_name}:
post:
summary: snapshot the current schema as an already-installed initial migration
operationId: generateInitialDatatableMigration
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: datatable_name
in: path
required: true
schema:
type: string
responses:
"200":
description: created migration
content:
application/json:
schema:
$ref: "#/components/schemas/DatatableMigration"
/w/{workspace}/workspaces/create_pg_database:
post:
summary: create a new PostgreSQL database for a datatable
@@ -28180,6 +28530,10 @@ components:
nullable: true
is_dev_workspace:
type: boolean
dev_workspace_label:
type: string
nullable: true
description: "Cosmetic display label of the dev workspace ('dev' | 'staging'); null defaults to 'dev'"
created_by:
type: string
nullable: true
@@ -28249,6 +28603,10 @@ components:
copy_members:
type: boolean
description: "Copy the parent's members (users + group memberships) into the fork so the team can work in it"
dev_workspace_label:
type: string
enum: [dev, staging]
description: "Cosmetic display label for the dev workspace (badge text + wording only); ignored for non-dev forks"
required:
- id
- name
@@ -29026,6 +29384,9 @@ components:
type: string
required:
- resource_type
migrations_enabled:
type: boolean
description: Whether the SQL migrations feature is opted in for this data table
forked_from:
type: object
description: Fork origin info with schema snapshot
@@ -29034,6 +29395,40 @@ components:
type: object
description: Schema snapshot at fork time
additionalProperties: true
DatatableMigration:
type: object
required: [datatable, timestamp, name, code_up]
properties:
datatable:
type: string
timestamp:
type: integer
format: int64
name:
type: string
code_up:
type: string
code_down:
type: string
DatatableMigrationWithStatus:
type: object
required: [timestamp, name, code_up, status]
properties:
timestamp:
type: integer
format: int64
name:
type: string
code_up:
type: string
code_down:
type: string
status:
type: string
enum:
- ran
- not_run
- unknown
DataTableSchema:
type: object
required: [datatable_name, schemas]
@@ -29745,6 +30140,7 @@ components:
- folders_changed
- schedules_changed
- triggers_changed
- datatable_migrations_changed
- conflicts
properties:
total_diffs:
@@ -29783,6 +30179,9 @@ components:
triggers_changed:
type: integer
description: Number of triggers with differences (sum across all trigger kinds)
datatable_migrations_changed:
type: integer
description: Number of data table migrations with differences
conflicts:
type: integer
description: Number of items that are both ahead and behind (conflicts)
+11 -3
View File
@@ -96,6 +96,14 @@ lazy_static::lazy_static! {
pub struct CustomMigrator {
inner: PoolConnection<Postgres>,
}
impl CustomMigrator {
/// The connection the migrator already holds (with the migration advisory lock).
/// Migration housekeeping runs on it instead of re-acquiring: a second connection
/// while this one is held deadlocks a single-connection backend (e.g. embedded pglite).
pub fn connection(&mut self) -> &mut PgConnection {
&mut *self.inner
}
}
impl Migrate for CustomMigrator {
fn ensure_migrations_table(
&mut self,
@@ -272,7 +280,7 @@ pub async fn migrate(
version=20250131115248 OR version=20250902085503 OR version=20250201145630 OR
version=20250201145631 OR version=20250201145632 OR version=20251006143821"
)
.execute(db)
.execute(custom_migrator.connection())
.await
{
tracing::info!("Could not remove sqlx migrations: {err:#}");
@@ -310,7 +318,7 @@ pub async fn migrate(
sqlx::query("DELETE FROM _sqlx_migrations WHERE version = $1 AND checksum != $2")
.bind(m.version)
.bind(&*m.checksum)
.execute(db)
.execute(custom_migrator.connection())
.await
{
tracing::info!("Could not clean up stale migration {}: {err:#}", m.version);
@@ -340,7 +348,7 @@ pub async fn migrate(
}
}
crate::live_migrations::custom_migrations(&mut custom_migrator, db).await?;
crate::live_migrations::custom_migrations(&mut custom_migrator).await?;
Ok(None)
}
+10 -10
View File
@@ -9,26 +9,26 @@
use sqlx::Postgres;
use windmill_common::error::Error;
use crate::db::{CustomMigrator, DB};
use crate::db::CustomMigrator;
use sqlx::migrate::Migrate;
use sqlx::Acquire;
use sqlx::Executor;
pub async fn custom_migrations(migrator: &mut CustomMigrator, db: &DB) -> Result<(), Error> {
if let Err(err) = fix_flow_versioning_migration(migrator, db).await {
pub async fn custom_migrations(migrator: &mut CustomMigrator) -> Result<(), Error> {
if let Err(err) = fix_flow_versioning_migration(migrator).await {
tracing::error!("Could not apply flow versioning fix migration: {err:#}");
}
Ok(())
}
async fn fix_flow_versioning_migration(
migrator: &mut CustomMigrator,
db: &DB,
) -> Result<(), Error> {
// Runs on the migrator's held connection (see CustomMigrator::connection): re-acquiring
// from the pool here would deadlock a single-connection backend.
async fn fix_flow_versioning_migration(migrator: &mut CustomMigrator) -> Result<(), Error> {
let has_done_migration = sqlx::query_scalar!(
"SELECT EXISTS(SELECT name FROM windmill_migrations WHERE name = 'fix_flow_versioning_2')",
)
.fetch_one(db)
.fetch_one(migrator.connection())
.await?
.unwrap_or(false);
@@ -44,14 +44,14 @@ async fn fix_flow_versioning_migration(
let has_done_migration = sqlx::query_scalar!(
"SELECT EXISTS(SELECT name FROM windmill_migrations WHERE name = 'fix_flow_versioning_2')",
)
.fetch_one(db)
.fetch_one(migrator.connection())
.await?
.unwrap_or(false);
if !has_done_migration {
let query = include_str!("../../custom_migrations/fix_flow_versioning_2.sql");
tracing::info!("Applying fix_flow_versioning_2.sql");
let mut tx: sqlx::Transaction<'_, Postgres> = db.begin().await?;
let mut tx: sqlx::Transaction<'_, Postgres> = migrator.connection().begin().await?;
tx.execute(query).await?;
tracing::info!("Applied fix_flow_versioning_2.sql");
sqlx::query!(
@@ -1546,6 +1546,34 @@ pub(crate) async fn tarball_workspace(
.await?;
}
{
// Data table migrations live in the `datatable_migrations` table; surface
// them in the export as `migrations/datatable/<datatable>/<version>_<name>`
// .up.sql (and .down.sql when present) so `wmill sync` treats them like any
// other workspace item.
let migrations = sqlx::query!(
"SELECT datatable, timestamp, name, code_up, code_down FROM datatable_migrations \
WHERE workspace_id = $1 ORDER BY datatable, timestamp",
&w_id
)
.fetch_all(&mut *tx)
.await?;
for m in migrations {
let base = format!(
"migrations/datatable/{}/{}_{}",
m.datatable, m.timestamp, m.name
);
archive
.write_to_archive(&m.code_up, &format!("{base}.up.sql"))
.await?;
if let Some(code_down) = m.code_down {
archive
.write_to_archive(&code_down, &format!("{base}.down.sql"))
.await?;
}
}
}
archive.finish().await?;
let file = tokio::fs::File::open(&file_path).await?;
+190
View File
@@ -169,6 +169,68 @@ fn is_write_access(access: Option<AssetUsageAccessType>) -> bool {
)
}
/// Kinds whose *read* usage auto-derives a cascade trigger edge inside a
/// `// pipeline`. Scoped to the two intra-pipeline data kinds — a ducklake
/// table read (the core case) and an s3 object read (file-ingestion
/// producers). Resource / datatable / volume reads stay explicit-`// on`:
/// a config/lookup read cascading is more often surprising than wanted.
fn is_auto_trigger_kind(kind: AssetKind) -> bool {
matches!(kind, AssetKind::Ducklake | AssetKind::S3Object)
}
/// Trigger refs auto-derived from a pipeline script's inferred reads, so the
/// FROM clause alone wires the cascade edge (no redundant `// on <asset>`).
///
/// Included: an input read read-*only* (`R`) of a supported kind
/// ([`is_auto_trigger_kind`]). The effective access type is
/// `access_type.or(alt_access_type)` — same precedence as the persisted
/// `asset.usage_access_type` and the frontend mirror's `access_type ??
/// alt_access_type`, so a manual read override on an ambiguous parse still
/// derives an edge (and the live canvas and the deployed graph agree).
/// Excluded, each for a reason:
/// - `RW` / `W` — the script also writes the asset; an edge would be a
/// self-triggering loop.
/// - `None` access — usage is ambiguous (poisoned merge) with no override;
/// can't confirm a read, so fail safe and don't cascade.
/// - already in `explicit_refs` — the author wrote `// on <asset>`, which
/// wins (it carries the per-edge debounce/opts).
/// - in `muted_refs` — a `// mute <asset>` opt-out (lookup / SCD input).
///
/// `mute_all` (from `// mute all`) short-circuits to no derivation, leaving
/// only the explicit `// on` edges. Returns canonical refs (e.g.
/// `ducklake://main.orders`), deduped, in input order.
pub fn derive_pipeline_asset_trigger_refs(
assets: &[AssetWithAltAccessType],
explicit_refs: &HashSet<String>,
muted_refs: &HashSet<String>,
mute_all: bool,
) -> Vec<String> {
if mute_all {
return vec![];
}
let mut out = vec![];
let mut seen = HashSet::new();
for a in assets {
// Effective access mirrors the persisted `usage_access_type` and the
// frontend derivation: an explicit parse wins, else the manual override.
let access = a.access_type.or(a.alt_access_type);
if access != Some(AssetUsageAccessType::R) || !is_auto_trigger_kind(a.kind) {
continue;
}
let Some(prefix) = a.kind.canonical_prefix() else {
continue;
};
let r = format!("{}{}", prefix, a.path);
if explicit_refs.contains(&r) || muted_refs.contains(&r) {
continue;
}
if seen.insert(r.clone()) {
out.push(r);
}
}
out
}
/// Clear and reinsert the full static-asset usage set of a script in one tx,
/// invalidating the producer-writes cache at most once and only on a real
/// change. The cache (asset_dispatch::ASSET_PRODUCER_WRITES_CACHE) keys a
@@ -369,6 +431,134 @@ mod debounce_duration_tests {
}
}
#[cfg(test)]
mod derive_trigger_tests {
use super::{derive_pipeline_asset_trigger_refs, AssetKind, AssetUsageAccessType};
use std::collections::HashSet;
use windmill_types::assets::AssetWithAltAccessType;
fn asset(
kind: AssetKind,
path: &str,
at: Option<AssetUsageAccessType>,
) -> AssetWithAltAccessType {
AssetWithAltAccessType {
path: path.to_string(),
kind,
access_type: at,
alt_access_type: None,
columns: None,
}
}
fn derive(assets: &[AssetWithAltAccessType]) -> Vec<String> {
derive_pipeline_asset_trigger_refs(assets, &HashSet::new(), &HashSet::new(), false)
}
#[test]
fn read_only_ducklake_and_s3_derive_an_edge() {
use AssetUsageAccessType::R;
let a = [
asset(AssetKind::Ducklake, "main.orders", Some(R)),
asset(AssetKind::S3Object, "raw/events", Some(R)),
];
assert_eq!(
derive(&a),
vec![
"ducklake://main.orders".to_string(),
"s3://raw/events".to_string()
]
);
}
#[test]
fn writes_and_rw_are_skipped_to_avoid_self_edges() {
use AssetUsageAccessType::*;
// W (pure producer) and RW (reads *and* writes the same table — a
// self-cascade if edged) both derive nothing.
let a = [
asset(AssetKind::Ducklake, "main.out", Some(W)),
asset(AssetKind::Ducklake, "main.self", Some(RW)),
];
assert!(derive(&a).is_empty());
}
#[test]
fn ambiguous_access_and_unsupported_kinds_are_skipped() {
use AssetUsageAccessType::R;
let a = [
asset(AssetKind::Ducklake, "main.ambiguous", None), // poisoned merge
asset(AssetKind::Resource, "f/db", Some(R)), // out of scope
asset(AssetKind::DataTable, "main.dt", Some(R)), // out of scope
];
assert!(derive(&a).is_empty());
}
#[test]
fn manual_read_override_on_ambiguous_parse_derives_an_edge() {
use AssetUsageAccessType::{R, W};
// Parser can't confirm access (`access_type: None`) but the user manually
// overrode it. Effective access = `access_type.or(alt_access_type)`, the
// same value persisted to `asset.usage_access_type` and used by the
// frontend canvas — so a read override derives an edge (parity, no
// silently-vanishing edge on deploy) and a write override does not.
let read_override = AssetWithAltAccessType {
path: "main.override_r".to_string(),
kind: AssetKind::Ducklake,
access_type: None,
alt_access_type: Some(R),
columns: None,
};
let write_override = AssetWithAltAccessType {
path: "main.override_w".to_string(),
kind: AssetKind::Ducklake,
access_type: None,
alt_access_type: Some(W),
columns: None,
};
assert_eq!(
derive(&[read_override, write_override]),
vec!["ducklake://main.override_r".to_string()]
);
}
#[test]
fn explicit_and_muted_refs_are_excluded() {
use AssetUsageAccessType::R;
let a = [
asset(AssetKind::Ducklake, "main.explicit", Some(R)),
asset(AssetKind::Ducklake, "main.muted", Some(R)),
asset(AssetKind::Ducklake, "main.keep", Some(R)),
];
let explicit: HashSet<String> = ["ducklake://main.explicit".to_string()].into();
let muted: HashSet<String> = ["ducklake://main.muted".to_string()].into();
assert_eq!(
derive_pipeline_asset_trigger_refs(&a, &explicit, &muted, false),
vec!["ducklake://main.keep".to_string()]
);
}
#[test]
fn mute_all_derives_nothing() {
use AssetUsageAccessType::R;
let a = [asset(AssetKind::Ducklake, "main.orders", Some(R))];
assert!(
derive_pipeline_asset_trigger_refs(&a, &HashSet::new(), &HashSet::new(), true)
.is_empty()
);
}
#[test]
fn duplicate_reads_dedup() {
use AssetUsageAccessType::R;
let a = [
asset(AssetKind::Ducklake, "main.orders", Some(R)),
asset(AssetKind::Ducklake, "main.orders", Some(R)),
];
assert_eq!(derive(&a), vec!["ducklake://main.orders".to_string()]);
}
}
#[cfg(test)]
mod trigger_ref_roundtrip_tests {
use super::{parse_asset_trigger_ref, trigger_spec_to_row, AssetKind, ScriptTriggerKind};
+8
View File
@@ -273,6 +273,14 @@ lazy_static::lazy_static! {
/// production `app.windmill.dev` cluster, not on staging or self-hosted.
pub static ref CLOUD_PRODUCTION_HOST: &'static str = "app.windmill.dev";
/// `--no-auth` mode: when set, every API request is treated as
/// authenticated as the `admin@windmill.dev` superadmin and no login is
/// ever required. Meant for self-hosted deployments that front Windmill
/// with their own authenticating gateway. Never honored on the managed
/// cloud (`CLOUD_HOSTED`), which must always enforce real authentication.
pub static ref NO_AUTH: bool = !*CLOUD_HOSTED
&& std::env::var("NO_AUTH").ok().is_some_and(|x| x == "1" || x == "true");
pub static ref CUSTOM_TAGS: Vec<String> = std::env::var("CUSTOM_TAGS")
.ok()
.map(|x| x.split(',').map(|x| x.to_string()).collect::<Vec<_>>()).unwrap_or_default();
@@ -164,6 +164,7 @@ pub enum ObjectType {
Settings,
Key,
WorkspaceDependencies,
DatatableMigration,
}
pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28719/sync-script-to-git-repo-windmill";
@@ -771,6 +772,11 @@ pub struct DataTable {
pub database: DataTableDatabase,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub forked_from: Option<DataTableForkedFrom>,
/// Whether the SQL-migrations feature is opted in for this data table.
/// Absent on data tables created before the feature: treated as enabled only
/// when migrations already exist (see `datatable_migrations_enabled`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub migrations_enabled: Option<bool>,
}
#[derive(Deserialize, Serialize, Debug)]
+99 -24
View File
@@ -24,30 +24,102 @@ pub use git_sync_oss::{
#[derive(Clone, Debug)]
pub enum DeployedObject {
Script { hash: ScriptHash, path: String, parent_path: Option<String> },
Flow { path: String, parent_path: Option<String>, version: i64 },
App { path: String, version: i64, parent_path: Option<String> },
RawApp { path: String, version: i64, parent_path: Option<String> },
Folder { path: String },
Resource { path: String, parent_path: Option<String> },
Variable { path: String, parent_path: Option<String> },
Schedule { path: String },
ResourceType { path: String },
User { email: String },
Group { name: String },
HttpTrigger { path: String, parent_path: Option<String> },
WebsocketTrigger { path: String, parent_path: Option<String> },
KafkaTrigger { path: String, parent_path: Option<String> },
NatsTrigger { path: String, parent_path: Option<String> },
PostgresTrigger { path: String, parent_path: Option<String> },
MqttTrigger { path: String, parent_path: Option<String> },
SqsTrigger { path: String, parent_path: Option<String> },
GcpTrigger { path: String, parent_path: Option<String> },
AzureTrigger { path: String, parent_path: Option<String> },
EmailTrigger { path: String, parent_path: Option<String> },
Settings { setting_type: String },
Key { key_type: String },
WorkspaceDependencies { path: String },
Script {
hash: ScriptHash,
path: String,
parent_path: Option<String>,
},
Flow {
path: String,
parent_path: Option<String>,
version: i64,
},
App {
path: String,
version: i64,
parent_path: Option<String>,
},
RawApp {
path: String,
version: i64,
parent_path: Option<String>,
},
Folder {
path: String,
},
Resource {
path: String,
parent_path: Option<String>,
},
Variable {
path: String,
parent_path: Option<String>,
},
Schedule {
path: String,
},
ResourceType {
path: String,
},
User {
email: String,
},
Group {
name: String,
},
HttpTrigger {
path: String,
parent_path: Option<String>,
},
WebsocketTrigger {
path: String,
parent_path: Option<String>,
},
KafkaTrigger {
path: String,
parent_path: Option<String>,
},
NatsTrigger {
path: String,
parent_path: Option<String>,
},
PostgresTrigger {
path: String,
parent_path: Option<String>,
},
MqttTrigger {
path: String,
parent_path: Option<String>,
},
SqsTrigger {
path: String,
parent_path: Option<String>,
},
GcpTrigger {
path: String,
parent_path: Option<String>,
},
AzureTrigger {
path: String,
parent_path: Option<String>,
},
EmailTrigger {
path: String,
parent_path: Option<String>,
},
Settings {
setting_type: String,
},
Key {
key_type: String,
},
WorkspaceDependencies {
path: String,
},
/// A single data table migration, identified by `<datatable>/<timestamp>_<name>`.
DatatableMigration {
path: String,
},
}
impl DeployedObject {
@@ -77,6 +149,7 @@ impl DeployedObject {
DeployedObject::Settings { .. } => "settings.yaml".to_string(),
DeployedObject::Key { .. } => "encryption_key.yaml".to_string(),
DeployedObject::WorkspaceDependencies { path, .. } => path.to_owned(),
DeployedObject::DatatableMigration { path } => path.to_owned(),
}
}
@@ -118,6 +191,7 @@ impl DeployedObject {
DeployedObject::Settings { .. } => None,
DeployedObject::Key { .. } => None,
DeployedObject::WorkspaceDependencies { .. } => None,
DeployedObject::DatatableMigration { .. } => None,
}
}
@@ -147,6 +221,7 @@ impl DeployedObject {
DeployedObject::Settings { .. } => "settings",
DeployedObject::Key { .. } => "key",
DeployedObject::WorkspaceDependencies { .. } => "workspace_dependencies",
DeployedObject::DatatableMigration { .. } => "datatable_migration",
}
.to_string()
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.750.0";
export const VERSION = "v1.751.0";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+71
View File
@@ -9,6 +9,13 @@ import { GlobalOptions } from "../../types.ts";
import { runCatalogQuery } from "../../utils/catalog.ts";
import { psql as psqlDatatable } from "./psql.ts";
import { serve as serveDatatable } from "./serve.ts";
import {
createMigration,
pushLocalMigrations,
rollbackMigrations,
runMigrations,
validateLocalMigrations,
} from "../datatable_migrations.ts";
const DEFAULT_DATATABLE_NAME = "main";
@@ -41,6 +48,69 @@ async function run(
await runCatalogQuery(opts, "datatable", name, sql);
}
function migrateNew(
opts: GlobalOptions & { datatable?: string },
name: string,
) {
createMigration(opts.datatable ?? DEFAULT_DATATABLE_NAME, name);
}
async function migrateUp(opts: GlobalOptions & { datatable?: string }) {
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
const dt = opts.datatable ?? DEFAULT_DATATABLE_NAME;
// Reject malformed local migrations (duplicate timestamps, orphan downs) before
// pushing — the same check `wmill sync push` runs — so a duplicate timestamp
// can't silently overwrite one migration on upsert.
const errors = validateLocalMigrations(new Set([dt]));
if (errors.length > 0) {
log.error(
"Invalid datatable migrations, aborting:\n" +
errors.map((e) => ` - ${e}`).join("\n"),
);
process.exit(1);
}
// Push any locally-created/edited migration files first (without running
// them), so `migrate up` works even before a `wmill sync push`.
await pushLocalMigrations(workspace.workspaceId, dt);
await runMigrations(workspace.workspaceId, dt);
}
async function migrateDown(opts: GlobalOptions & { datatable?: string }) {
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
const dt = opts.datatable ?? DEFAULT_DATATABLE_NAME;
await rollbackMigrations(workspace.workspaceId, dt);
}
const migrateCommand = new Command()
.description("manage datatable migrations")
.command("new", "scaffold a new migration (.up.sql / .down.sql files)")
.arguments("<name:string>")
.option(
"-d --datatable <datatable:string>",
"Target datatable (default: main)",
)
.action(migrateNew as any)
.command(
"up",
"apply all pending migrations to the main datatable (or one via --datatable)",
)
.option(
"-d --datatable <datatable:string>",
"Target datatable (default: main)",
)
.action(migrateUp as any)
.command(
"down",
"roll back the most recent migration on the main datatable (or one via --datatable)",
)
.option(
"-d --datatable <datatable:string>",
"Target datatable (default: main)",
)
.action(migrateDown as any);
async function create(
opts: GlobalOptions & { resource?: string; force?: boolean },
name?: string,
@@ -124,6 +194,7 @@ const command = new Command()
"Output only the final result as JSON. Useful for scripting.",
)
.action(run as any)
.command("migrate", migrateCommand)
.command(
"create",
"register a datatable database in the workspace (default: instance-backed 'main') so scripts can use datatable://<name>",
+340
View File
@@ -0,0 +1,340 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as log from "../core/log.ts";
import { colors } from "@cliffy/ansi/colors";
import * as wmill from "../../gen/services.gen.ts";
import { readTextFile } from "../utils/utils.ts";
import { Confirm } from "@cliffy/prompt/confirm";
// Migrations live under <cwd>/migrations/datatable/<datatable>/, one folder per
// target data table, as `<timestamp>_<name>.up.sql` (and optional `.down.sql`).
// They are synced as ordinary workspace files (see the workspace tarball export
// and the `datatable_migration` handling in sync.ts); this module only holds the
// `wmill datatable migrate` command helpers and the per-file push primitive.
const MIGRATIONS_DIR = path.join("migrations", "datatable");
// Migration names map directly onto file names and the DB `name` column.
const MIGRATION_NAME_RE = /^[a-zA-Z0-9_-]+$/;
/** Current UTC time as a YYYYMMDDHHMMSS migration version. */
function migrationTimestamp(): string {
const d = new Date();
const p = (n: number) => String(n).padStart(2, "0");
return (
`${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}` +
`${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}`
);
}
/**
* A migration version unique within a data table folder: the current UTC
* timestamp bumped past any existing version, so two migrations scaffolded in
* the same second don't collide on the `(datatable, timestamp)` identity used to
* upsert them.
*/
function nextMigrationTimestamp(dir: string): string {
const now = Number(migrationTimestamp());
let max = 0;
if (fs.existsSync(dir)) {
for (const file of fs.readdirSync(dir)) {
const m = file.match(/^(\d+)_.*\.(up|down)\.sql$/);
if (m) max = Math.max(max, Number(m[1]));
}
}
return String(max >= now ? max + 1 : now);
}
/**
* Scaffold a new migration under migrations/datatable/<datatable>/ as empty
* `<timestamp>_<name>.up.sql` and `.down.sql` files. Purely local no network.
*/
export function createMigration(datatable: string, name: string): void {
if (!MIGRATION_NAME_RE.test(name)) {
throw new Error(
`Invalid migration name '${name}': use only letters, digits, '_' and '-'`,
);
}
const dir = path.join(process.cwd(), MIGRATIONS_DIR, datatable);
fs.mkdirSync(dir, { recursive: true });
const timestamp = nextMigrationTimestamp(dir);
const base = `${timestamp}_${name}`;
const up = path.join(dir, `${base}.up.sql`);
const down = path.join(dir, `${base}.down.sql`);
// Frame the body in an explicit transaction so it applies atomically, matching
// the template the UI's "New migration" modal seeds.
const template = (direction: string) =>
`-- ${direction} migration: ${name}\nBEGIN;\n\n-- Add your migration here\n\nEND;\n`;
fs.writeFileSync(up, template("up"), "utf-8");
fs.writeFileSync(down, template("down"), "utf-8");
log.info(
colors.green(`Created migration ${base} in ${MIGRATIONS_DIR}/${datatable}/`),
);
for (const f of [up, down]) {
log.info(colors.gray(` ${path.relative(process.cwd(), f)}`));
}
}
/**
* Apply the workspace's pending migrations to a data table (forwards migrations
* recorded in `_wm_migrations`). Mirrors `wmill datatable migrate up`.
*/
export async function runMigrations(
workspace: string,
datatableName: string,
): Promise<void> {
const result = await wmill.runDatatableMigrations({
workspace,
datatableName,
});
const applied = result.applied ?? [];
if (applied.length === 0) {
log.info(colors.gray(`No pending migrations to run on '${datatableName}'`));
return;
}
log.info(
colors.green(`Applied ${applied.length} migration(s) to '${datatableName}':`),
);
for (const m of applied) {
log.info(colors.gray(` ${m.version} ${m.name}`));
}
}
/**
* Roll back the most recently applied migration on a data table (one step).
* Mirrors `wmill datatable migrate down`.
*/
export async function rollbackMigrations(
workspace: string,
datatableName: string,
): Promise<void> {
const result = await wmill.rollbackDatatableMigrations({
workspace,
datatableName,
});
const rolledBack = result.rolled_back ?? [];
if (rolledBack.length === 0) {
log.info(
colors.gray(`No applied migrations to roll back on '${datatableName}'`),
);
return;
}
for (const m of rolledBack) {
log.info(
colors.green(`Rolled back migration ${m.version} ${m.name} on '${datatableName}'`),
);
}
}
/**
* Validate the on-disk migration files for the given data tables (or all of
* them when `datatables` is omitted). Returns a list of human-readable problems;
* an empty list means the migrations are well-formed. Two states are invalid:
* - two up (or two down) files sharing the same timestamp, which collide on the
* `(datatable, timestamp)` identity used to upsert; and
* - a `.down.sql` with no matching `.up.sql` (an up file is mandatory).
*/
export function validateLocalMigrations(datatables?: Set<string>): string[] {
const errors: string[] = [];
const root = path.join(process.cwd(), MIGRATIONS_DIR);
if (!fs.existsSync(root)) return errors;
for (const datatable of fs.readdirSync(root)) {
if (datatables && !datatables.has(datatable)) continue;
const dtDir = path.join(root, datatable);
if (!fs.statSync(dtDir).isDirectory()) continue;
const upNamesByTs = new Map<number, string[]>();
const downNamesByTs = new Map<number, string[]>();
const upBases = new Set<string>();
const downBases: { ts: number; name: string }[] = [];
for (const file of fs.readdirSync(dtDir)) {
const m = file.match(/^(\d+)_(.*)\.(up|down)\.sql$/);
if (!m) continue;
const ts = Number(m[1]);
const name = m[2];
if (m[3] === "up") {
(upNamesByTs.get(ts) ?? upNamesByTs.set(ts, []).get(ts)!).push(name);
upBases.add(`${ts}_${name}`);
} else {
(downNamesByTs.get(ts) ?? downNamesByTs.set(ts, []).get(ts)!).push(name);
downBases.push({ ts, name });
}
}
for (const [ts, names] of upNamesByTs) {
if (names.length > 1) {
errors.push(
`${datatable}: ${names.length} up migrations share timestamp ${ts} (${names.join(", ")})`,
);
}
}
for (const [ts, names] of downNamesByTs) {
if (names.length > 1) {
errors.push(
`${datatable}: ${names.length} down migrations share timestamp ${ts} (${names.join(", ")})`,
);
}
}
for (const d of downBases) {
if (!upBases.has(`${d.ts}_${d.name}`)) {
errors.push(
`${datatable}: ${d.ts}_${d.name}.down.sql has no matching ${d.ts}_${d.name}.up.sql`,
);
}
}
}
return errors;
}
/**
* Sync a single migration to the workspace based on the current on-disk state of
* its `<datatable>/<timestamp>_<name>.up.sql` file: upsert it when the up file
* exists, otherwise delete it. Called by `wmill sync push` for each changed
* `datatable_migration` file.
*/
export async function pushMigrationFromDisk(
workspace: string,
m: { datatable: string; timestamp: number },
): Promise<void> {
const dir = path.join(process.cwd(), MIGRATIONS_DIR, m.datatable);
// Find the up file for this timestamp regardless of its name segment. A rename
// (`123_old.up.sql` -> `123_new.up.sql`) keeps the (datatable, timestamp)
// identity but changes the name; the diff sorter may process the deleted old
// path before the added new one, so keying off the passed name would delete
// the record. Scanning by timestamp upserts the surviving file instead.
const upFile = fs.existsSync(dir)
? fs.readdirSync(dir).find((f) => {
const parsed = f.match(/^(\d+)_(.*)\.up\.sql$/);
return parsed !== null && Number(parsed[1]) === m.timestamp;
})
: undefined;
if (upFile === undefined) {
log.info(colors.red(`Deleting datatable_migration ${m.datatable}/${m.timestamp}`));
await wmill.deleteDatatableMigration({
workspace,
datatableName: m.datatable,
timestamp: m.timestamp,
});
return;
}
const name = upFile.match(/^(\d+)_(.*)\.up\.sql$/)![2];
const base = `${m.timestamp}_${name}`;
const code_up = await readTextFile(path.join(dir, upFile));
const downPath = path.join(dir, `${base}.down.sql`);
const code_down = fs.existsSync(downPath) ? await readTextFile(downPath) : undefined;
log.info(colors.green(`Pushing datatable_migration ${m.datatable}/${base}`));
await wmill.upsertDatatableMigration({
workspace,
datatableName: m.datatable,
requestBody: {
timestamp: m.timestamp,
name,
code_up,
...(code_down !== undefined ? { code_down } : {}),
},
});
}
/**
* Upsert the on-disk migrations of a data table to the workspace, so a freshly
* created migration file works with `wmill datatable migrate up` even without a
* prior `wmill sync push`. Pushes only migrations that are new or edited
* (compared against the workspace's current definitions); it never deletes
* remote migrations absent on disk and never touches other item kinds.
*/
export async function pushLocalMigrations(
workspace: string,
datatableName: string,
): Promise<void> {
const dir = path.join(process.cwd(), MIGRATIONS_DIR, datatableName);
if (!fs.existsSync(dir)) return;
// Local migrations are identified by their `.up.sql` file (the up file is
// mandatory); this deliberately ignores files that were only deleted locally.
const local: { timestamp: number; name: string }[] = [];
for (const file of fs.readdirSync(dir)) {
const m = file.match(/^(\d+)_(.*)\.up\.sql$/);
if (m) local.push({ timestamp: Number(m[1]), name: m[2] });
}
if (local.length === 0) return;
const remote = await wmill.listDatatableMigrations({ workspace });
const remoteByTs = new Map(
remote
.filter((r) => r.datatable === datatableName)
.map((r) => [r.timestamp, r] as const),
);
for (const { timestamp, name } of local) {
const base = `${timestamp}_${name}`;
const code_up = await readTextFile(path.join(dir, `${base}.up.sql`));
const downPath = path.join(dir, `${base}.down.sql`);
const code_down = fs.existsSync(downPath)
? await readTextFile(downPath)
: undefined;
const r = remoteByTs.get(timestamp);
const unchanged =
r !== undefined &&
r.name === name &&
r.code_up === code_up &&
(r.code_down ?? undefined) === code_down;
if (unchanged) continue;
log.info(colors.green(`Pushing datatable_migration ${datatableName}/${base}`));
await wmill.upsertDatatableMigration({
workspace,
datatableName,
requestBody: {
timestamp,
name,
code_up,
...(code_down !== undefined ? { code_down } : {}),
},
});
}
}
/**
* After a push that introduced new migrations, list them and (interactively)
* offer to run them, equivalent to `wmill datatable migrate up` on each affected
* data table.
*/
export async function offerToRunNewMigrations(
workspace: string,
newMigrations: { datatable: string; timestamp: number; name: string }[],
opts?: { yes?: boolean; jsonOutput?: boolean },
): Promise<void> {
if (newMigrations.length === 0) return;
log.info(colors.green("New migrations were pushed:"));
for (const m of newMigrations) {
log.info(colors.gray(` ${m.datatable}: ${m.timestamp} ${m.name}`));
}
// Running migrations mutates the data tables, so skip the prompt in
// non-interactive contexts (--yes, --json, no TTY).
const interactive = !opts?.jsonOutput && !opts?.yes && !!process.stdin.isTTY;
if (!interactive) {
return;
}
const shouldRun = await Confirm.prompt({
message: "New migrations were pushed, run them?",
default: false,
});
if (!shouldRun) {
return;
}
for (const datatable of new Set(newMigrations.map((m) => m.datatable))) {
await runMigrations(workspace, datatable);
}
}
@@ -2,7 +2,7 @@ import { Command } from "@cliffy/command";
import { Confirm } from "@cliffy/prompt/confirm";
import { colors } from "@cliffy/ansi/colors";
import { sep as SEP } from "node:path";
import { GlobalOptions } from "../../types.ts";
import { GlobalOptions, isDatatableMigrationPath } from "../../types.ts";
import { SyncOptions, mergeConfigWithConfigFile } from "../../core/conf.ts";
import { resolveWorkspace } from "../../core/context.ts";
import { requireLogin } from "../../core/auth.ts";
@@ -54,6 +54,8 @@ async function walkLocalScripts(
(!isD && !exts.some((ext) => p.endsWith(ext))) ||
ignore(p, isD) ||
isFolderResourcePathAnyFormat(p) ||
// Datatable migration `.sql` files aren't Windmill scripts.
isDatatableMigrationPath(p) ||
(isScriptModulePath(p) && !isModuleEntryPoint(p)),
false,
{},
@@ -221,6 +223,8 @@ function categorizeLocalFiles(
} else if (
exts.some((ext) => p.endsWith(ext)) &&
!isFolderResourcePathAnyFormat(p) &&
// Datatable migration `.sql` files aren't Windmill scripts.
!isDatatableMigrationPath(p) &&
!(isScriptModulePath(p) && !isModuleEntryPoint(p))
) {
scripts.push(p);
+97 -9
View File
@@ -23,10 +23,17 @@ import {
showDiff,
extractNativeTriggerInfo,
redactEncryptionKey,
isDatatableMigrationPath,
parseDatatableMigrationPath,
} from "../../types.ts";
import { downloadZip } from "./pull.ts";
import { runLint, printReport, checkMissingLocks } from "../lint/lint.ts";
import { pullSharedUi, pushSharedUi } from "../shared_ui.ts";
import {
pushMigrationFromDisk,
offerToRunNewMigrations,
validateLocalMigrations,
} from "../datatable_migrations.ts";
import {
exts,
@@ -2477,7 +2484,8 @@ const isNotWmillFile = (p: string, isDirectory: boolean) => {
!p.startsWith("g" + SEP) &&
!p.startsWith("users" + SEP) &&
!p.startsWith("groups" + SEP) &&
!p.startsWith("dependencies" + SEP)
!p.startsWith("dependencies" + SEP) &&
!p.startsWith("migrations" + SEP)
);
}
@@ -2488,6 +2496,11 @@ const isNotWmillFile = (p: string, isDirectory: boolean) => {
try {
const typ = getTypeStrFromPath(p);
// Datatable migrations live under migrations/datatable/<datatable>/, outside
// the u/f/g namespaces, but are valid wmill files.
if (typ == "datatable_migration") {
return false;
}
if (
typ == "resource-type" ||
typ == "settings" ||
@@ -2519,7 +2532,8 @@ export const isWhitelisted = (p: string) => {
p == "ui" ||
p == "users" ||
p == "groups" ||
p == "dependencies"
p == "dependencies" ||
p == "migrations"
);
};
@@ -2614,6 +2628,11 @@ interface ChangeTracker {
}
async function addToChangedIfNotExists(p: string, tracker: ChangeTracker) {
// Datatable migration .sql files are not scripts; they're synced via the
// dedicated datatable_migration handler in the push loop.
if (isDatatableMigrationPath(p)) {
return;
}
const isScript = exts.some((e) => p.endsWith(e)) && !isFileResource(p) && !isFilesetResource(p);
if (isScript) {
if (isFlowPath(p)) {
@@ -3119,7 +3138,7 @@ export async function pull(
change.path.endsWith(".json")
) {
log.info(
`Editing ${getTypeStrFromPath(change.path)} ${targetPath}${
`Editing ${changeTypeLabel(change.path)}${targetPath}${
targetPath !== change.path
? colors.gray(` (workspace-specific override for ${change.path})`)
: ""
@@ -3137,7 +3156,7 @@ export async function pull(
if (opts.stateful) {
await mkdir(path.dirname(stateTarget), { recursive: true });
log.info(
`Adding ${getTypeStrFromPath(change.path)} ${targetPath}${
`Adding ${changeTypeLabel(change.path)}${targetPath}${
targetPath !== change.path
? colors.gray(` (workspace-specific override for ${change.path})`)
: ""
@@ -3146,7 +3165,7 @@ export async function pull(
}
await writeFile(target, change.content, "utf-8");
log.info(
`Writing ${getTypeStrFromPath(change.path)} ${targetPath}${
`Writing ${changeTypeLabel(change.path)}${targetPath}${
targetPath !== change.path
? colors.gray(` (workspace-specific override for ${change.path})`)
: ""
@@ -3158,7 +3177,7 @@ export async function pull(
} else if (change.name === "deleted") {
try {
log.info(
`Deleting ${getTypeStrFromPath(change.path)} ${change.path}`,
`Deleting ${changeTypeLabel(change.path)}${change.path}`,
);
await rm(target);
if (opts.stateful) {
@@ -3350,6 +3369,9 @@ export async function pull(
log.warn(`Failed to pull shared UI folder: ${e}`);
}
// Datatable migrations are part of the workspace export now, so they flow
// through the normal diff/apply above as `datatable_migration` items.
// Git-sync deployment-callback mode stops here: branch checkout + pull have
// happened, but commit + push are the caller's job. The hub script does
// them in-process with `set_gpg_signing_secret` so the agent's pre-warmed
@@ -3425,6 +3447,14 @@ export async function gitDeploy(
} as any);
}
// Display label for a change's type, with a trailing space. Datatable migrations
// are self-describing via their `migrations/datatable/...` path, so they get no
// label prefix.
function changeTypeLabel(p: string): string {
const t = getTypeStrFromPath(p);
return t === "datatable_migration" ? "" : `${t} `;
}
function prettyChanges(
changes: Change[],
specificItems?: SpecificItemsConfig,
@@ -3456,7 +3486,7 @@ function prettyChanges(
if (change.name === "added") {
log.info(
colors.green(
`+ ${getTypeStrFromPath(change.path)} ` +
`+ ${changeTypeLabel(change.path)}` +
displayPath +
colors.gray(wsNote),
) + extraNote,
@@ -3464,7 +3494,7 @@ function prettyChanges(
} else if (change.name === "deleted") {
log.info(
colors.red(
`- ${getTypeStrFromPath(change.path)} ` +
`- ${changeTypeLabel(change.path)}` +
displayPath +
colors.gray(wsNote),
),
@@ -3473,7 +3503,7 @@ function prettyChanges(
const changeType = getTypeStrFromPath(change.path);
log.info(
colors.yellow(
`~ ${changeType} ` +
`~ ${changeTypeLabel(change.path)}` +
displayPath +
colors.gray(wsNote) +
(change.codebase ? ` (codebase changed)` : ""),
@@ -4221,6 +4251,24 @@ export async function push(
));
}
// Reject malformed datatable migrations (duplicate timestamps, orphan downs)
// before touching the remote, scanning only the data tables in this push.
const migrationDatatables = new Set(
changes
.map((c) => parseDatatableMigrationPath(c.path)?.datatable)
.filter((d): d is string => !!d),
);
if (migrationDatatables.size > 0) {
const migrationErrors = validateLocalMigrations(migrationDatatables);
if (migrationErrors.length > 0) {
log.error(
"Invalid datatable migrations, aborting push:\n" +
migrationErrors.map((e) => ` - ${e}`).join("\n"),
);
process.exit(1);
}
}
if (
!opts.yes &&
!(await Confirm.prompt({
@@ -4293,6 +4341,21 @@ export async function push(
// Cache git branch at the start to avoid repeated execSync calls per change
const cachedWsNameForPush = wsNameForFiles || (isGitRepository() ? getCurrentGitBranch() : null);
// Datatable migrations are two files (.up.sql/.down.sql) for one record, so
// dedupe upsert/delete by (datatable, version) across the whole push.
const pushedMigrationKeys = new Set<string>();
// Migrations newly added by this push (an added .up.sql) — offered to run once
// the push has completed.
const newDatatableMigrations = changes
.filter((c) => c.name === "added")
.map((c) => parseDatatableMigrationPath(c.path))
.filter((p) => !!p && p.kind === "up")
.map((p) => ({
datatable: p!.datatable,
timestamp: p!.timestamp,
name: p!.name,
}));
while (queue.length > 0 || pool.size > 0) {
// Fill the pool until we reach the effective parallelism limit.
// During the folder-meta phase this is 1 (sequential) so no item change
@@ -4320,6 +4383,20 @@ export async function push(
}
for await (const change of changes) {
// A datatable migration is one record across two files; upsert/delete
// it from disk once (deduped), regardless of which file changed.
if (isDatatableMigrationPath(change.path)) {
const parsed = parseDatatableMigrationPath(change.path);
if (parsed) {
const key = `${parsed.datatable}\0${parsed.timestamp}`;
if (!pushedMigrationKeys.has(key)) {
pushedMigrationKeys.add(key);
await pushMigrationFromDisk(workspace.workspaceId, parsed);
}
}
continue;
}
let stateTarget = undefined;
if (stateful) {
try {
@@ -4974,6 +5051,16 @@ export async function push(
} catch (e) {
log.warn(`Failed to push shared UI folder: ${e}`);
}
try {
await offerToRunNewMigrations(workspace.workspaceId, newDatatableMigrations, {
yes: opts.yes,
jsonOutput: opts.jsonOutput,
});
} catch (e: any) {
log.warn(
`Failed to run new datatable migrations: ${e?.body ?? e?.message ?? e}`,
);
}
const lockJobs = await checkServerLockJobs(
workspace.workspaceId,
pushStartedAt,
@@ -5039,6 +5126,7 @@ export async function push(
} catch (e) {
log.warn(`Failed to push shared UI folder: ${e}`);
}
// No changes pushed, so no new datatable migrations to run.
if (opts.jsonOutput) {
console.log(
JSON.stringify(
+32
View File
@@ -11,10 +11,12 @@ import {
deleteItemInWorkspace,
getOnBehalfOf,
isTriggerOrScheduleKind,
parseDatatableMigrationDeployPath,
type DeployKind,
type DeployProvider,
type TriggerDeployKind,
} from "../../../windmill-utils-internal/src/deploy.ts";
import { offerToRunNewMigrations } from "../datatable_migrations.ts";
// ---------------------------------------------------------------------------
// Provider adapter — wraps CLI's standalone API functions
@@ -85,6 +87,10 @@ const provider: DeployProvider = {
createSchedule: wmill.createSchedule,
updateSchedule: wmill.updateSchedule,
deleteSchedule: wmill.deleteSchedule,
// Datatable migrations
listDatatableMigrations: wmill.listDatatableMigrations,
upsertDatatableMigration: wmill.upsertDatatableMigration,
deleteDatatableMigration: wmill.deleteDatatableMigration,
};
/**
@@ -530,6 +536,14 @@ async function mergeWorkspaces(
// 10. Deploy
let successCount = 0;
let failCount = 0;
// Datatable migrations deployed (not deleted) into the target. Deploying a
// migration only upserts its definition — the target schema is unchanged until
// the migration is run — so offer to run them afterwards (like the push path).
const deployedMigrations: {
datatable: string;
timestamp: number;
name: string;
}[] = [];
for (const diff of sorted) {
const label = `${diff.kind}:${diff.path}`;
@@ -573,6 +587,12 @@ async function mergeWorkspaces(
if (result.success) {
log.info(colors.green(`${label}`));
successCount++;
if (
!itemDeletedInSource &&
(diff.kind as DeployKind) === "datatable_migration"
) {
deployedMigrations.push(parseDatatableMigrationDeployPath(diff.path));
}
} else {
log.info(colors.red(`${label}: ${result.error}`));
failCount++;
@@ -606,6 +626,18 @@ async function mergeWorkspaces(
)
);
}
// 13. Deployed migration definitions don't touch the target schema until run;
// offer to run them on the target now (interactive only, like the push path).
if (deployedMigrations.length > 0) {
try {
await offerToRunNewMigrations(workspaceTo, deployedMigrations, {
yes: opts.yes,
});
} catch (e) {
log.warn(colors.yellow(`Failed to run deployed migrations: ${e}`));
}
}
}
export { mergeWorkspaces };
+1 -1
View File
@@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork";
// (e.g. utils.ts) can read it without importing main.ts and creating a circular
// dependency (main → workspace → utils → main) that triggers a TDZ.
// Re-exported from main.ts for backwards compatibility.
export const VERSION = "1.750.0";
export const VERSION = "1.751.0";
+18 -10
View File
@@ -5561,6 +5561,8 @@ A raw app has three logical parts:
\`index.tsx\` is the bundling entrypoint. It typically renders a top-level \`App\` component. The bundler is esbuild.
**Always begin every React file (\`.tsx\`/\`.jsx\`) that uses JSX with \`import React from 'react'\`.** esbuild uses the classic JSX transform, so \`React\` must be in scope wherever JSX appears — a missing import compiles fine but throws \`React is not defined\` at runtime, leaving a blank screen.
### Generated bindings (\`wmill.d.ts\` / \`wmill.ts\`)
The frontend imports a generated module that mirrors the backend runnables. **Never write to it directly** it gets regenerated whenever backend runnables change. Modifying it by hand will be overwritten.
@@ -6591,6 +6593,13 @@ datatable related commands
- \`datatable run <sql:string>\` - run a SQL query on a datatable
- \`-n --name <name:string>\` - Datatable name (default: main)
- \`-s --silent\` - Output only the final result as JSON. Useful for scripting.
- \`datatable migrate\` - manage datatable migrations
- \`datatable migrate new <name:string>\` - scaffold a new migration (.up.sql / .down.sql files)
- \`-d --datatable <datatable:string>\` - Target datatable (default: main)
- \`datatable migrate up\` - apply all pending migrations to the main datatable (or one via --datatable)
- \`-d --datatable <datatable:string>\` - Target datatable (default: main)
- \`datatable migrate down\` - roll back the most recent migration on the main datatable (or one via --datatable)
- \`-d --datatable <datatable:string>\` - Target datatable (default: main)
- \`datatable create [name:string]\` - register a datatable database in the workspace (default: instance-backed 'main') so scripts can use datatable://<name>
- \`--resource <resource:string>\` - Back the datatable with an existing postgresql resource path instead of the instance database
- \`--force\` - Allow adding to a workspace that already has datatables (fork metadata on existing ones is not preserved)
@@ -6859,19 +6868,18 @@ Manage jobs (list, inspect, cancel)
### jobs
Pull completed and queued jobs from workspace
**Arguments:** \`[workspace:string]\`
**Options:**
- \`-c, --completed-output <file:string>\` - Completed jobs output file (default: completed_jobs.json)
- \`-q, --queued-output <file:string>\` - Queued jobs output file (default: queued_jobs.json)
- \`--skip-worker-check\` - Skip checking for active workers before export
Manage jobs (import/export)
**Subcommands:**
- \`jobs pull\`
- \`jobs push\`
- \`jobs pull [workspace:string]\` - Pull completed and queued jobs from workspace
- \`-c, --completed-output <file:string>\` - Completed jobs output file (default: completed_jobs.json)
- \`-q, --queued-output <file:string>\` - Queued jobs output file (default: queued_jobs.json)
- \`--skip-worker-check\` - Skip checking for active workers before export
- \`jobs push [workspace:string]\` - Push completed and queued jobs to workspace
- \`-c, --completed-file <file:string>\` - Completed jobs input file (default: completed_jobs.json)
- \`-q, --queued-file <file:string>\` - Queued jobs input file (default: queued_jobs.json)
- \`--skip-worker-check\` - Skip checking for active workers before import
### lint
+36 -4
View File
@@ -274,10 +274,10 @@ export function parseFromPath(p: string, content: string): any {
return isWorkspaceDependencies(p)
? content
: p.endsWith(".yaml")
? yamlParseContent(p, content)
: p.endsWith(".json")
? JSON.parse(content)
: content;
? yamlParseContent(p, content)
: p.endsWith(".json")
? JSON.parse(content)
: content;
}
export function parseFromFile(p: string): any {
if (p.endsWith(".json")) {
@@ -288,9 +288,38 @@ export function parseFromFile(p: string): any {
throw new Error("Could not read file " + p);
}
}
/**
* Parse a `migrations/datatable/<datatable>/<timestamp>_<name>.(up|down).sql`
* path into its parts. Returns undefined for any other path.
*/
export function parseDatatableMigrationPath(p: string):
| { datatable: string; timestamp: number; name: string; kind: "up" | "down" }
| undefined {
const parts = p.split("/");
if (
parts[0] !== "migrations" ||
parts[1] !== "datatable" ||
parts.length !== 4
)
return undefined;
const m = parts[3].match(/^(\d+)_(.*)\.(up|down)\.sql$/);
if (!m) return undefined;
return {
datatable: parts[2],
timestamp: Number(m[1]),
name: m[2],
kind: m[3] as "up" | "down",
};
}
export function isDatatableMigrationPath(p: string): boolean {
return parseDatatableMigrationPath(p) !== undefined;
}
export function getTypeStrFromPath(
p: string
):
| "datatable_migration"
| "script"
| "variable"
| "flow"
@@ -316,6 +345,9 @@ export function getTypeStrFromPath(
| "settings"
| "encryption_key"
| "workspace_dependencies" {
if (isDatatableMigrationPath(p)) {
return "datatable_migration";
}
if (isScriptModulePath(p)) {
return "script";
}
+7 -3
View File
@@ -1,5 +1,9 @@
import { Schema, SchemaProperty } from "../../bootstrap/common.ts";
function quotePropName(name: string): string {
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
}
export function compileResourceTypeToTsType(schema: Schema) {
function rec(x: { [name: string]: SchemaProperty }, root = false) {
let res = "{\n";
@@ -10,15 +14,15 @@ export function compileResourceTypeToTsType(schema: Schema) {
let i = 0;
for (let [name, prop] of entries) {
if (prop.type == "object") {
res += ` ${name}: ${rec(prop.properties ?? {})}`;
res += ` ${quotePropName(name)}: ${rec(prop.properties ?? {})}`;
} else if (prop.type == "array") {
res += ` ${name}: ${prop?.items?.type ?? "any"}[]`;
res += ` ${quotePropName(name)}: ${prop?.items?.type ?? "any"}[]`;
} else {
let typ = prop?.type ?? "any";
if (typ == "integer") {
typ = "number";
}
res += ` ${name}: ${typ}`;
res += ` ${quotePropName(name)}: ${typ}`;
}
i++;
if (i < entries.length) {
+122
View File
@@ -0,0 +1,122 @@
/**
* Unit tests for datatable-migration path parsing and local validation.
*
* These exercise pure logic with no backend:
* - `parseDatatableMigrationPath` recognizes only the
* `migrations/datatable/<dt>/<timestamp>_<name>.(up|down).sql` shape.
* - `validateLocalMigrations` rejects the two invalid on-disk states a push
* must catch: two up (or two down) files sharing a timestamp, and a
* `.down.sql` with no matching `.up.sql`.
*/
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { parseDatatableMigrationPath } from "../src/types.ts";
import { validateLocalMigrations } from "../src/commands/datatable_migrations.ts";
describe("parseDatatableMigrationPath", () => {
test("parses up and down files of the new layout", () => {
expect(
parseDatatableMigrationPath(
"migrations/datatable/mydt/20260101000001_create_users.up.sql",
),
).toEqual({
datatable: "mydt",
timestamp: 20260101000001,
name: "create_users",
kind: "up",
});
expect(
parseDatatableMigrationPath(
"migrations/datatable/my-dt/42_x.down.sql",
),
).toEqual({ datatable: "my-dt", timestamp: 42, name: "x", kind: "down" });
});
test("rejects unrelated, legacy and malformed paths", () => {
for (
const p of [
// legacy top-level layout
"datatable_migrations/mydt/20260101000001_x.up.sql",
// wrong sub-namespace / depth
"migrations/ducklake/mydt/1_x.up.sql",
"migrations/datatable/1_x.up.sql",
"migrations/datatable/mydt/sub/1_x.up.sql",
// not a migration file
"migrations/datatable/mydt/notes.txt",
"migrations/datatable/mydt/x.up.sql", // no numeric timestamp prefix
// unrelated workspace files
"f/foo/bar.script.yaml",
"u/admin/script.ts",
]
) {
expect(parseDatatableMigrationPath(p)).toBeUndefined();
}
});
});
describe("validateLocalMigrations", () => {
let prevCwd: string;
let tmp: string;
beforeEach(() => {
prevCwd = process.cwd();
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dtmig-"));
process.chdir(tmp);
});
afterEach(() => {
process.chdir(prevCwd);
fs.rmSync(tmp, { recursive: true, force: true });
});
function write(datatable: string, file: string) {
const dir = path.join(tmp, "migrations", "datatable", datatable);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, file), "-- sql\n");
}
test("accepts up+down pairs and up-only migrations", () => {
write("mydt", "20260101000001_create_users.up.sql");
write("mydt", "20260101000001_create_users.down.sql");
write("mydt", "20260101000002_add_email.up.sql"); // down is optional
expect(validateLocalMigrations()).toEqual([]);
});
test("flags two up files sharing a timestamp", () => {
write("mydt", "20260101000003_foo.up.sql");
write("mydt", "20260101000003_bar.up.sql");
const errors = validateLocalMigrations();
expect(errors.length).toBe(1);
expect(errors[0]).toContain("20260101000003");
});
test("flags two down files sharing a timestamp", () => {
write("mydt", "20260101000004_a.up.sql");
write("mydt", "20260101000004_a.down.sql");
write("mydt", "20260101000004_b.down.sql");
const errors = validateLocalMigrations();
// duplicate down + the b.down orphan (no b.up)
expect(errors.some((e) => e.includes("down") && e.includes("20260101000004"))).toBe(true);
});
test("flags a down file with no matching up", () => {
write("mydt", "20260101000005_orphan.down.sql");
const errors = validateLocalMigrations();
expect(errors.length).toBe(1);
expect(errors[0]).toContain("20260101000005_orphan");
});
test("only validates the requested datatables", () => {
write("bad", "20260101000006_x.up.sql");
write("bad", "20260101000006_y.up.sql"); // duplicate, but in 'bad'
write("good", "20260101000007_ok.up.sql");
expect(validateLocalMigrations(new Set(["good"]))).toEqual([]);
expect(validateLocalMigrations(new Set(["bad"])).length).toBe(1);
});
test("returns no errors when the migrations folder is absent", () => {
expect(validateLocalMigrations()).toEqual([]);
});
});
+68
View File
@@ -0,0 +1,68 @@
import { expect, test } from "bun:test";
import { compileResourceTypeToTsType } from "../src/utils/resource_types.ts";
import type { Schema } from "../bootstrap/common.ts";
// =============================================================================
// Resource-type namespace generation (WIN-2132)
//
// `compileResourceTypeToTsType` renders a JSON Schema into the body of a
// TypeScript type used in the generated `rt.d.ts` (RT namespace). JSON Schema
// property names are unconstrained, so a name with a colon, hyphen, or space
// is legal in the schema but not a valid bare TS identifier. Emitting it raw
// produced syntactically invalid output that broke `tsc`. These tests pin that
// such names are quoted while plain identifiers stay bare.
// =============================================================================
function schema(properties: Schema["properties"]): Schema {
return {
$schema: undefined,
type: "object",
properties,
required: [],
};
}
test("plain identifiers are emitted without quotes", () => {
const out = compileResourceTypeToTsType(
schema({
host: { type: "string" },
_port: { type: "integer" },
$ref: { type: "boolean" },
})
);
expect(out).toContain(" host: string");
expect(out).toContain(" _port: number");
expect(out).toContain(" $ref: boolean");
expect(out).not.toContain('"host"');
});
test("non-identifier property names are double-quoted", () => {
const out = compileResourceTypeToTsType(
schema({
"content-type": { type: "string" },
"x:api:key": { type: "string" },
"with space": { type: "integer" },
"3leading": { type: "boolean" },
})
);
expect(out).toContain(' "content-type": string');
expect(out).toContain(' "x:api:key": string');
expect(out).toContain(' "with space": number');
expect(out).toContain(' "3leading": boolean');
});
test("nested object and array property names are quoted too", () => {
const out = compileResourceTypeToTsType(
schema({
"nested-obj": {
type: "object",
properties: { "inner-key": { type: "string" } },
},
"arr-field": { type: "array", items: { type: "string" } },
})
);
expect(out).toContain('"nested-obj": {');
expect(out).toContain('"inner-key": string');
expect(out).toContain('"arr-field": string[]');
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "windmill-utils-internal",
"version": "1.7.0",
"version": "1.8.2",
"description": "Internal utility functions for Windmill",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
+124
View File
@@ -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<any>;
deleteFolder(p: { workspace: string; name: string }): Promise<any>;
// Datatable migrations. In the diff, an item's `path` is
// `<datatable>/<timestamp>_<name>` (see `parseDatatableMigrationDeployPath`).
listDatatableMigrations(p: { workspace: string }): Promise<any>;
upsertDatatableMigration(p: {
workspace: string;
datatableName: string;
requestBody: {
timestamp: number;
name: string;
code_up: string;
code_down?: string;
};
}): Promise<any>;
deleteDatatableMigration(p: {
workspace: string;
datatableName: string;
timestamp: number;
}): Promise<any>;
// 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 `<datatable>/<timestamp>_<name>`
// (mirrors the backend, e.g. `mydt/20260101000001_create_users`).
export 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 {
@@ -767,6 +868,29 @@ export async function getItemValue(
} else if (isTriggerKind(kind)) {
const trigger = await provider.getTriggerValue(kind, { workspace, path });
return stripTriggerOrScheduleRuntimeFields(trigger);
} else if (kind === "datatable_migration") {
// Surface the migration SQL so the diff drawer shows the up/down bodies a
// reviewer needs to inspect before deploying.
const { datatable, timestamp } = parseDatatableMigrationDeployPath(path);
const migrations = (await provider.listDatatableMigrations({
workspace,
})) as {
datatable: string;
timestamp: number;
name: string;
code_up: string;
code_down?: string;
}[];
const migration = migrations.find(
(m) => m.datatable === datatable && m.timestamp === timestamp
);
if (migration) {
return {
name: migration.name,
code_up: migration.code_up,
code_down: migration.code_down ?? null,
};
}
}
} catch {
// Item may not exist
+4 -1
View File
@@ -35,7 +35,10 @@ RUN wget https://dot.net/v1/dotnet-install.sh -O dotnet-install.sh \
# Oracle DB Client
COPY --from=oracledb-client /opt/oracle/23/lib /opt/oracle/23/lib
RUN apt-get -y update && apt-get install -y libaio1
# libaio1t64 only ships libaio.so.1t64; Oracle instantclient loads libaio.so.1, so add a compat symlink
RUN apt-get -y update && apt-get install -y libaio1t64 \
&& libaio="$(find /usr/lib -name 'libaio.so.1t64' -print -quit)" \
&& ln -s "$(basename "$libaio")" "$(dirname "$libaio")/libaio.so.1"
RUN echo /opt/oracle/23/lib > /etc/ld.so.conf.d/oracle-instantclient.conf && ldconfig
# Nushell
+8 -8
View File
@@ -1,6 +1,6 @@
ARG DEBIAN_IMAGE=debian:bookworm-slim
ARG DEBIAN_IMAGE=debian:trixie-slim
FROM debian:bookworm-slim AS nsjail
FROM debian:trixie-slim AS nsjail
WORKDIR /nsjail
@@ -9,12 +9,12 @@ RUN apt-get -y update \
bison=2:3.8.* \
ca-certificates \
flex=2.6.* \
g++=4:12.2.* \
gcc=4:12.2.* \
git=1:2.39.* \
g++=4:14.2.* \
gcc=4:14.2.* \
git=1:2.47.* \
libprotobuf-dev=3.21.* \
libnl-route-3-dev=3.7.* \
make=4.3-4.1 \
make=4.4.* \
pkg-config=1.8.* \
protobuf-compiler=3.21.* \
&& apt-get clean \
@@ -39,7 +39,7 @@ ENV PATH=/usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH
# Install system dependencies
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release libgnutls30 libgcrypt20 \
&& apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release libgnutls30t64 libgcrypt20 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
@@ -86,7 +86,7 @@ COPY --from=docker:29-dind /usr/local/bin/docker /usr/local/bin/
# nsjail runtime deps and binary
RUN apt-get update \
&& apt-get install -y --no-install-recommends libprotobuf32 libnl-route-3-200 libnl-3-200 \
&& apt-get install -y --no-install-recommends libprotobuf32t64 libnl-route-3-200 libnl-3-200 \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY --from=nsjail /nsjail/nsjail /bin/nsjail
+8 -8
View File
@@ -1,6 +1,6 @@
ARG DEBIAN_IMAGE=debian:bookworm-slim
ARG DEBIAN_IMAGE=debian:trixie-slim
FROM debian:bookworm-slim AS nsjail
FROM debian:trixie-slim AS nsjail
WORKDIR /nsjail
@@ -9,12 +9,12 @@ RUN apt-get -y update \
bison=2:3.8.* \
ca-certificates \
flex=2.6.* \
g++=4:12.2.* \
gcc=4:12.2.* \
git=1:2.39.* \
g++=4:14.2.* \
gcc=4:14.2.* \
git=1:2.47.* \
libprotobuf-dev=3.21.* \
libnl-route-3-dev=3.7.* \
make=4.3-4.1 \
make=4.4.* \
pkg-config=1.8.* \
protobuf-compiler=3.21.* \
&& apt-get clean \
@@ -39,7 +39,7 @@ ENV PATH=/usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH
# Install system dependencies
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release libgnutls30 libgcrypt20 \
&& apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release libgnutls30t64 libgcrypt20 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
@@ -86,7 +86,7 @@ COPY --from=docker:29-dind /usr/local/bin/docker /usr/local/bin/
# nsjail runtime deps and binary
RUN apt-get update \
&& apt-get install -y --no-install-recommends libprotobuf32 libnl-route-3-200 libnl-3-200 \
&& apt-get install -y --no-install-recommends libprotobuf32t64 libnl-route-3-200 libnl-3-200 \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY --from=nsjail /nsjail/nsjail /bin/nsjail
+57 -118
View File
@@ -1,12 +1,12 @@
{
"name": "@windmill-labs/components",
"version": "1.750.0",
"version": "1.751.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@windmill-labs/components",
"version": "1.750.0",
"version": "1.751.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@@ -94,7 +94,7 @@
"windmill-parser-wasm-wac": "1.668.6",
"windmill-parser-wasm-yaml": "1.593.0",
"windmill-sql-datatype-parser-wasm": "1.512.0",
"windmill-utils-internal": "^1.7.0",
"windmill-utils-internal": "1.8.2",
"xterm": "^5.3.0",
"xterm-readline": "^1.1.2",
"y-monaco": "^0.1.4",
@@ -291,7 +291,6 @@
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-validator-identifier": "^7.28.5",
"js-tokens": "^4.0.0",
@@ -307,7 +306,6 @@
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.9.0"
}
@@ -865,7 +863,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": "^14 || ^16 || >=18"
},
@@ -875,21 +872,21 @@
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
"integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -898,9 +895,9 @@
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -1392,6 +1389,7 @@
"integrity": "sha512-Jer+M7DgIwT5IHfTayb4Iw/fkkxWNmC/mqn/nMh9JrbPbkxmyabfLQnhJ+JDn5HK77f84j34lubO3iqFtYAfMg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@floating-ui/core": "^1.3.1",
"@floating-ui/dom": "^1.4.5",
@@ -1548,6 +1546,7 @@
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
@@ -1774,8 +1773,8 @@
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
"@emnapi/core": "1.11.2",
"@emnapi/runtime": "1.11.2",
"@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
@@ -1912,6 +1911,7 @@
"integrity": "sha512-iAIPEahFgDJJyvz8g0jP08KvqnM6JvdW8YfsygZ+pMeMvyM2zssWMltcsotETvjSZ82G3VlitgDtBIvpQSZrTA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.5",
@@ -2007,6 +2007,7 @@
"integrity": "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"deepmerge": "^4.3.1",
"magic-string": "^0.30.21",
@@ -2468,8 +2469,7 @@
"resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz",
"integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "2.1.0",
@@ -2482,8 +2482,7 @@
"resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz",
"integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/semver": {
"version": "7.7.1",
@@ -2552,6 +2551,7 @@
"integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "5.62.0",
"@typescript-eslint/types": "5.62.0",
@@ -3079,6 +3079,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3132,6 +3133,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -3277,7 +3279,6 @@
"integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -3305,7 +3306,6 @@
"integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -3386,8 +3386,7 @@
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-2.0.0.tgz",
"integrity": "sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
@@ -3514,6 +3513,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
@@ -3711,7 +3711,6 @@
"integrity": "sha512-Rjs1H+A9R+Ig+4E/9oyB66UC5Mj9Xq3N//vcLf2WzgdTi/3gUu3Z9KoqmlrEG4VuuLK8wJHofxzdQXz/knhiYg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"camelcase": "^6.3.0",
"map-obj": "^4.1.0",
@@ -3731,7 +3730,6 @@
"integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -3745,7 +3743,6 @@
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -3759,7 +3756,6 @@
"integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -3868,6 +3864,7 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
@@ -4144,7 +4141,6 @@
"integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"import-fresh": "^3.3.0",
"js-yaml": "^4.1.0",
@@ -4209,7 +4205,6 @@
"integrity": "sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -4397,6 +4392,7 @@
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.0.tgz",
"integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10"
}
@@ -4819,6 +4815,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -4918,6 +4915,7 @@
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.21.0"
},
@@ -4958,7 +4956,6 @@
"integrity": "sha512-VfxadyCECXgQlkoEAjeghAr5gY3Hf+IKjKb+X8tGVDtveCjN+USwprd2q3QXBR9T1+x2DG0XZF5/w+7HAtSaXA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -4972,7 +4969,6 @@
"integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"decamelize": "^1.1.0",
"map-obj": "^1.0.0"
@@ -4990,7 +4986,6 @@
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5001,7 +4996,6 @@
"integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5495,7 +5489,6 @@
"integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"is-arrayish": "^0.2.1"
}
@@ -5593,6 +5586,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -6149,7 +6143,6 @@
"integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 4.9.1"
}
@@ -6509,7 +6502,6 @@
"integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"global-prefix": "^3.0.0"
},
@@ -6523,7 +6515,6 @@
"integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ini": "^1.3.5",
"kind-of": "^6.0.2",
@@ -6539,7 +6530,6 @@
"integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"isexe": "^2.0.0"
},
@@ -6589,8 +6579,7 @@
"resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz",
"integrity": "sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/gopd": {
"version": "1.2.0",
@@ -6673,7 +6662,6 @@
"integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6"
}
@@ -6910,7 +6898,6 @@
"integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"lru-cache": "^6.0.0"
},
@@ -6924,7 +6911,6 @@
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"yallist": "^4.0.0"
},
@@ -6937,8 +6923,7 @@
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true,
"license": "ISC",
"peer": true
"license": "ISC"
},
"node_modules/html-tags": {
"version": "3.3.1",
@@ -6946,7 +6931,6 @@
"integrity": "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
},
@@ -7042,7 +7026,6 @@
"integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -7073,7 +7056,6 @@
"integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -7143,8 +7125,7 @@
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/is-binary-path": {
"version": "2.1.0",
@@ -7249,7 +7230,6 @@
"integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -7260,7 +7240,6 @@
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -7365,8 +7344,7 @@
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.0",
@@ -7402,8 +7380,7 @@
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/json-refs": {
"version": "3.0.15",
@@ -7600,7 +7577,6 @@
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -8206,8 +8182,7 @@
"resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz",
"integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lodash.uniq": {
"version": "4.5.0",
@@ -8275,7 +8250,6 @@
"integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
},
@@ -8326,7 +8300,6 @@
"integrity": "sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
@@ -8567,7 +8540,6 @@
"integrity": "sha512-/d+PQ4GKmGvM9Bee/DPa8z3mXs/pkvJE2KEThngVNOqtmljC6K7NMPxtc2JeZYTmpWb9k/TmxjeL18ez3h7vCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/minimist": "^1.2.2",
"camelcase-keys": "^7.0.0",
@@ -8595,7 +8567,6 @@
"integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -9330,7 +9301,6 @@
"integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"arrify": "^1.0.1",
"is-plain-obj": "^1.1.0",
@@ -9396,6 +9366,7 @@
"resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-editor-api/-/monaco-vscode-editor-api-25.0.0.tgz",
"integrity": "sha512-uiY06RTWFo2WZdh6OybkLlDhuG+8LlkjUDpr9/wW55uucqHo4X8fx4XKEtD98cscC+6FKQkbG2yyUiOJ/npHOw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codingame/monaco-vscode-api": "25.0.0"
}
@@ -9632,7 +9603,6 @@
"integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"hosted-git-info": "^4.0.1",
"is-core-module": "^2.5.0",
@@ -9984,7 +9954,6 @@
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.0.0",
"error-ex": "^1.3.1",
@@ -10306,6 +10275,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -10494,6 +10464,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"lilconfig": "^3.0.0",
"yaml": "^2.3.4"
@@ -10883,8 +10854,7 @@
"resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.6.tgz",
"integrity": "sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/postcss-safe-parser": {
"version": "6.0.0",
@@ -11060,6 +11030,7 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -11363,7 +11334,6 @@
"integrity": "sha512-X1Fu3dPuk/8ZLsMhEj5f4wFAF0DWoK7qhGJvgaijocXxBmSToKfbFtqbxMO7bVjNA1dmE5huAzjXj/ey86iw9Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/normalize-package-data": "^2.4.0",
"normalize-package-data": "^3.0.2",
@@ -11383,7 +11353,6 @@
"integrity": "sha512-snVCqPczksT0HS2EC+SxUndvSzn6LRCwpfSvLrIfR5BKDQQZMaI6jPRC9dYvYFDRAuFEAnkwww8kBBNE/3VvzQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"find-up": "^5.0.0",
"read-pkg": "^6.0.0",
@@ -11402,7 +11371,6 @@
"integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -11416,7 +11384,6 @@
"integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -11459,7 +11426,6 @@
"integrity": "sha512-tYkDkVVtYkSVhuQ4zBgfvciymHaeuel+zFKXShfDnFP5SyVEP7qo70Rf1jTOTCx3vGNAbnEi/xFkcfQVMIBWag==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"indent-string": "^5.0.0",
"strip-indent": "^4.0.0"
@@ -12100,7 +12066,6 @@
"integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-styles": "^4.0.0",
"astral-regex": "^2.0.0",
@@ -12177,7 +12142,6 @@
"integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"spdx-expression-parse": "^3.0.0",
"spdx-license-ids": "^3.0.0"
@@ -12188,8 +12152,7 @@
"resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz",
"integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==",
"dev": true,
"license": "CC-BY-3.0",
"peer": true
"license": "CC-BY-3.0"
},
"node_modules/spdx-expression-parse": {
"version": "3.0.1",
@@ -12197,7 +12160,6 @@
"integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"spdx-exceptions": "^2.1.0",
"spdx-license-ids": "^3.0.0"
@@ -12208,8 +12170,7 @@
"resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz",
"integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==",
"dev": true,
"license": "CC0-1.0",
"peer": true
"license": "CC0-1.0"
},
"node_modules/sprintf-js": {
"version": "1.0.3",
@@ -12303,7 +12264,6 @@
"integrity": "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -12329,8 +12289,7 @@
"resolved": "https://registry.npmjs.org/style-search/-/style-search-0.1.0.tgz",
"integrity": "sha512-Dj1Okke1C3uKKwQcetra4jSuk0DqbzbYtXipzFlFMZtowbF1x7BKJwB9AayVMyFARvU8EDrZdcax4At/452cAg==",
"dev": true,
"license": "ISC",
"peer": true
"license": "ISC"
},
"node_modules/style-to-object": {
"version": "0.4.4",
@@ -12379,7 +12338,6 @@
"integrity": "sha512-78O4c6IswZ9TzpcIiQJIN49K3qNoXTM8zEJzhaTE/xRTCZswaovSEVIa/uwbOltZrk16X4jAxjaOhzz/hTm1Kw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@csstools/css-parser-algorithms": "^2.3.1",
"@csstools/css-tokenizer": "^2.2.0",
@@ -12462,7 +12420,6 @@
}
],
"license": "MIT-0",
"peer": true,
"engines": {
"node": "^14 || ^16 || >=18"
},
@@ -12476,7 +12433,6 @@
"integrity": "sha512-TfW7/1iI4Cy7Y8L6iqNdZQVvdXn0f8B4QcIXmkIbtTIe/Okm/nSlHb4IwGzRVOd3WfSieCgvf5cMzEfySAIl0g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"flat-cache": "^3.2.0"
},
@@ -12489,8 +12445,7 @@
"resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.29.0.tgz",
"integrity": "sha512-Ne7wqW7/9Cz54PDt4I3tcV+hAyat8ypyOGzYRJQfdxnnjeWsTxt1cy8pjvvKeI5kfXuyvULyeeAvwvvtAX3ayQ==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/stylelint/node_modules/postcss-selector-parser": {
"version": "6.1.2",
@@ -12513,7 +12468,6 @@
"integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -12654,7 +12608,6 @@
"integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"has-flag": "^4.0.0",
"supports-color": "^7.0.0"
@@ -12684,6 +12637,7 @@
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.5.tgz",
"integrity": "sha512-YkqERnF05g8KLdDZwZrF8/i1eSbj6Eoat8Jjr2IfruZz9StLuBqo8sfCSzjosNKd+ZrQ8DkKZDjpO5y3ht1Pow==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -12781,21 +12735,6 @@
}
}
},
"node_modules/svelte-check/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/svelte-eslint-parser": {
"version": "0.43.0",
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz",
@@ -13025,8 +12964,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz",
"integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==",
"dev": true,
"peer": true
"dev": true
},
"node_modules/svgo": {
"version": "3.3.2",
@@ -13077,7 +13015,6 @@
"integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==",
"dev": true,
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"ajv": "^8.0.1",
"lodash.truncate": "^4.4.2",
@@ -13105,6 +13042,7 @@
"integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@@ -13357,6 +13295,7 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"devOptional": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -13419,7 +13358,6 @@
"integrity": "sha512-jRKj0n0jXWo6kh62nA5TEh3+4igKDXLvzBJcPpiizP7oOolUrYIxmVBG9TOtHYFHoddUk6YvAkGeGoSVTXfQXQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -13537,6 +13475,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -13773,7 +13712,6 @@
"integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"spdx-correct": "^3.0.0",
"spdx-expression-parse": "^3.0.0"
@@ -13827,6 +13765,7 @@
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
@@ -14396,9 +14335,9 @@
"integrity": "sha512-uHNL8F72/Tf96xF3hOHnPDjkEyqXw7fNjcPJiUhth9sTQkcwUIoJMOdwm8/cs+j9kKVRJ4tgNYMHEBLylazp6g=="
},
"node_modules/windmill-utils-internal": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/windmill-utils-internal/-/windmill-utils-internal-1.7.0.tgz",
"integrity": "sha512-K5kAiJKhavfGatmicbJyqT+KFspzFZK5Ou14pHj4Xg6Q2Z1a4ZgziBdrRNsaeAuBhB62yXWZA0OXDzg936Ymmw==",
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/windmill-utils-internal/-/windmill-utils-internal-1.8.2.tgz",
"integrity": "sha512-Otdn4iCE0QiOtMKPdHX5I89oECfZa3vmO2jdLdSETf7MBSv/gw9gvQm3oUQ29ymIHXLeuhQk/ebPD+WYolQJEA==",
"license": "Apache 2.0"
},
"node_modules/word-wrap": {
@@ -14534,7 +14473,6 @@
"integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"imurmurhash": "^0.1.4",
"signal-exit": "^4.0.1"
@@ -14730,7 +14668,6 @@
"integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==",
"dev": true,
"license": "ISC",
"peer": true,
"engines": {
"node": ">=10"
}
@@ -14750,6 +14687,7 @@
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz",
"integrity": "sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==",
"license": "MIT",
"peer": true,
"dependencies": {
"lib0": "^0.2.99"
},
@@ -14785,6 +14723,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill-labs/components",
"version": "1.750.0",
"version": "1.751.0",
"scripts": {
"dev": "vite dev",
"dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev",
@@ -169,7 +169,7 @@
"windmill-parser-wasm-wac": "1.668.6",
"windmill-parser-wasm-yaml": "1.593.0",
"windmill-sql-datatype-parser-wasm": "1.512.0",
"windmill-utils-internal": "^1.7.0",
"windmill-utils-internal": "1.8.2",
"xterm": "^5.3.0",
"xterm-readline": "^1.1.2",
"y-monaco": "^0.1.4",
+2 -2
View File
@@ -1,5 +1,5 @@
{
"baseUrl": "https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev",
"version": "062d11c",
"sha256": "355bf3acfb935080e4a4fa23d0509e03e4bc01aeed622db110663d7ec6c1c438"
"version": "f8cecf9",
"sha256": "2a79b838e21e06abe06119263872afbe83c4a13f7aa72c722af3e34b819f0ce3"
}
@@ -646,7 +646,7 @@
deploymentStatus[statusKey] = { status: 'deployed' }
} else {
deploymentStatus[statusKey] = { status: 'failed', error: result.error }
sendUserToast(`Failed to deploy ${statusKey}: ${result.error}`)
sendUserToast(`Failed to deploy ${statusKey}: ${result.error}`, 'error')
}
}
@@ -696,7 +696,10 @@
if (!aIsFolder && bIsFolder) return 1
return 0
})
const to = mergeIntoParent ? parent : current
let anyFailed = false
// Datatables whose migrations deployed cleanly — candidates for a run prompt.
const deployedMigrationDatatables = new Set<string>()
for (const itemKey of sortedItems) {
const deployable = deployableItems.find((d) => d.key === itemKey)
@@ -705,16 +708,19 @@
continue
}
const to = mergeIntoParent ? parent : current
const from = mergeIntoParent ? current : parent
await deploy(deployable.kind, deployable.path, to, from, itemKey)
if (deploymentStatus[itemKey]?.status === 'failed') {
anyFailed = true
} else if (deployable.kind === 'datatable_migration') {
deployedMigrationDatatables.add(deployable.path.split('/')[0])
}
}
deploying = false
deselectAll()
await maybePromptRunMigrations(deployedMigrationDatatables, to)
// If every selected item deployed cleanly and the direction was
// merge-into-parent, resolve any open deployment request for this fork.
if (!anyFailed && mergeIntoParent) {
@@ -750,6 +756,51 @@
onChanged?.()
}
/**
* After a deploy, offer to run the migrations of every cloned datatable that
* received one. `forked_from` is set on the fork's datatable config only when
* the datatable was cloned into a separate database — shared-DB datatables
* have already had the schema change applied and must not be re-run.
*/
async function maybePromptRunMigrations(
deployedMigrationDatatables: Set<string>,
runTargetWorkspace: string
) {
if (deployedMigrationDatatables.size === 0) return
try {
const forkSettings = await WorkspaceService.getPublicSettings({
workspace: currentWorkspaceId
})
const datatables = forkSettings.datatable?.datatables ?? {}
const cloned = [...deployedMigrationDatatables].filter(
(dt) => datatables[dt]?.forked_from != null
)
if (cloned.length === 0) return
runMigrationsDatatables = cloned.sort()
runMigrationsTargetWorkspace = runTargetWorkspace
runMigrationsModalOpen = true
} catch (e) {
console.error('Failed to determine cloned datatables for migration run prompt', e)
}
}
async function runDeployedMigrations() {
runMigrationsModalOpen = false
for (const dt of runMigrationsDatatables) {
try {
const res = await WorkspaceService.runDatatableMigrations({
workspace: runMigrationsTargetWorkspace,
datatableName: dt
})
sendUserToast(
`Ran ${res.applied.length} migration${res.applied.length !== 1 ? 's' : ''} on ${dt}`
)
} catch (e: any) {
sendUserToast(`Failed to run migrations on ${dt}: ${e.body ?? e.message ?? e}`, true)
}
}
}
function toggleKey(key: string) {
if (selectedItems.includes(key)) {
selectedItems = selectedItems.filter((i) => i !== key)
@@ -930,6 +981,17 @@
let deploymentRequestPanel: DeploymentRequestPanel | undefined = $state(undefined)
let hasOpenDeploymentRequest = $state(false)
// After deploying datatable migrations to a cloned (separate-DB) datatable, we
// offer to run them in the target workspace. Shared-DB datatables are skipped:
// the schema change is already physically applied, so re-running is redundant.
let runMigrationsModalOpen = $state(false)
let runMigrationsDatatables = $state<string[]>([])
let runMigrationsTargetWorkspace = $state('')
let runMigrationsTargetWorkspaceName = $derived(
$userWorkspaces.find((w) => w.id == runMigrationsTargetWorkspace)?.name ??
runMigrationsTargetWorkspace
)
/** Display labels for trigger/schedule kinds in the merge UI. */
const KIND_DISPLAY_NAMES: Record<string, string> = {
schedule: 'Schedule',
@@ -942,7 +1004,8 @@
sqs_trigger: 'SQS trigger',
gcp_trigger: 'GCP trigger',
azure_trigger: 'Azure trigger',
email_trigger: 'Email trigger'
email_trigger: 'Email trigger',
datatable_migration: 'Data table migration'
}
// Human label for a diff kind, lowercased for inline use in the hidden-items
@@ -1453,9 +1516,7 @@
/>
</div>
<div class="bg-surface-tertiary p-4 rounded-md border">
<DatatableSchemaDiff {currentWorkspaceId} {parentWorkspaceId} />
</div>
<DatatableSchemaDiff {currentWorkspaceId} {parentWorkspaceId} />
{#if pinnedItems.length > 0}
<div class="bg-surface-tertiary p-4 rounded-md border flex flex-col gap-2">
@@ -1536,6 +1597,26 @@
</div>
</ConfirmationModal>
<ConfirmationModal
open={runMigrationsModalOpen}
title="Run datatable migrations?"
confirmationText="Run migrations"
onConfirmed={runDeployedMigrations}
onCanceled={() => (runMigrationsModalOpen = false)}
>
<div class="flex flex-col gap-2">
<p>
Run the deployed migrations in <b>{runMigrationsTargetWorkspaceName}</b> now? These data tables
use a separate database, so the schema changes won't apply until the migrations are run.
</p>
<ul class="list-disc pl-5 text-sm font-mono text-secondary">
{#each runMigrationsDatatables as dt (dt)}
<li>{dt}</li>
{/each}
</ul>
</div>
</ConfirmationModal>
<ConfirmationModal
open={!!createConfirm}
title="Create in {createConfirm?.onCurrent ? parentWorkspaceId : currentWorkspaceId}?"
+6 -4
View File
@@ -370,7 +370,7 @@
refresh?.()
sendUserToast(`Schema '${schemaKey}' deleted successfully`)
} catch (e) {
let msg: string | undefined = (e as Error).message
let msg: string | undefined = (e as any).body ?? (e as Error).message
if (typeof msg !== 'string') msg = e ? JSON.stringify(e) : undefined
sendUserToast(msg ?? 'Action failed!', true)
}
@@ -436,7 +436,7 @@
refresh?.()
sendUserToast(`Table '${tableKey}' deleted successfully`)
} catch (e) {
let msg: string | undefined = (e as Error).message
let msg: string | undefined = (e as any).body ?? (e as Error).message
if (typeof msg !== 'string') msg = e ? JSON.stringify(e) : undefined
sendUserToast(msg ?? 'Action failed!', true)
}
@@ -502,7 +502,7 @@
refresh?.()
sendUserToast(`Table '${tableKey}' deleted successfully`)
} catch (e) {
let msg: string | undefined = (e as Error).message
let msg: string | undefined = (e as any).body ?? (e as Error).message
if (typeof msg !== 'string') msg = e ? JSON.stringify(e) : undefined
sendUserToast(msg ?? 'Action failed!', true)
}
@@ -605,7 +605,9 @@
onConfirm={async ({ values }) => {
if (dbTableEditorState.alterTableKey && dbTableEditorAlterTableData.current) {
let diff = diffTableEditorValues(dbTableEditorAlterTableData.current, values)
await dbSchemaOps.onAlter({ schema: selected.schemaKey, values: diff })
// Reverse diff (new → old) so the migration's down undoes the alter.
let reverse = diffTableEditorValues(values, dbTableEditorAlterTableData.current)
await dbSchemaOps.onAlter({ schema: selected.schemaKey, values: diff, reverse })
} else {
await dbSchemaOps.onCreate({ values, schema: selected.schemaKey })
}
@@ -20,6 +20,10 @@
import type { SelectedTable } from './DBManager.svelte'
import { getDbFeatures } from './apps/components/display/dbtable/dbFeatures'
import { resource } from 'runed'
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
import { createAsyncConfirmationModal } from './common/confirmationModal/asyncConfirmationModal.svelte'
import Portal from '$lib/components/Portal.svelte'
import { outOfOrderRunMessage } from './workspaceSettings/datatableMigrationUtils'
interface Props {
input?: DbInput
@@ -52,6 +56,8 @@
let dbSchema: DBSchema | undefined = $derived(input && $dbSchemas[getDbSchemasPath(input)])
const outOfOrderModal = createAsyncConfirmationModal()
function getDbSchemasPath(input: DbInput): string {
switch (input.type) {
case 'database':
@@ -163,7 +169,13 @@
})}
dbSchemaOps={dbSchemaOpsWithPreviewScripts({
input: _input,
workspace: $workspaceStore
workspace: $workspaceStore,
confirmRunOutOfOrder: (pending) =>
outOfOrderModal.ask({
title: 'Run migration out of order',
confirmationText: 'Run anyway',
children: outOfOrderRunMessage(pending)
})
})}
initialTableKey={input.specificTable}
initialSchemaKey={input.specificSchema}
@@ -192,6 +204,7 @@
onData={(data) => {
replResultData = data
}}
onSchemaChange={() => refresh()}
placeholderTableName={sortArray(
Object.keys(
dbSchema?.schema[
@@ -214,3 +227,9 @@
</Pane>
</Splitpanes>
{/if}
<Portal>
<!-- Stacks above the DB table editor's own preview confirmation (z-[9999]),
which is still open when applyDdl asks for out-of-order confirmation. -->
<ConfirmationModal {...outOfOrderModal.props} zIndexClass="z-[10000]" />
</Portal>
@@ -16,6 +16,7 @@
Upload
} from 'lucide-svelte'
import DBManagerContent from './DBManagerContent.svelte'
import DataTableMigrationsButton from './workspaceSettings/DataTableMigrationsButton.svelte'
import { resource } from 'runed'
import { untrack } from 'svelte'
import type { DbManagerUriState } from './dbManagerDrawerModel.svelte'
@@ -105,6 +106,11 @@
return toSourceIdentifier(input.resourcePath)
}
function refreshManager() {
dbManagerContent?.refresh()
dbManagerContent?.dbManager()?.dbTable()?.refresh()
}
async function handleExportSchema() {
const source = currentSourceIdentifier()
if (!source || !$workspaceStore) return
@@ -201,6 +207,13 @@
{/key}
{/if}
{#snippet actions()}
{#if uriState.isDatatableInput && uriState.selectedDatatable && $workspaceStore}
<DataTableMigrationsButton
workspace={$workspaceStore}
datatable={uriState.selectedDatatable}
onSchemaChanged={refreshManager}
/>
{/if}
{#if enableImportExport}
<Button startIcon={{ icon: Download }} onClick={handleExportSchema}>Export</Button>
<Button startIcon={{ icon: Upload }} onClick={() => (importDrawerOpen = true)}>
@@ -209,16 +222,13 @@
{/if}
<Button
loading={dbManagerContent?.isLoading() ?? false}
on:click={() => {
dbManagerContent?.refresh()
dbManagerContent?.dbManager()?.dbTable()?.refresh()
}}
on:click={refreshManager}
startIcon={{ icon: RefreshCcw }}
iconOnly
title="Refresh"
size="xs"
color="light"
>
Refresh
</Button>
/>
<Button
on:click={() => (expand = !expand)}
@@ -234,7 +244,9 @@
<DrawerContent title="Export Schemas" on:close={() => (exportDrawerOpen = false)}>
{#if exportResult}
<div class="flex flex-col gap-2 h-full relative">
<pre class="overflow-auto text-xs bg-surface-secondary p-4 rounded flex-1">{exportResult}</pre>
<pre class="overflow-auto text-xs bg-surface-secondary p-4 rounded flex-1"
>{exportResult}</pre
>
<Button
size="xs"
color="light"
@@ -52,6 +52,7 @@
import Popover from './meltComponents/Popover.svelte'
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
import { sendUserToast } from '$lib/toast'
import { MigrationRunCancelled } from './dbOps'
import { getFlatTableNamesFromSchema, type DBSchema } from '$lib/stores'
import { twMerge } from 'tailwind-merge'
import DarkModeObserver from './DarkModeObserver.svelte'
@@ -460,9 +461,13 @@
askingForConfirmation && (askingForConfirmation.loading = true)
await onConfirm({ values })
} catch (e) {
let msg: string | undefined = (e as Error)?.message
if (typeof msg !== 'string') msg = e ? JSON.stringify(e) : 'An error occurred'
sendUserToast(msg, true)
// User declined the out-of-order run warning: silent cancel,
// leave the editor open so they can adjust or run earlier first.
if (!(e instanceof MigrationRunCancelled)) {
let msg: string | undefined = (e as any)?.body ?? (e as Error)?.message
if (typeof msg !== 'string') msg = e ? JSON.stringify(e) : 'An error occurred'
sendUserToast(msg, true)
}
}
askingForConfirmation = undefined
},
@@ -472,7 +477,7 @@
...(preview && { codeContent: preview.sql, alert: preview.alert })
}
} catch (e) {
let msg: string | undefined = (e as Error)?.message
let msg: string | undefined = (e as any)?.body ?? (e as Error)?.message
if (typeof msg !== 'string') msg = e ? JSON.stringify(e) : 'An error occurred'
sendUserToast(msg, true)
} finally {
@@ -196,10 +196,17 @@
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
import { sendUserToast } from '$lib/toast'
import { userWorkspaces } from '$lib/stores'
import { runScriptAndPollResult } from '$lib/components/jobs/utils'
import YAML from 'yaml'
import DrawerContent from './common/drawer/DrawerContent.svelte'
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
import { createAsyncConfirmationModal } from './common/confirmationModal/asyncConfirmationModal.svelte'
import Portal from '$lib/components/Portal.svelte'
import {
pendingMigrations,
outOfOrderRunMessage
} from './workspaceSettings/datatableMigrationUtils'
import Alert from './common/alert/Alert.svelte'
import ResizeTransitionWrapper from './common/ResizeTransitionWrapper.svelte'
@@ -213,6 +220,11 @@
let loading = $state(true)
let error: string | undefined = $state(undefined)
let diffs: DatatableDiff[] = $state([])
// Number of forked datatables this schema-diff section applies to: those that
// have NOT opted in to the migrations feature. When a datatable enables
// migrations, its changes flow through the normal item diff instead, so it is
// excluded here and the whole section hides once none remain.
let applicableCount = $state(0)
let expandedDatatables: Set<string> = $state(new Set())
// Drawer state
@@ -223,6 +235,7 @@
let migrationSql = $state('')
let migrationRunning = $state(false)
let confirmDeployOpen = $state(false)
const outOfOrderModal = createAsyncConfirmationModal()
async function loadDiffs() {
loading = true
@@ -233,7 +246,10 @@
workspace: currentWorkspaceId
})
const datatables = forkSettings.datatable?.datatables ?? {}
const forkedEntries = Object.entries(datatables).filter(([_, dt]) => dt.forked_from != null)
const forkedEntries = Object.entries(datatables).filter(
([_, dt]) => dt.forked_from != null && dt.migrations_enabled !== true
)
applicableCount = forkedEntries.length
if (forkedEntries.length === 0) {
loading = false
return
@@ -343,14 +359,68 @@
const dtName = drawerDiff.datatableName
try {
await runScriptAndPollResult({
// If the target data table opted in to migrations, record this merge as a
// tracked migration (named after the fork) and run it, instead of applying
// raw SQL that would bypass the target's migration history.
// Don't swallow a status-check failure by defaulting to raw apply: that
// would apply the DDL untracked (schema drift) — exactly what this feature
// prevents. Let the error propagate (fail closed, handled by the outer
// catch); only fall back to raw apply when the API explicitly returns
// enabled === false.
const status = await WorkspaceService.getDatatableMigrationsStatus({
workspace: targetWorkspace,
requestBody: {
args: { database: `datatable://${dtName}` },
language: 'postgresql',
content: migrationSql
}
datatableName: dtName
})
if (status.enabled) {
// The merge migration gets the highest timestamp, so any still-pending
// migration on the target is earlier: running only the merge applies it
// out of order. Warn like the row-level Run action does.
const pending = pendingMigrations(status.migrations)
if (pending.length > 0) {
const confirmed = await outOfOrderModal.ask({
title: 'Run migration out of order',
confirmationText: 'Run anyway',
children: outOfOrderRunMessage(pending.length)
})
if (!confirmed) {
migrationRunning = false
return
}
}
const forkName =
$userWorkspaces.find((w) => w.id === currentWorkspaceId)?.name || currentWorkspaceId
const migName = `merge_${forkName}`.replace(/[^a-zA-Z0-9_-]+/g, '_')
const created = await WorkspaceService.createDatatableMigration({
workspace: targetWorkspace,
datatableName: dtName,
requestBody: { name: migName, code_up: migrationSql }
})
try {
await WorkspaceService.runDatatableMigrations({
workspace: targetWorkspace,
datatableName: dtName,
only: created.timestamp
})
} catch (runErr: any) {
// Undo the insertion so the user can fix and retry from a clean state.
await WorkspaceService.deleteDatatableMigration({
workspace: targetWorkspace,
datatableName: dtName,
timestamp: created.timestamp
}).catch(() => {})
throw runErr
}
} else {
await runScriptAndPollResult({
workspace: targetWorkspace,
requestBody: {
args: { database: `datatable://${dtName}` },
language: 'postgresql',
content: migrationSql
}
})
}
} catch (e: any) {
sendUserToast(e?.body ?? e?.message ?? String(e), true)
migrationRunning = false
@@ -394,102 +464,107 @@
}
</script>
<h3 class="text-sm font-semibold">Datatable schema changes</h3>
{#if loading}
<div class="flex items-center gap-2 text-xs text-tertiary py-2">
<Loader2 class="w-4 h-4 animate-spin" /> Loading datatable diffs...
</div>
{:else if error}
<div class="text-xs text-red-500 py-2">Failed to load datatable diffs: {error}</div>
{:else if diffs.length > 0}
<div class="flex flex-col gap-2 mt-3 mb-1">
{#each diffs as diff}
<ResizeTransitionWrapper class="border rounded-md" innerClass="w-full" vertical>
<button
class="w-full flex items-center justify-between px-3 py-2 hover:bg-surface-hover"
onclick={() => toggleExpanded(diff.datatableName)}
>
<span class="text-xs font-medium">{diff.datatableName}</span>
<div class="flex items-center gap-2 text-2xs text-tertiary">
{#if diff.aheadChanges.length > 0}
<span class="text-blue-500">{diff.aheadChanges.length} ahead</span>
{/if}
{#if diff.behindChanges.length > 0}
<span class="text-orange-500">{diff.behindChanges.length} behind</span>
{/if}
{#if expandedDatatables.has(diff.datatableName)}
<ChevronDown class="w-3 h-3" />
{:else}
<ChevronRight class="w-3 h-3" />
{/if}
</div>
</button>
{#if applicableCount > 0}
<div class="bg-surface-tertiary p-4 rounded-md border">
<h3 class="text-sm font-semibold">Datatable schema changes</h3>
{#if loading}
<div class="flex items-center gap-2 text-xs text-tertiary py-2">
<Loader2 class="w-4 h-4 animate-spin" /> Loading datatable diffs...
</div>
{:else if error}
<div class="text-xs text-red-500 py-2">Failed to load datatable diffs: {error}</div>
{:else if diffs.length > 0}
<div class="flex flex-col gap-2 mt-3 mb-1">
{#each diffs as diff}
<ResizeTransitionWrapper class="border rounded-md" innerClass="w-full" vertical>
<button
class="w-full flex items-center justify-between px-3 py-2 hover:bg-surface-hover"
onclick={() => toggleExpanded(diff.datatableName)}
>
<span class="text-xs font-medium">{diff.datatableName}</span>
<div class="flex items-center gap-2 text-2xs text-tertiary">
{#if diff.aheadChanges.length > 0}
<span class="text-blue-500">{diff.aheadChanges.length} ahead</span>
{/if}
{#if diff.behindChanges.length > 0}
<span class="text-orange-500">{diff.behindChanges.length} behind</span>
{/if}
{#if expandedDatatables.has(diff.datatableName)}
<ChevronDown class="w-3 h-3" />
{:else}
<ChevronRight class="w-3 h-3" />
{/if}
</div>
</button>
{#if expandedDatatables.has(diff.datatableName)}
<div class="border-t divide-y">
{#if diff.aheadChanges.length > 0}
<div class="px-3 py-1.5">
<div class="text-2xs font-semibold text-blue-500 mb-1">Fork changes (ahead)</div>
{#each diff.aheadChanges as change}
<div class="flex items-center gap-2 text-xs py-0.5">
{#if change.kind === 'added'}
<Plus class="w-3 h-3 text-green-500 shrink-0" />
{:else if change.kind === 'removed'}
<Minus class="w-3 h-3 text-red-500 shrink-0" />
{:else}
<Pencil class="w-3 h-3 text-yellow-500 shrink-0" />
{/if}
<span class="text-tertiary">{change.schemaName}.</span>
<span class="font-medium">{change.tableName}</span>
<span class="text-tertiary text-2xs grow">{operationSummary(change)}</span>
<Button
size="xs"
variant="subtle"
startIcon={{ icon: Eye }}
onclick={() => openReview(change, diff, 'ahead')}
{#if expandedDatatables.has(diff.datatableName)}
<div class="border-t divide-y">
{#if diff.aheadChanges.length > 0}
<div class="px-3 py-1.5">
<div class="text-2xs font-semibold text-blue-500 mb-1">Fork changes (ahead)</div
>
Review
</Button>
{#each diff.aheadChanges as change}
<div class="flex items-center gap-2 text-xs py-0.5">
{#if change.kind === 'added'}
<Plus class="w-3 h-3 text-green-500 shrink-0" />
{:else if change.kind === 'removed'}
<Minus class="w-3 h-3 text-red-500 shrink-0" />
{:else}
<Pencil class="w-3 h-3 text-yellow-500 shrink-0" />
{/if}
<span class="text-tertiary">{change.schemaName}.</span>
<span class="font-medium">{change.tableName}</span>
<span class="text-tertiary text-2xs grow">{operationSummary(change)}</span>
<Button
size="xs"
variant="subtle"
startIcon={{ icon: Eye }}
onclick={() => openReview(change, diff, 'ahead')}
>
Review
</Button>
</div>
{/each}
</div>
{/each}
{/if}
{#if diff.behindChanges.length > 0}
<div class="px-3 py-1.5">
<div class="text-2xs font-semibold text-orange-500 mb-1">
Parent changes (behind)
</div>
{#each diff.behindChanges as change}
<div class="flex items-center gap-2 text-xs py-0.5">
{#if change.kind === 'added'}
<Plus class="w-3 h-3 text-green-500 shrink-0" />
{:else if change.kind === 'removed'}
<Minus class="w-3 h-3 text-red-500 shrink-0" />
{:else}
<Pencil class="w-3 h-3 text-yellow-500 shrink-0" />
{/if}
<span class="text-tertiary">{change.schemaName}.</span>
<span class="font-medium">{change.tableName}</span>
<span class="text-tertiary text-2xs grow">{operationSummary(change)}</span>
<Button
size="xs"
variant="subtle"
startIcon={{ icon: Eye }}
onclick={() => openReview(change, diff, 'behind')}
>
Review
</Button>
</div>
{/each}
</div>
{/if}
</div>
{/if}
{#if diff.behindChanges.length > 0}
<div class="px-3 py-1.5">
<div class="text-2xs font-semibold text-orange-500 mb-1">
Parent changes (behind)
</div>
{#each diff.behindChanges as change}
<div class="flex items-center gap-2 text-xs py-0.5">
{#if change.kind === 'added'}
<Plus class="w-3 h-3 text-green-500 shrink-0" />
{:else if change.kind === 'removed'}
<Minus class="w-3 h-3 text-red-500 shrink-0" />
{:else}
<Pencil class="w-3 h-3 text-yellow-500 shrink-0" />
{/if}
<span class="text-tertiary">{change.schemaName}.</span>
<span class="font-medium">{change.tableName}</span>
<span class="text-tertiary text-2xs grow">{operationSummary(change)}</span>
<Button
size="xs"
variant="subtle"
startIcon={{ icon: Eye }}
onclick={() => openReview(change, diff, 'behind')}
>
Review
</Button>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</ResizeTransitionWrapper>
{/each}
</ResizeTransitionWrapper>
{/each}
</div>
{:else}
<span class="text-xs text-secondary"> No changes detected </span>
{/if}
</div>
{:else}
<span class="text-xs text-secondary"> No changes detected </span>
{/if}
<Drawer bind:open={drawerOpen} size="900px">
@@ -580,3 +655,7 @@
>{migrationSql}</pre
>
</ConfirmationModal>
<Portal>
<ConfirmationModal {...outOfOrderModal.props} />
</Portal>
@@ -0,0 +1,159 @@
<script lang="ts">
import { Button } from './common'
import Modal2 from './common/modal/Modal2.svelte'
import NewDataTableMigrationModal from './workspaceSettings/NewDataTableMigrationModal.svelte'
import DataTableMigrationsButton from './workspaceSettings/DataTableMigrationsButton.svelte'
import { splitSqlStatements, isDdlStatement } from './sqlDdl'
import { CornerDownLeft } from 'lucide-svelte'
let { workspace, datatable }: { workspace: string; datatable: string } = $props()
type Choice = 'run' | 'migrate' | 'cancel'
let promptStatement = $state<string | undefined>(undefined)
let promptOpen = $state(false)
let resolvePrompt: ((choice: Choice) => void) | undefined = undefined
let resolveMigrationClosed: ((created: boolean) => void) | undefined = undefined
// Set when a migration is created *and run* during a guard() call, so the
// caller can refresh the schema afterwards.
let migrationRan = false
let newMigrationModal = $state<NewDataTableMigrationModal | undefined>(undefined)
// Triggerless Migrations modal, opened programmatically from the "See migration"
// toast action after a migration is created here.
let migrationsModal = $state<DataTableMigrationsButton | undefined>(undefined)
function finishPrompt(choice: Choice) {
const r = resolvePrompt
resolvePrompt = undefined
promptOpen = false
promptStatement = undefined
r?.(choice)
}
// Closing the modal (X / escape) while a prompt is pending counts as cancel.
$effect(() => {
if (!promptOpen && resolvePrompt) {
finishPrompt('cancel')
}
})
// Enter confirms the primary action ("Create a migration"). Ignore modified
// Enter so editor shortcuts (Cmd/Ctrl+Enter) keep working underneath.
function onKeyDown(event: KeyboardEvent) {
if (!promptOpen || event.metaKey || event.ctrlKey || event.altKey) return
if (event.key === 'Enter') {
event.stopPropagation()
event.preventDefault()
finishPrompt('migrate')
}
}
function promptDdl(statement: string): Promise<Choice> {
return new Promise((resolve) => {
resolvePrompt = resolve
promptStatement = statement
promptOpen = true
})
}
function handleMigrationClosed(result: { created: boolean; ran: boolean }) {
if (result.ran) migrationRan = true
const r = resolveMigrationClosed
resolveMigrationClosed = undefined
r?.(result.created)
}
// Open the prefilled new-migration modal. Resolves with whether a migration
// was actually created (false if the user cancelled / closed it).
function openMigrationModal(statement: string): Promise<boolean> {
return new Promise((resolve) => {
resolveMigrationClosed = (created: boolean) => resolve(created)
newMigrationModal?.open({ codeUp: statement })
})
}
/**
* Inspect `code` for DDL statements. For each one, prompt the user to run it
* anyway or turn it into a migration (prompts shown one at a time). Returns
* whether to proceed and the code to run (with migrated statements stripped).
*/
export async function guard(
code: string
): Promise<{ proceed: boolean; code: string; ranMigration: boolean }> {
migrationRan = false
const statements = splitSqlStatements(code)
if (!statements.some((s) => isDdlStatement(s))) {
return { proceed: true, code, ranMigration: false }
}
const kept: string[] = []
for (const statement of statements) {
if (!isDdlStatement(statement)) {
kept.push(statement)
continue
}
// Re-prompt for this statement until the user makes a terminal choice;
// cancelling the migration modal returns to the prompt with the DDL intact.
for (;;) {
const choice = await promptDdl(statement)
if (choice === 'cancel') {
return { proceed: false, code, ranMigration: migrationRan }
}
if (choice === 'run') {
kept.push(statement)
break
}
// migrate: only strip the statement once a migration is actually
// created; if the modal was cancelled, loop back to the prompt.
const created = await openMigrationModal(statement)
if (created) {
break
}
}
}
return { proceed: true, code: kept.join(';\n'), ranMigration: migrationRan }
}
</script>
<svelte:window onkeydown={onKeyDown} />
<Modal2
title="Schema change detected"
fixedWidth="md"
fixedHeight="adaptive"
bind:isOpen={promptOpen}
closeOnOutsideClick={false}
>
<div class="flex flex-col gap-3 w-full">
<p class="text-sm text-secondary">
This looks like a schema-changing (DDL) statement. Schema changes are best tracked as
migrations rather than run ad-hoc. Create a migration for it instead?
</p>
<pre
class="text-xs whitespace-pre-wrap font-mono bg-surface-secondary rounded p-3 max-h-48 overflow-auto"
>{promptStatement ?? ''}</pre
>
<div class="flex justify-end gap-2 pt-2">
<Button variant="default" size="sm" on:click={() => finishPrompt('run')}>Run anyway</Button>
<Button
variant="accent"
size="sm"
shortCut={{ Icon: CornerDownLeft, withoutModifier: true }}
on:click={() => finishPrompt('migrate')}
>
Create a migration
</Button>
</div>
</div>
</Modal2>
<NewDataTableMigrationModal
bind:this={newMigrationModal}
{workspace}
{datatable}
onClose={handleMigrationClosed}
onSeeMigration={(m) => migrationsModal?.openMigration(m.timestamp)}
/>
<DataTableMigrationsButton bind:this={migrationsModal} hideTrigger {workspace} {datatable} />
@@ -1,7 +1,7 @@
<script lang="ts">
import { workspaceStore, userWorkspaces, usersWorkspaceStore } from '$lib/stores'
import { WorkspaceService } from '$lib/gen'
import { Button } from '$lib/components/common'
import { Badge, Button } from '$lib/components/common'
import Select from '$lib/components/select/Select.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { sendUserToast } from '$lib/toast'
@@ -9,12 +9,14 @@
import { goto } from '$app/navigation'
import { base } from '$lib/base'
import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy'
import { devBadgeText, devLabelKey, devLabelNoun } from '$lib/utils/devWorkspaceLabel'
import { loadProtectionRules } from '$lib/workspaceProtectionRules.svelte'
import { GitFork, ExternalLink } from 'lucide-svelte'
import { resource } from 'runed'
let currentWs = $derived($userWorkspaces.find((w) => w.id === $workspaceStore))
let isDev = $derived(currentWs?.is_dev_workspace ?? false)
let currentLabel = $derived(devLabelKey(currentWs?.dev_workspace_label))
let parentId = $derived(currentWs?.parent_workspace_id ?? undefined)
let canonicalDev = $derived(findCanonicalDevWorkspace($workspaceStore, $userWorkspaces))
@@ -29,12 +31,18 @@
// else the server result (pairing + detach still available to a prod admin).
let pairedDev = $derived(
canonicalDev
? { id: canonicalDev.id, name: canonicalDev.name, isMember: true }
? {
id: canonicalDev.id,
name: canonicalDev.name,
isMember: true,
label: canonicalDev.dev_workspace_label
}
: devWorkspaceResource.current
? {
id: devWorkspaceResource.current.id,
name: devWorkspaceResource.current.name,
isMember: false
isMember: false,
label: devWorkspaceResource.current.dev_workspace_label
}
: undefined
)
@@ -42,7 +50,10 @@
let selectedDevId = $state<string | undefined>(undefined)
let lockProdDeploy = $state(true)
let lockProdForking = $state(true)
// Cosmetic display label chosen when attaching an existing workspace as dev.
let attachLabel = $state<'dev' | 'staging'>('dev')
let busy = $state(false)
let labelBusy = $state(false)
// A standalone root workspace, or an existing fork of this prod (same family), can be attached.
// A fork parented to a different workspace can't (the backend rejects a parent that isn't this
@@ -82,10 +93,11 @@
requestBody: {
dev_workspace_id: selectedDevId,
lock_prod_deploy: lockProdDeploy,
lock_prod_forking: lockProdForking
lock_prod_forking: lockProdForking,
dev_workspace_label: attachLabel
}
})
sendUserToast(`Attached ${selectedDevId} as dev workspace`)
sendUserToast(`Attached ${selectedDevId} as ${attachLabel} workspace`)
selectedDevId = undefined
await refresh()
} catch (e: any) {
@@ -95,6 +107,22 @@
}
}
async function setLabel(label: 'dev' | 'staging') {
if (!$workspaceStore || label === devLabelKey(currentWs?.dev_workspace_label)) return
labelBusy = true
try {
await WorkspaceService.setDevWorkspaceLabel({
workspace: $workspaceStore,
requestBody: { dev_workspace_label: label }
})
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
} catch (e: any) {
sendUserToast(`Failed to update display label: ${e?.body ?? e}`, true)
} finally {
labelBusy = false
}
}
async function detach(devId: string) {
if (!$workspaceStore) return
busy = true
@@ -116,9 +144,20 @@
{#if isDev && parentId}
<div class="flex flex-col gap-3 max-w-2xl">
<p class="text-sm">
This is a <b>dev workspace</b> paired with root workspace <b>{parentId}</b>. Promote changes
from the home page banner or the Compare &amp; Deploy page.
This is a <b>{devLabelNoun(currentWs?.dev_workspace_label)}</b> paired with root workspace
<b>{parentId}</b>. Promote changes from the home page banner or the Compare &amp; Deploy page.
</p>
<div class="text-2xs text-secondary">
Cosmetic label: <Badge color="indigo" small>{devBadgeText(currentLabel)}</Badge>
<button
type="button"
disabled={labelBusy}
class="text-secondary hover:text-primary hover:underline disabled:opacity-50"
onclick={() => setLabel(currentLabel === 'staging' ? 'dev' : 'staging')}
>
Change to {currentLabel === 'staging' ? 'dev' : 'staging'}
</button>
</div>
<div>
<Button
variant="default"
@@ -132,8 +171,8 @@
{:else if pairedDev}
<div class="flex flex-col gap-3 max-w-2xl">
<p class="text-sm">
This workspace's dev workspace is <b>{pairedDev.name}</b> ({pairedDev.id}). Edits to this
workspace are redirected there.
This workspace's {devLabelNoun(pairedDev.label)} is <b>{pairedDev.name}</b> ({pairedDev.id}).
Edits to this workspace are redirected there.
</p>
<div class="flex gap-2">
{#if pairedDev.isMember}
@@ -142,7 +181,7 @@
startIcon={{ icon: GitFork }}
onclick={() => switchWorkspace(pairedDev.id)}
>
Go to dev workspace
Go to {devLabelNoun(pairedDev.label)}
</Button>
{/if}
<Button color="red" disabled={busy} onclick={() => detach(pairedDev.id)}>Detach</Button>
@@ -168,6 +207,16 @@
clearable
/>
</div>
<div class="text-2xs text-secondary">
Cosmetic label: <Badge color="indigo" small>{devBadgeText(attachLabel)}</Badge>
<button
type="button"
class="text-secondary hover:text-primary hover:underline"
onclick={() => (attachLabel = attachLabel === 'staging' ? 'dev' : 'staging')}
>
Change to {attachLabel === 'staging' ? 'dev' : 'staging'}
</button>
</div>
<Toggle
bind:checked={lockProdDeploy}
options={{
+39 -1
View File
@@ -43,6 +43,7 @@
import { editorFontSize } from '$lib/editorFontSize.svelte'
import { createHash as randomHash } from '$lib/editorLangUtils'
import { workspaceStore } from '$lib/stores'
import DdlMigrationGuard from './DdlMigrationGuard.svelte'
import {
type Preview,
ResourceService,
@@ -222,6 +223,35 @@
let lang = $state(scriptLangToEditorLang(untrack(() => scriptLang)))
// On a postgres script targeting a datatable, DDL statements are intercepted
// on run (cmd+enter) and offered as migrations instead.
let datatableForMigrations = $derived(
scriptLang === 'postgresql' &&
typeof args?.database === 'string' &&
args.database.startsWith('datatable://')
? args.database.slice('datatable://'.length).split('/')[0]
: undefined
)
let ddlGuard = $state<DdlMigrationGuard | undefined>(undefined)
// Run the DDL migration guard against the current code. Returns false when the
// user cancels (the run must be aborted); may rewrite the code (migrated
// statements stripped). Exported so run paths that bypass the Monaco
// Cmd+Enter binding (e.g. the Test button) can guard too.
export async function guardDdlBeforeRun(): Promise<boolean> {
if (datatableForMigrations && ddlGuard) {
const res = await ddlGuard.guard(getCode())
if (!res.proceed) return false
if (res.code !== getCode()) setCode(res.code)
}
return true
}
async function runCmdEnterWithDdlGuard() {
if (!(await guardDdlBeforeRun())) return
cmdEnterAction?.()
}
let filePath = $state(computePath(untrack(() => path)))
let initialPath: string | undefined = $state(untrack(() => path))
@@ -1639,7 +1669,8 @@
editor?.addCommand(KeyMod.CtrlCmd | KeyCode.Enter, function () {
updateCode()
shouldBindKey && cmdEnterAction && cmdEnterAction()
if (!shouldBindKey || !cmdEnterAction) return
void runCmdEnterWithDdlGuard()
})
editor?.addCommand(KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Digit7, function () {
@@ -2209,6 +2240,13 @@
<svelte:window onkeydown={onKeyDown} />
<EditorTheme />
{#if datatableForMigrations && $workspaceStore}
<DdlMigrationGuard
bind:this={ddlGuard}
workspace={$workspaceStore}
datatable={datatableForMigrations}
/>
{/if}
{#if !editor}
<div class="inset-0 absolute overflow-clip">
<FakeMonacoPlaceHolder {code} lineNumbersWidth={51} />
@@ -7,6 +7,7 @@
import { goto } from '$app/navigation'
import { onMount, untrack } from 'svelte'
import { useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
import { devLabelWord } from '$lib/utils/devWorkspaceLabel'
let loading = $state(false)
let comparison: WorkspaceComparison | undefined = $state(undefined)
@@ -170,7 +171,9 @@
<GitFork class="w-4 h-4 text-accent" />
<div class="text-sm">
<span class="font-medium text-blue-900 dark:text-blue-100">
{isDevWorkspace ? 'Dev workspace of' : 'Fork of'}
{isDevWorkspace
? `${devLabelWord(currentWorkspaceData?.dev_workspace_label)} workspace of`
: 'Fork of'}
<b>{parentWorkspaceData?.name}</b> ({parentWorkspaceId})
</span>
</div>
@@ -6,6 +6,7 @@
isRuleActive
} from '$lib/workspaceProtectionRules.svelte'
import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy'
import { devLabelNoun } from '$lib/utils/devWorkspaceLabel'
import { canCreateFork } from '$lib/utils/editInFork'
import { switchWorkspace } from '$lib/storeUtils'
import { Alert, Button } from './common'
@@ -40,12 +41,14 @@
<div class="my-2">
<Alert
type="info"
title={canonicalDev ? 'Edits happen in the dev workspace' : 'Workspace protection active'}
title={canonicalDev
? `Edits happen in the ${devLabelNoun(canonicalDev.dev_workspace_label)}`
: 'Workspace protection active'}
>
<div class="flex flex-col gap-2">
{#if canonicalDev}
<p>
Edits to this workspace are made in its dev workspace
Edits to this workspace are made in its {devLabelNoun(canonicalDev.dev_workspace_label)}
<b>{canonicalDev.name}</b> ({canonicalDev.id}) and promoted here.
</p>
<div>
@@ -58,7 +61,7 @@
if (canonicalDev) switchWorkspace(canonicalDev.id)
}}
>
Go to dev workspace
Go to {devLabelNoun(canonicalDev.dev_workspace_label)}
</Button>
</div>
{:else}
@@ -793,7 +793,17 @@
args = nargs
}
export async function runTest(opts?: { cascade?: boolean }) {
export async function runTest(opts?: { cascade?: boolean; skipDdlGuard?: boolean }) {
// Intercept DDL statements (offer to turn them into data table migrations)
// on every run path, not just the editor's Cmd+Enter. `skipDdlGuard` is set
// by the Cmd+Enter action, which already guarded before calling us.
if (!opts?.skipDdlGuard) {
if ((await editor?.guardDdlBeforeRun()) === false) return
// The guard may have rewritten the code (migrated statements stripped);
// `editorCode` is kept in sync by the editor binding, so mirror the
// on:change handler and pull it into `code` before we run.
if (activeModuleTab === null) code = editorCode
}
// When the caller forces a cascade choice (e.g. the canvas runnable
// menu's "Run + trigger N downstream"), also flip the persistent
// `cascadeDownstream` state so the split button's label/icon reflect
@@ -2677,7 +2687,8 @@
} else {
await inferModuleSchema()
}
runTest()
// The Editor already ran the DDL guard before invoking this action.
runTest({ skipDdlGuard: true })
}}
formatAction={async () => {
if (activeModuleTab === null) {
@@ -95,6 +95,7 @@
loadAsync = false,
key,
disabled = false,
readOnly = false,
minHeight = 1000,
renderLineHighlight = 'none',
suggestion
@@ -123,6 +124,9 @@
initialCursorPos?: IPosition
key?: string
disabled?: boolean
/** Read-only Monaco mode: not editable, but still scrollable/selectable
* (unlike `disabled`, which makes the editor non-interactive). */
readOnly?: boolean
minHeight?: number
renderLineHighlight?: 'all' | 'line' | 'gutter' | 'none'
suggestion?: string
@@ -239,6 +243,9 @@
lineNumbers: $relativeLineNumbers ? 'relative' : 'on'
})
})
$effect(() => {
editor?.updateOptions({ readOnly })
})
function onVimDisable() {
vimDisposable?.dispose()
@@ -342,6 +349,7 @@
),
model,
...(yPadding !== undefined ? { padding: { bottom: yPadding, top: yPadding } } : {}),
readOnly,
renderLineHighlight,
lineDecorationsWidth: 0,
lineNumbersMinChars: 2,
+35 -48
View File
@@ -1,49 +1,3 @@
<script module lang="ts">
// code may be composed of many sql statements separated by ';'
// this function splits them while taking into account that ';' may not
// be the end of a statement (string or escaped)
function splitSqlStatements(code: string) {
const statements: string[] = []
let currentStatement = ''
let inSingleQuote = false
let inDoubleQuote = false
let inBacktick = false
for (let i = 0; i < code.length; i++) {
const char = code[i]
const prevChar = i > 0 ? code[i - 1] : null
if (char === "'" && !inDoubleQuote && !inBacktick && prevChar !== '\\') {
inSingleQuote = !inSingleQuote
} else if (char === '"' && !inSingleQuote && !inBacktick && prevChar !== '\\') {
inDoubleQuote = !inDoubleQuote
} else if (char === '`' && !inSingleQuote && !inDoubleQuote && prevChar !== '\\') {
inBacktick = !inBacktick
}
if (char === ';' && !inSingleQuote && !inDoubleQuote && !inBacktick) {
statements.push(currentStatement.trim())
currentStatement = ''
} else {
currentStatement += char
}
}
if (currentStatement.trim()) {
statements.push(currentStatement.trim())
}
return statements
}
function pruneComments(code: string) {
return code
.replace(/--.*?(\r?\n|$)/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.trim()
}
</script>
<script lang="ts">
import { CornerDownLeft, Loader2 } from 'lucide-svelte'
import Button from './common/button/Button.svelte'
@@ -57,15 +11,29 @@
import { getDatabaseArg, getDbType } from './dbOps'
import type { DbInput } from './dbTypes'
import { wrapDucklakeQuery } from './ducklake'
import { splitSqlStatements, pruneComments } from './sqlDdl'
import DdlMigrationGuard from './DdlMigrationGuard.svelte'
type Props = {
input: DbInput
onData: (data: Record<string, any>[]) => void
placeholderTableName?: string
/** Called after a migration is run via the DDL guard, so the schema view
* can be refreshed to reflect the applied change. */
onSchemaChange?: () => void
}
let { input, onData, placeholderTableName }: Props = $props()
let { input, onData, placeholderTableName, onSchemaChange }: Props = $props()
let dbType = $derived(getDbType(input))
// A datatable REPL targets `datatable://<name>`; surface DDL statements as
// migration prompts instead of running them ad-hoc.
let datatableName = $derived(
input.type === 'database' && input.resourcePath.startsWith('datatable://')
? input.resourcePath.slice('datatable://'.length).split('/')[0]
: undefined
)
let ddlGuard = $state<DdlMigrationGuard | undefined>(undefined)
const DEFAULT_SQL = 'SELECT * FROM _'
let code = $state(DEFAULT_SQL)
$effect(() => {
@@ -81,6 +49,18 @@
async function run({ doPostgresRowToJsonFix }: { doPostgresRowToJsonFix?: boolean } = {}) {
if (isRunning || !$workspaceStore) return
const READ_OPS = ['SELECT', 'WITH', 'SHOW', 'EXPLAIN', 'DESCRIBE']
// On a datatable, intercept DDL statements and offer to make them
// migrations; the user may strip some, leaving the rest to run.
if (datatableName && ddlGuard && !doPostgresRowToJsonFix) {
const res = await ddlGuard.guard(code)
// A migration that was created and run changes the schema; refresh it.
if (res.ranMigration) onSchemaChange?.()
if (!res.proceed) return
if (res.code !== code) code = res.code
if (pruneComments(code).trim() === '') return
}
isRunning = true
try {
const statements = splitSqlStatements(pruneComments(code))
@@ -155,7 +135,10 @@
isRunning = false
return await run({ doPostgresRowToJsonFix: true })
}
sendUserToast('Error running query: ' + (e.message ?? e.error.message), true)
sendUserToast(
'Error running query: ' + (e?.body ?? e?.message ?? e?.error?.message ?? e),
true
)
} finally {
isRunning = false
}
@@ -197,3 +180,7 @@
/>
</Pane>
</Splitpanes>
{#if datatableName && $workspaceStore}
<DdlMigrationGuard bind:this={ddlGuard} workspace={$workspaceStore} datatable={datatableName} />
{/if}

Some files were not shown because too many files have changed in this diff Show More