Compare commits
19 Commits
feat/backe
...
v1.625.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8799090942 | ||
|
|
d113546169 | ||
|
|
460b9bf82c | ||
|
|
a3d75ba10a | ||
|
|
149da9b763 | ||
|
|
c9ee675de8 | ||
|
|
145ee68210 | ||
|
|
63f9d85bf6 | ||
|
|
db56518e4f | ||
|
|
8220a0bd8d | ||
|
|
e6ae87ecc1 | ||
|
|
c760a33ccd | ||
|
|
0caa533fbd | ||
|
|
5e62690d52 | ||
|
|
3c8daa9a58 | ||
|
|
02f43d575b | ||
|
|
027573ad2d | ||
|
|
92cbeb7c6e | ||
|
|
4098679fd7 |
81
.github/workflows/docker-image.yml
vendored
81
.github/workflows/docker-image.yml
vendored
@@ -24,11 +24,6 @@ on:
|
||||
description: "Tag the image"
|
||||
required: true
|
||||
default: "test"
|
||||
nsjail:
|
||||
description: "Build nsjail image (true, false)"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
slim:
|
||||
description: "Build slim image (true, false)"
|
||||
required: false
|
||||
@@ -106,7 +101,7 @@ jobs:
|
||||
|
||||
build_ee:
|
||||
runs-on: ubicloud
|
||||
if: (github.event_name != 'workflow_dispatch') || (github.event.inputs.ee || github.event.inputs.nsjail)
|
||||
if: (github.event_name != 'workflow_dispatch') || github.event.inputs.ee
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -370,67 +365,10 @@ jobs:
|
||||
# ignore-unchanged: true
|
||||
# only-fixed: true
|
||||
|
||||
build_ee_nsjail:
|
||||
needs: [build_ee]
|
||||
runs-on: ubicloud
|
||||
if: (github.event_name != 'pull_request') && ((github.event_name != 'workflow_dispatch') || (github.event.inputs.ee || github.event.inputs.nsjail))
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
# - name: Set up Docker Buildx
|
||||
# uses: docker/setup-buildx-action@v2
|
||||
|
||||
- uses: depot/setup-action@v1
|
||||
|
||||
- name: Docker meta
|
||||
id: meta-ee-public
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee-nsjail
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,enable=true,priority=100,prefix=,suffix=,format=short
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
|
||||
- name: Login to registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Update Dockerfile image reference
|
||||
run: |
|
||||
sed -i 's|FROM ghcr.io/windmill-labs/windmill-ee:dev|FROM ghcr.io/${{ env.IMAGE_NAME }}-ee:${{ env.DEV_SHA }}|' ./docker/DockerfileNsjail
|
||||
cat ./docker/DockerfileNsjail | grep "FROM"
|
||||
|
||||
- name: Build and push publicly ee
|
||||
uses: depot/build-push-action@v1
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
file: "./docker/DockerfileNsjail"
|
||||
tags: |
|
||||
${{ steps.meta-ee-public.outputs.tags }}
|
||||
labels: |
|
||||
${{ steps.meta-ee-public.outputs.labels }}
|
||||
org.opencontainers.image.licenses=Windmill-Enterprise-License
|
||||
|
||||
publish_ecr_s3:
|
||||
needs: [build_ee_nsjail]
|
||||
needs: [build_ee_full]
|
||||
runs-on: ubicloud-standard-2-arm
|
||||
if: (github.event_name != 'pull_request') && (github.event_name !=
|
||||
'workflow_dispatch')
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
@@ -449,23 +387,18 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: get git hash
|
||||
if: github.event_name != 'pull_request'
|
||||
id: git_hash
|
||||
run: |
|
||||
git_hash=$(git rev-parse --short "$GITHUB_SHA")
|
||||
echo "GIT_HASH=${git_hash:0:7}" >> "$GITHUB_OUTPUT"
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: shrink/actions-docker-extract@v3
|
||||
if: github.event_name != 'pull_request'
|
||||
id: extract
|
||||
with:
|
||||
image: |-
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee-nsjail:${{ steps.git_hash.outputs.GIT_HASH }}
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee-full:${{ steps.version.outputs.VERSION }}
|
||||
path: "/static_frontend/."
|
||||
|
||||
- uses: reggionick/s3-deploy@v4
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
folder: ${{ steps.extract.outputs.destination }}
|
||||
bucket: windmill-frontend
|
||||
|
||||
23
CHANGELOG.md
23
CHANGELOG.md
@@ -1,5 +1,28 @@
|
||||
# Changelog
|
||||
|
||||
## [1.625.0](https://github.com/windmill-labs/windmill/compare/v1.624.0...v1.625.0) (2026-02-04)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add filters to Kafka triggers ([#7750](https://github.com/windmill-labs/windmill/issues/7750)) ([3c8daa9](https://github.com/windmill-labs/windmill/commit/3c8daa9a58b5e4a2e8c85a9805a5b194ed75d055))
|
||||
* Assets page exploration UI ([#7784](https://github.com/windmill-labs/windmill/issues/7784)) ([0508425](https://github.com/windmill-labs/windmill/commit/05084254a34da81d227813a5190e3ce3dc0f816e))
|
||||
* cache lockfile results for scripts with same raw_workspace_dependencies ([#7787](https://github.com/windmill-labs/windmill/issues/7787)) ([4098679](https://github.com/windmill-labs/windmill/commit/4098679fd7eca059dfa128a6f8b8e1698a65b632))
|
||||
* column-level asset tracking for ducklake and datatables ([#7774](https://github.com/windmill-labs/windmill/issues/7774)) ([0caa533](https://github.com/windmill-labs/windmill/commit/0caa533fbd70fffec27d86d62e16bb92cf7a612a))
|
||||
* favorite datatable and ducklake tables + asset page nits ([#7795](https://github.com/windmill-labs/windmill/issues/7795)) ([a3d75ba](https://github.com/windmill-labs/windmill/commit/a3d75ba10ae85e5ecb55351555879be7fe0bfcca))
|
||||
* make nsjail available in all standard images (CE) ([#7793](https://github.com/windmill-labs/windmill/issues/7793)) ([149da9b](https://github.com/windmill-labs/windmill/commit/149da9b763e4f5dd93d2905be89b5df81bb61934))
|
||||
* public app rate limiting + fork hub raw apps + raw apps publish to hub button ([#7789](https://github.com/windmill-labs/windmill/issues/7789)) ([63f9d85](https://github.com/windmill-labs/windmill/commit/63f9d85bf6a5dd25977995978a8b0a4d32fee995))
|
||||
* replace LISTEN/NOTIFY with polling-based event system ([#7778](https://github.com/windmill-labs/windmill/issues/7778)) ([e860847](https://github.com/windmill-labs/windmill/commit/e860847073b56be469ba37af5e3a8cb7d30ef7bc))
|
||||
* upgrade bun to v1.3.8 with regression tests ([#7761](https://github.com/windmill-labs/windmill/issues/7761)) ([ef89a51](https://github.com/windmill-labs/windmill/commit/ef89a51f3a1cc1ae562d97b413c78393c0ea92cf))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* fix forking raw apps and summary setting in deploy drawer ([#7792](https://github.com/windmill-labs/windmill/issues/7792)) ([db56518](https://github.com/windmill-labs/windmill/commit/db56518e4fc53931e3498db06bbefd511c343d23))
|
||||
* handle Date serialization in quickjs flow eval via toJSON ([f151fdc](https://github.com/windmill-labs/windmill/commit/f151fdcf7f91a7b0ac75a133d5193538f4a9b4d8))
|
||||
* make private registries settings password in the instance settings ([727bd21](https://github.com/windmill-labs/windmill/commit/727bd2164059e4d44f2e2f6f70a567e7fac3a921))
|
||||
* persist ws_error_handler_muted for flows in create/update ([#7797](https://github.com/windmill-labs/windmill/issues/7797)) ([d113546](https://github.com/windmill-labs/windmill/commit/d113546169a790997d4842b7cfeb43ec2c90c6ea))
|
||||
|
||||
## [1.624.0](https://github.com/windmill-labs/windmill/compare/v1.623.1...v1.624.0) (2026-02-03)
|
||||
|
||||
|
||||
|
||||
25
Dockerfile
25
Dockerfile
@@ -1,6 +1,26 @@
|
||||
ARG DEBIAN_IMAGE=debian:bookworm-slim
|
||||
ARG RUST_IMAGE=rust:1.90-slim-bookworm
|
||||
|
||||
FROM debian:bookworm-slim AS nsjail
|
||||
|
||||
WORKDIR /nsjail
|
||||
|
||||
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.* \
|
||||
libprotobuf-dev=3.21.* \
|
||||
libnl-route-3-dev=3.7.* \
|
||||
make=4.3-4.1 \
|
||||
pkg-config=1.8.* \
|
||||
protobuf-compiler=3.21.*
|
||||
|
||||
RUN git clone -b master --single-branch https://github.com/google/nsjail.git . && git checkout dccf911fd2659e7b08ce9507c25b2b38ec2c5800
|
||||
RUN make
|
||||
|
||||
FROM ${RUST_IMAGE} AS rust_base
|
||||
|
||||
RUN apt-get update && apt-get install -y git libssl-dev pkg-config npm
|
||||
@@ -246,6 +266,11 @@ ENV RUSTUP_HOME="/usr/local/rustup"
|
||||
ENV CARGO_HOME="/usr/local/cargo"
|
||||
ENV LD_LIBRARY_PATH="."
|
||||
|
||||
# nsjail runtime deps and binary
|
||||
RUN apt-get update && apt-get install -y libprotobuf-dev libnl-route-3-dev \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=nsjail /nsjail/nsjail /bin/nsjail
|
||||
|
||||
WORKDIR ${APP}
|
||||
|
||||
RUN ln -s ${APP}/windmill /usr/local/bin/windmill
|
||||
|
||||
@@ -152,6 +152,11 @@
|
||||
"ordinal": 29,
|
||||
"name": "success_handler",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 30,
|
||||
"name": "public_app_execution_limit_per_minute",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -189,6 +194,7 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow"
|
||||
"flow",
|
||||
"job"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
"app",
|
||||
"script",
|
||||
"flow",
|
||||
"raw_app"
|
||||
"raw_app",
|
||||
"asset"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO asset (workspace_id, path, kind, usage_access_type, usage_path, usage_kind, asset_detection_kind, job_id)\n VALUES ($1, $2, $3, $4, $5, $6, 'static', NULL) ON CONFLICT DO NOTHING",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "asset_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"s3object",
|
||||
"resource",
|
||||
"variable",
|
||||
"ducklake",
|
||||
"datatable"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Custom": {
|
||||
"name": "asset_access_type",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"r",
|
||||
"w",
|
||||
"rw"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Varchar",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "asset_usage_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "31eada57708ac3347d1e0a4fb5fd412a4a0c6046dbe4cf91849def2eea2e4c62"
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE kafka_trigger \n SET \n kafka_resource_path = $1,\n group_id = $2,\n topics = $3,\n script_path = $4,\n path = $5,\n is_flow = $6,\n edited_by = $7,\n email = $8,\n edited_at = now(),\n server_id = NULL,\n error = NULL,\n error_handler_path = $11,\n error_handler_args = $12,\n retry = $13\n WHERE \n workspace_id = $9 AND path = $10\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"VarcharArray",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Jsonb",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3b6bd7b41f130ce6df62fdecb351a3e01be0726d02d3f863e0ea5c476a8e785e"
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO asset (workspace_id, path, kind, usage_access_type, usage_path, usage_kind, asset_detection_kind, job_id)\n VALUES ($1, $2, $3, NULL, $4, $5, 'runtime', $6) ON CONFLICT DO NOTHING",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "asset_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"s3object",
|
||||
"resource",
|
||||
"variable",
|
||||
"ducklake",
|
||||
"datatable"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Varchar",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "asset_usage_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3c6bed058591ad5438428b8c154ba6ee737e59aa033d833843b54e9f58eef3f2"
|
||||
}
|
||||
15
backend/.sqlx/query-3fe6f5d77332cce5ad249b8d6e1ea34aa57650c6effc3a9a2f4f720ea934669b.json
generated
Normal file
15
backend/.sqlx/query-3fe6f5d77332cce5ad249b8d6e1ea34aa57650c6effc3a9a2f4f720ea934669b.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings SET public_app_execution_limit_per_minute = $1 WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int4",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3fe6f5d77332cce5ad249b8d6e1ea34aa57650c6effc3a9a2f4f720ea934669b"
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM asset WHERE workspace_id = $1 AND usage_kind = 'script' AND usage_path = (SELECT path FROM script WHERE hash = $2 AND workspace_id = $1) AND asset_detection_kind = 'static'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "46395d77f3393a058e25ff4efb0b61c743c58418ba2ae1104bcfc99fae7925e4"
|
||||
}
|
||||
23
backend/.sqlx/query-4f666058177fed05c25036852f772d3cc5e2a5f947f307597b5a4a50f571c89b.json
generated
Normal file
23
backend/.sqlx/query-4f666058177fed05c25036852f772d3cc5e2a5f947f307597b5a4a50f571c89b.json
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n jsonb_strip_nulls(jsonb_build_object(\n 'path', favorite.path\n )) as \"favorite_asset!: _\"\n FROM favorite\n WHERE favorite.workspace_id = $1\n AND favorite.usr = $2\n AND favorite_kind = 'asset'\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "favorite_asset!: _",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "4f666058177fed05c25036852f772d3cc5e2a5f947f307597b5a4a50f571c89b"
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT fv.id\n FROM flow f\n INNER JOIN flow_version fv ON fv.id = f.versions[array_upper(f.versions, 1)]\n WHERE fv.value->'preprocessor_module'->'value'->>'path' = $1 AND f.workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "551fee7919fdeb911e3f9cc5852e158ea47e3db4895c2b2b1d3cb6b16fceeda9"
|
||||
}
|
||||
@@ -16,7 +16,8 @@
|
||||
"app",
|
||||
"script",
|
||||
"flow",
|
||||
"raw_app"
|
||||
"raw_app",
|
||||
"asset"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO flow (\n workspace_id, path, summary, description,\n dependency_job, lock_error_logs, draft_only, tag,\n dedicated_worker, visible_to_runner_only, on_behalf_of_email,\n value, schema, edited_by, edited_at\n ) VALUES (\n $1, $2, $3, $4,\n NULL, '', $5, $6,\n $7, $8, $9,\n $10, $11::text::json, $12, now()\n )",
|
||||
"query": "INSERT INTO flow (\n workspace_id, path, summary, description,\n dependency_job, lock_error_logs, draft_only, tag,\n dedicated_worker, visible_to_runner_only, on_behalf_of_email,\n ws_error_handler_muted,\n value, schema, edited_by, edited_at\n ) VALUES (\n $1, $2, $3, $4,\n NULL, '', $5, $6,\n $7, $8, $9,\n $10,\n $11, $12::text::json, $13, now()\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -14,6 +14,7 @@
|
||||
"Bool",
|
||||
"Bool",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Jsonb",
|
||||
"Text",
|
||||
"Varchar"
|
||||
@@ -21,5 +22,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "081dc94a7d0fdaade77cfb593a025d8c48d7eab3dbb30ca0b43fb1ef45d8d8bd"
|
||||
"hash": "6bde827da007b470b9d0acccfc3e00ce6aac650b9138a236f34c614eed753849"
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM asset WHERE workspace_id = $1 AND usage_kind = 'script' AND usage_path = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6fdab4c131f3126d5020b780ba45927dc778ac6fefdc2f91982f600b7cb9954f"
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM asset WHERE workspace_id = $1 AND usage_kind = 'script' AND usage_path = $2 AND asset_detection_kind = 'static'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "752fc50d3374ee5ccdb49f10c11e50bbffeec1088364a64f0849fb9b25e11ef4"
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n jsonb_build_object(\n 'path', path,\n 'kind', kind,\n 'access_type', usage_access_type\n ) as \"list!: _\"\n FROM asset\n WHERE workspace_id = $1 AND usage_path = $2 AND usage_kind = $3\n ORDER BY path, kind",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "list!: _",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "asset_usage_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "76033e76f15cee2aa0394d4ec2ff62130e7e48cb40d3b1534b0d791760b33ec7"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE\n flow\n SET\n path = $1,\n summary = $2,\n description = $3,\n dependency_job = NULL,\n lock_error_logs = '',\n draft_only = NULL,\n tag = $4,\n dedicated_worker = $5,\n visible_to_runner_only = $6,\n on_behalf_of_email = $7,\n value = $8,\n schema = $9::text::json,\n edited_by = $10,\n edited_at = now()\n WHERE\n path = $11 AND workspace_id = $12",
|
||||
"query": "\n UPDATE\n flow\n SET\n path = $1,\n summary = $2,\n description = $3,\n dependency_job = NULL,\n lock_error_logs = '',\n draft_only = NULL,\n tag = $4,\n dedicated_worker = $5,\n visible_to_runner_only = $6,\n on_behalf_of_email = $7,\n ws_error_handler_muted = $8,\n value = $9,\n schema = $10::text::json,\n edited_by = $11,\n edited_at = now()\n WHERE\n path = $12 AND workspace_id = $13",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -12,6 +12,7 @@
|
||||
"Bool",
|
||||
"Bool",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Jsonb",
|
||||
"Text",
|
||||
"Varchar",
|
||||
@@ -21,5 +22,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "207a0721b6f0b8b6ddd4120343eba524a2bc1e9047bdde5f568af4d993dbb74c"
|
||||
"hash": "77ac7257be02fb04c4b3213e2221e6f60621b4b2909d770de744ef5671e12ed9"
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n jsonb_build_object(\n 'path', path,\n 'kind', kind,\n 'access_type', usage_access_type\n ) as \"list!: _\"\n FROM asset\n WHERE workspace_id = $1 AND usage_path = $2 AND usage_kind = $3\n AND asset_detection_kind = 'static'\n ORDER BY path, kind",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "list!: _",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "asset_usage_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "7d73a51d5a83b11c1c4088678dd22ef369e9e26fafb75b351abfdef2b8a7a3e4"
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM asset WHERE workspace_id = $1 AND usage_path = $2 AND usage_kind = $3 AND asset_detection_kind = 'static'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "asset_usage_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "8ff8b53147d857db7829889ccbb59d2ca0492704a0b4a3cc8d1f4f733e0c794b"
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM asset WHERE workspace_id = $1 AND usage_kind = 'flow' AND usage_path = $2 AND asset_detection_kind = 'static'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "97101fae34227628efbcd72457a83a683745a6e2fb120329deba5117cc523bf2"
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n DELETE FROM asset\n WHERE id IN (\n SELECT id FROM (\n SELECT a.id, ROW_NUMBER() OVER (\n PARTITION BY a.workspace_id, a.path, a.kind\n ORDER BY a.created_at DESC\n ) as rn,\n limits.max_n\n FROM asset a\n INNER JOIN (\n SELECT * FROM UNNEST(\n $1::varchar[], \n $2::varchar[], \n $3::asset_kind[],\n $4::int[]\n ) AS t(workspace_id, path, kind, max_n)\n ) limits\n ON a.workspace_id = limits.workspace_id \n AND a.path = limits.path \n AND a.kind = limits.kind\n WHERE a.asset_detection_kind = 'runtime'\n ) ranked\n WHERE rn > max_n\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"VarcharArray",
|
||||
"VarcharArray",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "asset_kind[]",
|
||||
"kind": {
|
||||
"Array": {
|
||||
"Custom": {
|
||||
"name": "asset_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"s3object",
|
||||
"resource",
|
||||
"variable",
|
||||
"ducklake",
|
||||
"datatable"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Int4Array"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a14d43c1baebcdf7e8cebcb7c752f78a2212fe55d4247c2321b37d182d2a4386"
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n jsonb_strip_nulls(jsonb_build_object(\n 'path', asset.path,\n 'kind', asset.kind,\n 'usages', ARRAY_AGG(DISTINCT jsonb_build_object(\n 'path', asset.usage_path,\n 'kind', asset.usage_kind,\n 'access_type', asset.usage_access_type,\n 'detection_kinds', (\n SELECT ARRAY_AGG(DISTINCT a2.asset_detection_kind)\n FROM asset a2\n WHERE a2.workspace_id = asset.workspace_id\n AND a2.path = asset.path\n AND a2.kind = asset.kind\n AND a2.usage_path = asset.usage_path\n AND a2.usage_kind = asset.usage_kind\n )\n )),\n 'metadata', (CASE\n WHEN asset.kind = 'resource' THEN\n jsonb_build_object('resource_type', resource.resource_type)\n ELSE\n NULL\n END\n )\n )) as \"list!: _\"\n FROM asset\n LEFT JOIN resource ON asset.kind = 'resource'\n AND array_to_string((string_to_array(asset.path, '/'))[1:3], '/') = resource.path -- With specific table, asset path can be e.g u/diego/pg_db/table_name\n AND resource.workspace_id = $1\n WHERE asset.workspace_id = $1\n AND (asset.kind <> 'resource' OR resource.path IS NOT NULL)\n AND (asset.usage_kind <> 'flow' OR asset.usage_path = ANY(SELECT path FROM flow WHERE workspace_id = $1))\n AND (asset.usage_kind <> 'script' OR asset.usage_path = ANY(SELECT path FROM script WHERE workspace_id = $1))\n GROUP BY asset.path, asset.kind, resource.resource_type\n ORDER BY asset.path, asset.kind",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "list!: _",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "a21337dac33ef7a1aff1c6797a3ca47fed231f542d5bd8725d6c86b8dd1a2535"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n deploy_to,\n ai_config,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
|
||||
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n deploy_to,\n ai_config,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n public_app_execution_limit_per_minute\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -152,6 +152,11 @@
|
||||
"ordinal": 29,
|
||||
"name": "success_handler",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 30,
|
||||
"name": "public_app_execution_limit_per_minute",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -189,8 +194,9 @@
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "289919809e16aee33c81951b05a4795de710421bcd3e4c06588e56092677bd05"
|
||||
"hash": "a479cd371fb5d1f52e7c727730cf48ab229e63b8dfe377975d48dcd223251e7c"
|
||||
}
|
||||
35
backend/.sqlx/query-a750630d79166f5b6d5be0e049c36135212666ce74dbf290d701b6402b800f13.json
generated
Normal file
35
backend/.sqlx/query-a750630d79166f5b6d5be0e049c36135212666ce74dbf290d701b6402b800f13.json
generated
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n jsonb_strip_nulls(jsonb_build_object(\n 'path', path,\n 'kind', kind,\n 'access_type', usage_access_type,\n 'columns', columns\n )) as \"list!: _\"\n FROM asset\n WHERE workspace_id = $1 AND usage_path = $2 AND usage_kind = $3\n ORDER BY path, kind",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "list!: _",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "asset_usage_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"job"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "a750630d79166f5b6d5be0e049c36135212666ce74dbf290d701b6402b800f13"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO asset (workspace_id, path, kind, usage_access_type, usage_path, usage_kind)\n VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING",
|
||||
"query": "INSERT INTO asset (workspace_id, path, kind, usage_access_type, usage_path, usage_kind, columns)\n VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT DO NOTHING",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -40,14 +40,16 @@
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow"
|
||||
"flow",
|
||||
"job"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "74a2871aba7e35527dcefb2538b8cf41c35618f7c15ea047c5082f8feb7a8464"
|
||||
"hash": "a9e29764b5b9d94269e2b8aa755c71b61774c8ff8ae218d7a8d6ed0ac0169366"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO kafka_trigger (\n workspace_id,\n path,\n kafka_resource_path,\n group_id,\n topics,\n script_path,\n is_flow,\n mode,\n edited_by,\n email,\n edited_at,\n error_handler_path,\n error_handler_args,\n retry\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, now(), $11, $12, $13\n )\n ",
|
||||
"query": "\n INSERT INTO kafka_trigger (\n workspace_id,\n path,\n kafka_resource_path,\n group_id,\n topics,\n filters,\n script_path,\n is_flow,\n mode,\n edited_by,\n email,\n edited_at,\n error_handler_path,\n error_handler_args,\n retry\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, now(), $12, $13, $14\n )\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -10,6 +10,7 @@
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"VarcharArray",
|
||||
"JsonbArray",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
{
|
||||
@@ -33,5 +34,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1de3e078c108a8a0136fccdf9187cc3500260bac39c7f1dfa05a93628569465b"
|
||||
"hash": "aed5439aa6dad950e505f9f8f6914fa5ca21319c501b2822c9bc751ddfc9a0a4"
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n jsonb_strip_nulls(jsonb_build_object(\n 'path', asset.path,\n 'kind', asset.kind,\n 'usages', ARRAY_AGG(jsonb_build_object(\n 'path', asset.usage_path,\n 'kind', asset.usage_kind,\n 'access_type', asset.usage_access_type\n )),\n 'metadata', (CASE\n WHEN asset.kind = 'resource' THEN\n jsonb_build_object('resource_type', resource.resource_type)\n ELSE\n NULL\n END\n )\n )) as \"list!: _\"\n FROM asset\n LEFT JOIN resource ON asset.kind = 'resource'\n AND array_to_string((string_to_array(asset.path, '/'))[1:3], '/') = resource.path -- With specific table, asset path can be e.g u/diego/pg_db/table_name\n AND resource.workspace_id = $1\n WHERE asset.workspace_id = $1\n AND (asset.kind <> 'resource' OR resource.path IS NOT NULL)\n AND (asset.usage_kind <> 'flow' OR asset.usage_path = ANY(SELECT path FROM flow WHERE workspace_id = $1))\n AND (asset.usage_kind <> 'script' OR asset.usage_path = ANY(SELECT path FROM script WHERE workspace_id = $1))\n GROUP BY asset.path, asset.kind, resource.resource_type\n ORDER BY asset.path, asset.kind",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "list!: _",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "c94cd50ff1233025b170efee6489e6637676a8b0435a4612a7efbfff6ea2543d"
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COUNT(DISTINCT asset.job_id)::bigint as \"count!\"\n FROM asset\n WHERE asset.workspace_id = $1\n AND asset.path = $2\n AND asset.kind = $3\n AND asset.asset_detection_kind = 'runtime'\n AND asset.job_id IS NOT NULL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "asset_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"s3object",
|
||||
"resource",
|
||||
"variable",
|
||||
"ducklake",
|
||||
"datatable"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "df290155c071e823a1d60573a3cba932d070ed730a37450cd30ba9b90205ed3b"
|
||||
}
|
||||
27
backend/.sqlx/query-e2921e44c70cf6c76c55177f2b56985e84c59ecb3e1a13fcf27d5f7ae5f8d84c.json
generated
Normal file
27
backend/.sqlx/query-e2921e44c70cf6c76c55177f2b56985e84c59ecb3e1a13fcf27d5f7ae5f8d84c.json
generated
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE kafka_trigger\n SET\n kafka_resource_path = $1,\n group_id = $2,\n topics = $3,\n filters = $4,\n script_path = $5,\n path = $6,\n is_flow = $7,\n edited_by = $8,\n email = $9,\n edited_at = now(),\n server_id = NULL,\n error = NULL,\n error_handler_path = $12,\n error_handler_args = $13,\n retry = $14\n WHERE\n workspace_id = $10 AND path = $11\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"VarcharArray",
|
||||
"JsonbArray",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Jsonb",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e2921e44c70cf6c76c55177f2b56985e84c59ecb3e1a13fcf27d5f7ae5f8d84c"
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT DISTINCT\n v2_job.id,\n v2_job.created_at,\n v2_job.created_by,\n v2_job.runnable_path,\n CASE\n WHEN v2_job_completed.id IS NOT NULL THEN v2_job_completed.status::text\n ELSE NULL\n END as status\n FROM asset\n INNER JOIN v2_job ON asset.job_id = v2_job.id\n LEFT JOIN v2_job_completed ON v2_job.id = v2_job_completed.id\n WHERE asset.workspace_id = $1\n AND asset.path = $2\n AND asset.kind = $3\n AND asset.asset_detection_kind = 'runtime'\n AND asset.job_id IS NOT NULL\n ORDER BY v2_job.created_at DESC\n LIMIT $4 OFFSET $5",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "runnable_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "status",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "asset_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"s3object",
|
||||
"resource",
|
||||
"variable",
|
||||
"ducklake",
|
||||
"datatable"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "fb6656bc8fbd2e1b2c59f9ecbab55e5cdccdd7c99af0b61946694c6e03e7f636"
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM asset WHERE workspace_id = $1 AND usage_kind = 'flow' AND usage_path = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ff64f77d46fe8e1a07f26634df040ec5ced23cbb4390cd851c3bd7013dfd5758"
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
# Backend Compilation Optimization
|
||||
|
||||
## Summary
|
||||
|
||||
Feature-gated heavy dependencies that were compiled by default but only used behind enterprise/EE feature flags. This reduces the default build from **761 crates to 609 crates** (20% reduction).
|
||||
|
||||
## Changes
|
||||
|
||||
### Root `Cargo.toml`
|
||||
- Removed 12 unused direct dependencies: `kube`, `k8s-openapi`, `aws-sigv4`, `aws-sdk-config`, `opentelemetry-proto`, `systemstat`, `globset`, `libloading`, `bitflags`, `memchr`, `quote`, `pep440_rs`
|
||||
|
||||
### `windmill-worker/Cargo.toml`
|
||||
- Made optional (only needed for EE OTEL tracing proxy): `hudsucker`, `hyper-http-proxy`, `hyper-tls`, `hyper-util`, `rcgen`, `opentelemetry-proto`, `prost`
|
||||
- Made optional (only needed for EE features): `aws-config`, `aws-credential-types`, `aws-smithy-types`
|
||||
- Created `otel_proxy` feature to group the OTEL proxy deps
|
||||
- Updated `private` feature to include `otel_proxy`
|
||||
|
||||
### `windmill-common/Cargo.toml`
|
||||
- Made optional: `aws-config`, `aws-credential-types`, `aws-smithy-types`, `systemstat`, `globset`
|
||||
- Added AWS deps to `private`, `parquet`, `aws_auth`, `bedrock` features
|
||||
- Added `systemstat` to `private` feature
|
||||
- Added `globset` to `parquet` feature
|
||||
|
||||
### `windmill-api/Cargo.toml`
|
||||
- Made optional: `aws-sigv4`, `aws-sdk-config`, `aws-credential-types`, `aws-smithy-types`, `windmill-parser-py-imports`, `windmill-autoscaling`
|
||||
- Added AWS deps to `parquet` and `bedrock` features
|
||||
- Added `windmill-parser-py-imports` to `python` and `agent_worker_server` features
|
||||
- Added `windmill-autoscaling` to `enterprise` feature
|
||||
|
||||
### `windmill-autoscaling/Cargo.toml`
|
||||
- Made optional: `kube`, `k8s-openapi` (only used in EE code)
|
||||
- Added to `private` feature
|
||||
|
||||
### `parsers/windmill-parser-py-imports/Cargo.toml`
|
||||
- Removed unused direct dependencies: `malachite`, `malachite-bigint` (still available transitively via `rustpython-parser`)
|
||||
|
||||
## Benchmarks
|
||||
|
||||
### Default build (no features)
|
||||
|
||||
| Metric | Before | After |
|
||||
|---|---|---|
|
||||
| Crates compiled | 761 | 609 |
|
||||
| Notable deps eliminated | - | aws-sdk-config (9.5s), k8s-openapi (7.3s), zstd-sys (7.7s), kube-client (1.9s) |
|
||||
|
||||
### Incremental compilation (stable-state, warm cache)
|
||||
|
||||
| Scenario | Before | After |
|
||||
|---|---|---|
|
||||
| Touch `windmill-api/src/users.rs` | ~5.6s | ~5.4s |
|
||||
| Touch `windmill-worker/src/worker.rs` | ~6.7s | ~6.2s |
|
||||
| Touch `windmill-common/src/worker.rs` (cascade) | ~8.5s | ~8.5s |
|
||||
|
||||
Incremental compilation improvement from feature-gating alone is modest because the bottleneck is the compilation of the windmill crates themselves (especially windmill-api at 90k LOC), not the dependencies.
|
||||
|
||||
## Developer-Local Speed Tips
|
||||
|
||||
These settings are **not committed** because they are developer-local preferences that depend on toolchain availability. Combined, they yield ~16% faster incremental compilation.
|
||||
|
||||
### mold linker (~6% improvement)
|
||||
|
||||
Install `mold` and add to `.cargo/config.toml`:
|
||||
|
||||
```toml
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
linker = "clang"
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
|
||||
```
|
||||
|
||||
### Reduced debug info (~10% improvement)
|
||||
|
||||
Add to `[profile.dev]` in `Cargo.toml`:
|
||||
|
||||
```toml
|
||||
split-debuginfo = "unpacked"
|
||||
debug = "line-tables-only"
|
||||
```
|
||||
|
||||
### SQLX offline mode (~4% improvement)
|
||||
|
||||
If you're not modifying SQL queries:
|
||||
|
||||
```bash
|
||||
export SQLX_OFFLINE=true
|
||||
```
|
||||
|
||||
### Combined effect
|
||||
|
||||
| Scenario | Baseline | With all tips |
|
||||
|---|---|---|
|
||||
| Touch `windmill-api` file | 5.6s | **4.7s** |
|
||||
| Touch `windmill-worker` file | 6.7s | **6.0s** |
|
||||
| Touch `windmill-common` file (cascade) | 8.5s | **7.6s** |
|
||||
|
||||
## What would help more (future work)
|
||||
|
||||
The single biggest improvement would be **splitting `windmill-api`** (90k LOC) into smaller crates. Currently, any file change in the crate triggers re-analysis of all 90k lines. However, this requires significant refactoring due to tight coupling between the triggers subsystem, jobs, users, and the axum router initialization.
|
||||
152
backend/Cargo.lock
generated
152
backend/Cargo.lock
generated
@@ -829,7 +829,7 @@ dependencies = [
|
||||
"aws-sdk-sts",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http 0.62.6",
|
||||
"aws-smithy-json",
|
||||
"aws-smithy-json 0.61.9",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
@@ -882,23 +882,23 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-runtime"
|
||||
version = "1.5.18"
|
||||
version = "1.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "959dab27ce613e6c9658eb3621064d0e2027e5f2acb65bc526a43577facea557"
|
||||
checksum = "c635c2dc792cb4a11ce1a4f392a925340d1bdf499289b5ec1ec6810954eb43f5"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-sigv4",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-eventstream",
|
||||
"aws-smithy-http 0.62.6",
|
||||
"aws-smithy-http 0.63.3",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
"aws-types",
|
||||
"bytes",
|
||||
"fastrand",
|
||||
"http 0.2.12",
|
||||
"http-body 0.4.6",
|
||||
"http 1.4.0",
|
||||
"http-body 1.0.1",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tracing",
|
||||
@@ -907,15 +907,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-bedrock"
|
||||
version = "1.129.0"
|
||||
version = "1.130.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d009c7cbb8332c805be2be32c0def27cb7bd4804d9333f5cf204abfd96b6d25"
|
||||
checksum = "883f0f0b2014a0bb59b6bfce8265a6ca6dc7e849d09314124f94a347b9d9e9f5"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http 0.62.6",
|
||||
"aws-smithy-json",
|
||||
"aws-smithy-http 0.63.3",
|
||||
"aws-smithy-json 0.62.3",
|
||||
"aws-smithy-observability",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
@@ -924,6 +924,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"fastrand",
|
||||
"http 0.2.12",
|
||||
"http 1.4.0",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
]
|
||||
@@ -940,7 +941,7 @@ dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-eventstream",
|
||||
"aws-smithy-http 0.62.6",
|
||||
"aws-smithy-json",
|
||||
"aws-smithy-json 0.61.9",
|
||||
"aws-smithy-observability",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
@@ -964,7 +965,7 @@ dependencies = [
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http 0.62.6",
|
||||
"aws-smithy-json",
|
||||
"aws-smithy-json 0.61.9",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
@@ -979,16 +980,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-rds"
|
||||
version = "1.123.0"
|
||||
version = "1.124.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43adf02ed73dcbaa18979ab849162331e4b772e276988e5750fd277687d92897"
|
||||
checksum = "24627b8374f8f242f351aa8a69adae8a416826a89819a7bfd010fc48b81f51e8"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-runtime",
|
||||
"aws-sigv4",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http 0.62.6",
|
||||
"aws-smithy-json",
|
||||
"aws-smithy-http 0.63.3",
|
||||
"aws-smithy-json 0.62.3",
|
||||
"aws-smithy-observability",
|
||||
"aws-smithy-query",
|
||||
"aws-smithy-runtime",
|
||||
@@ -998,6 +999,7 @@ dependencies = [
|
||||
"aws-types",
|
||||
"fastrand",
|
||||
"http 0.2.12",
|
||||
"http 1.4.0",
|
||||
"regex-lite",
|
||||
"tracing",
|
||||
"url",
|
||||
@@ -1013,7 +1015,7 @@ dependencies = [
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http 0.62.6",
|
||||
"aws-smithy-json",
|
||||
"aws-smithy-json 0.61.9",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
@@ -1035,7 +1037,7 @@ dependencies = [
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http 0.62.6",
|
||||
"aws-smithy-json",
|
||||
"aws-smithy-json 0.61.9",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
@@ -1057,7 +1059,7 @@ dependencies = [
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http 0.62.6",
|
||||
"aws-smithy-json",
|
||||
"aws-smithy-json 0.61.9",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
@@ -1079,7 +1081,7 @@ dependencies = [
|
||||
"aws-runtime",
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http 0.62.6",
|
||||
"aws-smithy-json",
|
||||
"aws-smithy-json 0.61.9",
|
||||
"aws-smithy-query",
|
||||
"aws-smithy-runtime",
|
||||
"aws-smithy-runtime-api",
|
||||
@@ -1094,13 +1096,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sigv4"
|
||||
version = "1.3.7"
|
||||
version = "1.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69e523e1c4e8e7e8ff219d732988e22bfeae8a1cafdbe6d9eca1546fa080be7c"
|
||||
checksum = "efa49f3c607b92daae0c078d48a4571f599f966dce3caee5f1ea55c4d9073f99"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-smithy-eventstream",
|
||||
"aws-smithy-http 0.62.6",
|
||||
"aws-smithy-http 0.63.3",
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
"bytes",
|
||||
@@ -1219,6 +1221,15 @@ dependencies = [
|
||||
"aws-smithy-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-json"
|
||||
version = "0.62.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3cb96aa208d62ee94104645f7b2ecaf77bf27edf161590b6224bfbac2832f979"
|
||||
dependencies = [
|
||||
"aws-smithy-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-observability"
|
||||
version = "0.2.4"
|
||||
@@ -6959,7 +6970,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tower-service",
|
||||
"webpki-roots 1.0.5",
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10986,7 +10997,7 @@ dependencies = [
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.5",
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11580,7 +11591,7 @@ dependencies = [
|
||||
"rustls-webpki 0.103.9",
|
||||
"security-framework 3.5.1",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs 1.0.5",
|
||||
"webpki-root-certs 1.0.6",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -15300,14 +15311,14 @@ version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e"
|
||||
dependencies = [
|
||||
"webpki-root-certs 1.0.5",
|
||||
"webpki-root-certs 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.5"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc"
|
||||
checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -15318,14 +15329,14 @@ version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.5",
|
||||
"webpki-roots 1.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.5"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c"
|
||||
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -15487,11 +15498,14 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windmill"
|
||||
version = "1.624.0"
|
||||
version = "1.625.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aws-sdk-config",
|
||||
"aws-sigv4",
|
||||
"axum 0.7.9",
|
||||
"base64 0.22.1",
|
||||
"bitflags 2.9.4",
|
||||
"chrono",
|
||||
"constant_time_eq 0.3.1",
|
||||
"deno_core",
|
||||
@@ -15499,10 +15513,18 @@ dependencies = [
|
||||
"futures",
|
||||
"gethostname",
|
||||
"git-version",
|
||||
"globset",
|
||||
"k8s-openapi",
|
||||
"kube",
|
||||
"lazy_static",
|
||||
"libloading 0.8.9",
|
||||
"memchr",
|
||||
"object_store",
|
||||
"once_cell",
|
||||
"opentelemetry-proto 0.29.0",
|
||||
"pep440_rs",
|
||||
"prometheus",
|
||||
"quote",
|
||||
"rand 0.9.0",
|
||||
"reqwest 0.13.1",
|
||||
"rustls 0.23.35",
|
||||
@@ -15515,6 +15537,7 @@ dependencies = [
|
||||
"sql-builder",
|
||||
"sqlx",
|
||||
"strum 0.27.2",
|
||||
"systemstat",
|
||||
"tempfile",
|
||||
"tikv-jemalloc-ctl",
|
||||
"tikv-jemalloc-sys",
|
||||
@@ -15539,7 +15562,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api"
|
||||
version = "1.624.0"
|
||||
version = "1.625.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -15575,6 +15598,7 @@ dependencies = [
|
||||
"constant_time_eq 0.3.1",
|
||||
"cookie 0.17.0",
|
||||
"cron",
|
||||
"dashmap 6.1.0",
|
||||
"datafusion",
|
||||
"deno_core",
|
||||
"deno_error",
|
||||
@@ -15670,7 +15694,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-client"
|
||||
version = "1.624.0"
|
||||
version = "1.625.0"
|
||||
dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -15680,7 +15704,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-audit"
|
||||
version = "1.624.0"
|
||||
version = "1.625.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"lazy_static",
|
||||
@@ -15694,7 +15718,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-autoscaling"
|
||||
version = "1.624.0"
|
||||
version = "1.625.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.7.9",
|
||||
@@ -15713,7 +15737,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.624.0"
|
||||
version = "1.625.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -15809,7 +15833,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-git-sync"
|
||||
version = "1.624.0"
|
||||
version = "1.625.0"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
@@ -15824,7 +15848,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-indexer"
|
||||
version = "1.624.0"
|
||||
version = "1.625.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"astral-tokio-tar",
|
||||
@@ -15848,7 +15872,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.624.0"
|
||||
version = "1.625.0"
|
||||
dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
@@ -15864,7 +15888,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-mcp"
|
||||
version = "1.624.0"
|
||||
version = "1.625.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15884,7 +15908,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-oauth"
|
||||
version = "1.624.0"
|
||||
version = "1.625.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-oauth2",
|
||||
@@ -15908,7 +15932,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.624.0"
|
||||
version = "1.625.0"
|
||||
dependencies = [
|
||||
"convert_case 0.6.0",
|
||||
"serde",
|
||||
@@ -15917,7 +15941,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.624.0"
|
||||
version = "1.625.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -15929,7 +15953,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.624.0"
|
||||
version = "1.625.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -15941,7 +15965,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.624.0"
|
||||
version = "1.625.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -15953,7 +15977,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.624.0"
|
||||
version = "1.625.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -15965,7 +15989,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.624.0"
|
||||
version = "1.625.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -15977,7 +16001,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.624.0"
|
||||
version = "1.625.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -15988,7 +16012,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.624.0"
|
||||
version = "1.625.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -15999,7 +16023,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.624.0"
|
||||
version = "1.625.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -16012,12 +16036,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.624.0"
|
||||
version = "1.625.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
"malachite",
|
||||
"malachite-bigint",
|
||||
"pep440_rs",
|
||||
"phf 0.11.3",
|
||||
"regex",
|
||||
@@ -16034,7 +16060,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.624.0"
|
||||
version = "1.625.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16048,7 +16074,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.624.0"
|
||||
version = "1.625.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case 0.6.0",
|
||||
@@ -16065,7 +16091,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.624.0"
|
||||
version = "1.625.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16079,7 +16105,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.624.0"
|
||||
version = "1.625.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -16098,7 +16124,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.624.0"
|
||||
version = "1.625.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -16109,7 +16135,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-queue"
|
||||
version = "1.624.0"
|
||||
version = "1.625.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -16146,7 +16172,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-sql-datatype-parser-wasm"
|
||||
version = "1.624.0"
|
||||
version = "1.625.0"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-test",
|
||||
@@ -16156,7 +16182,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker"
|
||||
version = "1.624.0"
|
||||
version = "1.625.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-once-cell",
|
||||
@@ -17151,9 +17177,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "7.2.0"
|
||||
version = "7.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0"
|
||||
checksum = "268bf6f9ceb991e07155234071501490bb41fd1e39c6a588106dad10ae2a5804"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"indexmap 2.11.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.624.0"
|
||||
version = "1.625.0"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -35,7 +35,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.624.0"
|
||||
version = "1.625.0"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -150,13 +150,21 @@ deno_core = { workspace = true, optional = true }
|
||||
object_store = { workspace = true, optional = true }
|
||||
sha1 = { workspace = true, optional = true }
|
||||
constant_time_eq = { workspace = true, optional = true }
|
||||
|
||||
|
||||
quote.workspace = true
|
||||
memchr.workspace = true
|
||||
v8 = { workspace = true, optional = true }
|
||||
rustls.workspace = true
|
||||
pep440_rs.workspace = true
|
||||
strum.workspace = true
|
||||
|
||||
|
||||
aws-sigv4.workspace = true
|
||||
aws-sdk-config.workspace = true
|
||||
kube.workspace = true
|
||||
k8s-openapi.workspace = true
|
||||
libloading.workspace = true
|
||||
bitflags.workspace = true
|
||||
globset.workspace = true
|
||||
opentelemetry-proto.workspace = true
|
||||
systemstat.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-service = "0.7"
|
||||
|
||||
@@ -1 +1 @@
|
||||
138a4f5f868f3bded5bb7cb77b222b532c07e4af
|
||||
41264d02f64572a3ea3b81ac2833e8d466366e96
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE kafka_trigger DROP COLUMN filters;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE kafka_trigger ADD COLUMN filters JSONB[] NOT NULL DEFAULT '{}';
|
||||
2
backend/migrations/20260203122047_asset_columns.down.sql
Normal file
2
backend/migrations/20260203122047_asset_columns.down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Remove columns field from asset table
|
||||
ALTER TABLE asset DROP COLUMN columns;
|
||||
3
backend/migrations/20260203122047_asset_columns.up.sql
Normal file
3
backend/migrations/20260203122047_asset_columns.up.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- Add columns field to asset table to store column-level access information
|
||||
-- This is a JSONB map of column name to access type (r, w, or rw)
|
||||
ALTER TABLE asset ADD COLUMN columns JSONB;
|
||||
@@ -0,0 +1,5 @@
|
||||
DROP TRIGGER IF EXISTS workspace_rate_limit_change_trigger ON workspace_settings;
|
||||
DROP FUNCTION IF EXISTS notify_workspace_rate_limit_change();
|
||||
|
||||
ALTER TABLE workspace_settings
|
||||
DROP COLUMN IF EXISTS public_app_execution_limit_per_minute;
|
||||
@@ -0,0 +1,19 @@
|
||||
ALTER TABLE workspace_settings
|
||||
ADD COLUMN IF NOT EXISTS public_app_execution_limit_per_minute INTEGER DEFAULT NULL;
|
||||
|
||||
-- Add trigger function for rate limit changes
|
||||
CREATE OR REPLACE FUNCTION notify_workspace_rate_limit_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
INSERT INTO notify_event (channel, payload)
|
||||
VALUES ('notify_workspace_rate_limit_change', NEW.workspace_id);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create trigger on workspace_settings (drop first if exists)
|
||||
DROP TRIGGER IF EXISTS workspace_rate_limit_change_trigger ON workspace_settings;
|
||||
CREATE TRIGGER workspace_rate_limit_change_trigger
|
||||
AFTER UPDATE OF public_app_execution_limit_per_minute ON workspace_settings
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION notify_workspace_rate_limit_change();
|
||||
@@ -0,0 +1 @@
|
||||
-- Add down migration script here
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Add up migration script here
|
||||
|
||||
DO
|
||||
$do$
|
||||
BEGIN
|
||||
ALTER TYPE FAVORITE_KIND ADD VALUE 'asset';
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RAISE NOTICE 'Couldn''t create FAVORITE_KIND::asset: %', SQLERRM;
|
||||
END
|
||||
$do$;
|
||||
@@ -18,6 +18,8 @@ regex.workspace = true
|
||||
windmill-parser.workspace = true
|
||||
windmill-common.workspace = true
|
||||
rustpython-parser.workspace = true
|
||||
malachite.workspace = true
|
||||
malachite-bigint.workspace = true
|
||||
phf.workspace = true
|
||||
itertools.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -19,9 +19,12 @@ pub fn parse_assets(input: &str) -> anyhow::Result<ParseAssetsOutput> {
|
||||
// if a db = wmill.datatable() was never used (e.g db.query(...)),
|
||||
// we still want to register the asset as unknown access type
|
||||
if asset_was_used(&assets_finder.assets, (kind, &path)) == false {
|
||||
assets_finder
|
||||
.assets
|
||||
.push(ParseAssetsResult { kind, access_type: None, path });
|
||||
assets_finder.assets.push(ParseAssetsResult {
|
||||
kind,
|
||||
path,
|
||||
access_type: None,
|
||||
columns: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,8 +51,12 @@ impl Visitor for AssetsFinder {
|
||||
match removed {
|
||||
Some((kind, path, _)) => {
|
||||
if !asset_was_used(&self.assets, (kind, &path)) {
|
||||
self.assets
|
||||
.push(ParseAssetsResult { kind, access_type: None, path });
|
||||
self.assets.push(ParseAssetsResult {
|
||||
kind,
|
||||
path,
|
||||
access_type: None,
|
||||
columns: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
@@ -76,6 +83,7 @@ impl Visitor for AssetsFinder {
|
||||
kind,
|
||||
path: path.to_string(),
|
||||
access_type: None,
|
||||
columns: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -97,6 +105,7 @@ impl Visitor for AssetsFinder {
|
||||
kind,
|
||||
path: path.to_string(),
|
||||
access_type: None,
|
||||
columns: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -252,8 +261,12 @@ impl AssetsFinder {
|
||||
let path = parse_asset_syntax(&value, false)
|
||||
.map(|(_, p)| p)
|
||||
.unwrap_or(&value);
|
||||
self.assets
|
||||
.push(ParseAssetsResult { kind, path: path.to_string(), access_type });
|
||||
self.assets.push(ParseAssetsResult {
|
||||
kind,
|
||||
path: path.to_string(),
|
||||
access_type,
|
||||
columns: None,
|
||||
});
|
||||
}
|
||||
_ => return Err(()),
|
||||
};
|
||||
@@ -266,6 +279,8 @@ struct Arg(usize, &'static str);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -281,7 +296,8 @@ def main():
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::S3Object,
|
||||
path: "/test.csv".to_string(),
|
||||
access_type: Some(R)
|
||||
access_type: Some(R),
|
||||
columns: None,
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -299,7 +315,8 @@ def main():
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "main".to_string(),
|
||||
access_type: None
|
||||
access_type: None,
|
||||
columns: None,
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -318,7 +335,8 @@ def main(x: int):
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "dt/friends".to_string(),
|
||||
access_type: Some(R)
|
||||
access_type: Some(R),
|
||||
columns: None,
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -340,12 +358,14 @@ def main(x: int):
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "dt/analytics".to_string(),
|
||||
access_type: Some(R)
|
||||
access_type: Some(R),
|
||||
columns: None,
|
||||
},
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "dt/friends".to_string(),
|
||||
access_type: Some(RW)
|
||||
access_type: Some(RW),
|
||||
columns: Some(BTreeMap::from([("x".to_string(), AssetUsageAccessType::W)])),
|
||||
},
|
||||
])
|
||||
);
|
||||
@@ -372,17 +392,20 @@ def g():
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "another1/customers".to_string(),
|
||||
access_type: Some(W)
|
||||
access_type: Some(W),
|
||||
columns: None,
|
||||
},
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "another2".to_string(),
|
||||
access_type: None
|
||||
access_type: None,
|
||||
columns: None,
|
||||
},
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "main/friends".to_string(),
|
||||
access_type: Some(R)
|
||||
access_type: Some(R),
|
||||
columns: None,
|
||||
},
|
||||
])
|
||||
);
|
||||
@@ -404,12 +427,14 @@ def g():
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "another1".to_string(),
|
||||
access_type: None
|
||||
access_type: None,
|
||||
columns: None,
|
||||
},
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "main".to_string(),
|
||||
access_type: None
|
||||
access_type: None,
|
||||
columns: None,
|
||||
},
|
||||
])
|
||||
);
|
||||
@@ -429,7 +454,8 @@ def main(x: int):
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "dt/public.friends".to_string(),
|
||||
access_type: Some(R)
|
||||
access_type: Some(R),
|
||||
columns: None,
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -448,7 +474,8 @@ def main():
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "lake1/analytics.metrics".to_string(),
|
||||
access_type: Some(R)
|
||||
access_type: Some(R),
|
||||
columns: None,
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -468,7 +495,8 @@ def main(x: int):
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "dt/public.users".to_string(),
|
||||
access_type: Some(RW)
|
||||
access_type: Some(RW),
|
||||
columns: None,
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -486,7 +514,8 @@ def main():
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "dt".to_string(),
|
||||
access_type: None
|
||||
access_type: None,
|
||||
columns: None,
|
||||
},])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use sqlparser::{
|
||||
ast::{
|
||||
CopyTarget, Expr, ObjectName, TableFactor, TableObject, Value, ValueWithSpan, Visit,
|
||||
Visitor,
|
||||
CopyTarget, Expr, ObjectName, ObjectNamePart, SelectItem, TableFactor, TableObject, Value,
|
||||
ValueWithSpan, Visit, Visitor,
|
||||
},
|
||||
dialect::DuckDbDialect,
|
||||
parser::Parser,
|
||||
@@ -24,9 +24,12 @@ pub fn parse_assets(input: &str) -> anyhow::Result<ParseAssetsOutput> {
|
||||
|
||||
for (_, (kind, path)) in collector.var_identifiers {
|
||||
if !asset_was_used(&collector.assets, (kind, &path)) {
|
||||
collector
|
||||
.assets
|
||||
.push(ParseAssetsResult { kind, access_type: None, path: path });
|
||||
collector.assets.push(ParseAssetsResult {
|
||||
kind,
|
||||
access_type: None,
|
||||
path: path,
|
||||
columns: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +42,7 @@ struct AssetCollector {
|
||||
// e.g set to Read when we are inside a SELECT ... FROM ... statement
|
||||
current_access_type_stack: Vec<AssetUsageAccessType>,
|
||||
// e.g ATTACH 'ducklake://a' AS dl; => { "dl": (Ducklake, "a") }
|
||||
var_identifiers: HashMap<String, (AssetKind, String)>,
|
||||
var_identifiers: BTreeMap<String, (AssetKind, String)>,
|
||||
// e.g USE dl;
|
||||
currently_used_asset: Option<(AssetKind, String)>,
|
||||
}
|
||||
@@ -49,15 +52,19 @@ impl AssetCollector {
|
||||
Self {
|
||||
assets: Vec::new(),
|
||||
current_access_type_stack: Vec::with_capacity(8),
|
||||
var_identifiers: HashMap::new(),
|
||||
var_identifiers: BTreeMap::new(),
|
||||
currently_used_asset: None,
|
||||
}
|
||||
}
|
||||
|
||||
// Detect when we do 'a.b' and 'a' is associated with an asset in var_identifiers
|
||||
// Or when we access 'b' and we did USE a;
|
||||
fn get_associated_asset_from_obj_name(&self, name: &ObjectName) -> Option<ParseAssetsResult> {
|
||||
let access_type = self.current_access_type_stack.last().copied();
|
||||
fn get_associated_asset_from_obj_name(
|
||||
&self,
|
||||
name: &ObjectName,
|
||||
access_type: Option<AssetUsageAccessType>,
|
||||
) -> Option<ParseAssetsResult> {
|
||||
let access_type = access_type.or_else(|| self.current_access_type_stack.last().copied());
|
||||
if let Some((kind, path)) = &self.currently_used_asset {
|
||||
// We don't want to infer that any simple identifier refers to an asset if
|
||||
// we are not in a known R/W context
|
||||
@@ -81,7 +88,7 @@ impl AssetCollector {
|
||||
.collect::<Option<Vec<String>>>()?
|
||||
.join(".");
|
||||
let path = format!("{}/{}", path, specific_table);
|
||||
return Some(ParseAssetsResult { kind: *kind, access_type, path });
|
||||
return Some(ParseAssetsResult { kind: *kind, access_type, path, columns: None });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +108,7 @@ impl AssetCollector {
|
||||
} else {
|
||||
path.clone()
|
||||
};
|
||||
Some(ParseAssetsResult { kind: *kind, access_type, path })
|
||||
Some(ParseAssetsResult { kind: *kind, access_type, path, columns: None })
|
||||
}
|
||||
|
||||
fn handle_string_literal(&mut self, s: &str) {
|
||||
@@ -112,6 +119,7 @@ impl AssetCollector {
|
||||
kind,
|
||||
path: path.to_string(),
|
||||
access_type: self.current_access_type_stack.last().copied(),
|
||||
columns: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -126,13 +134,6 @@ impl AssetCollector {
|
||||
if let Some(str_lit) = get_str_lit_from_obj_name(name) {
|
||||
self.handle_string_literal(str_lit);
|
||||
}
|
||||
|
||||
// Writes to tables should be handled directly when visiting the statement
|
||||
if self.current_access_type_stack.last() == Some(&R) {
|
||||
if let Some(asset) = self.get_associated_asset_from_obj_name(name) {
|
||||
self.assets.push(asset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_obj_name_post(&mut self, name: &ObjectName) {
|
||||
@@ -146,20 +147,144 @@ impl AssetCollector {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_table_with_joins(&mut self, table_with_joins: &sqlparser::ast::TableWithJoins) {
|
||||
if let TableFactor::Table { name, .. } = &table_with_joins.relation {
|
||||
if let Some(asset) = self.get_associated_asset_from_obj_name(name) {
|
||||
fn handle_table_with_joins(
|
||||
&mut self,
|
||||
table_with_joins: &sqlparser::ast::TableWithJoins,
|
||||
access_type: Option<AssetUsageAccessType>,
|
||||
) {
|
||||
if let TableFactor::Table { name, args, .. } = &table_with_joins.relation {
|
||||
if args.is_some() && args.as_ref().map_or(0, |a| a.args.len()) > 0 {
|
||||
return;
|
||||
}
|
||||
if let Some(asset) = self.get_associated_asset_from_obj_name(name, access_type) {
|
||||
self.assets.push(asset);
|
||||
}
|
||||
}
|
||||
for join in &table_with_joins.joins {
|
||||
if let TableFactor::Table { name, .. } = &join.relation {
|
||||
if let Some(asset) = self.get_associated_asset_from_obj_name(name) {
|
||||
if let Some(asset) = self.get_associated_asset_from_obj_name(name, access_type) {
|
||||
self.assets.push(asset);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract columns from SELECT items and create individual asset results for each column
|
||||
// Only processes columns that reference known assets to avoid false positives
|
||||
fn extract_column_assets(
|
||||
&mut self,
|
||||
projection: &[SelectItem],
|
||||
from_tables: &[sqlparser::ast::TableWithJoins],
|
||||
) {
|
||||
// Check if this is a single-table SELECT (to avoid ambiguity)
|
||||
let single_table = if from_tables.len() == 1 {
|
||||
if let TableFactor::Table { name, args, .. } = &from_tables[0].relation {
|
||||
if args.is_some() && args.as_ref().map_or(0, |a| a.args.len()) > 0 {
|
||||
return; // Skip table functions
|
||||
}
|
||||
self.get_associated_asset_from_obj_name(name, Some(R))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Build a map of table aliases/names to assets for multi-table queries
|
||||
let mut table_to_asset: BTreeMap<String, ParseAssetsResult> = BTreeMap::new();
|
||||
for table_with_joins in from_tables {
|
||||
if let TableFactor::Table { name, alias, args, .. } = &table_with_joins.relation {
|
||||
if args.is_some() && args.as_ref().map_or(0, |a| a.args.len()) > 0 {
|
||||
continue; // Skip table functions
|
||||
}
|
||||
if let Some(asset) = self.get_associated_asset_from_obj_name(name, Some(R)) {
|
||||
// Use alias if present, otherwise use the table name
|
||||
let table_key = if let Some(alias) = alias {
|
||||
alias.name.value.clone()
|
||||
} else {
|
||||
// For qualified names like "dl.table1", use just the last part
|
||||
name.0
|
||||
.last()
|
||||
.and_then(|id| id.as_ident())
|
||||
.map(|id| id.value.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
table_to_asset.insert(table_key, asset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process each SELECT item
|
||||
for item in projection {
|
||||
match item {
|
||||
SelectItem::UnnamedExpr(Expr::Identifier(ident))
|
||||
| SelectItem::ExprWithAlias { expr: Expr::Identifier(ident), .. } => {
|
||||
// Simple column: SELECT a
|
||||
// Only add if we have a single table (unambiguous)
|
||||
if let Some(asset) = &single_table {
|
||||
let mut columns = BTreeMap::new();
|
||||
columns.insert(ident.value.clone(), R);
|
||||
self.assets.push(ParseAssetsResult {
|
||||
kind: asset.kind,
|
||||
path: asset.path.clone(),
|
||||
access_type: Some(R),
|
||||
columns: Some(columns),
|
||||
});
|
||||
}
|
||||
}
|
||||
SelectItem::UnnamedExpr(Expr::CompoundIdentifier(parts))
|
||||
| SelectItem::ExprWithAlias { expr: Expr::CompoundIdentifier(parts), .. } => {
|
||||
// Qualified column: SELECT table1.a or SELECT x.table1.a
|
||||
if parts.len() >= 2 {
|
||||
let column_name = parts.last().map(|id| id.value.clone());
|
||||
|
||||
if let Some(column_name) = column_name {
|
||||
// Check if the prefix matches a known table
|
||||
let table_prefix = parts.first().map(|id| id.value.clone());
|
||||
|
||||
if let Some(table_prefix) = table_prefix {
|
||||
if let Some(asset) = table_to_asset.get(&table_prefix) {
|
||||
// Found a matching table, add column asset
|
||||
let mut columns = BTreeMap::new();
|
||||
columns.insert(column_name.clone(), R);
|
||||
self.assets.push(ParseAssetsResult {
|
||||
kind: asset.kind,
|
||||
path: asset.path.clone(),
|
||||
access_type: Some(R),
|
||||
columns: Some(columns),
|
||||
});
|
||||
} else if parts.len() >= 3 {
|
||||
// Could be x.table1.column format or db.schema.table.column
|
||||
// Convert Idents to ObjectNameParts
|
||||
let obj_parts: Vec<ObjectNamePart> = parts[..parts.len() - 1]
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|ident| ObjectNamePart::Identifier(ident))
|
||||
.collect();
|
||||
let obj_name = ObjectName(obj_parts);
|
||||
if let Some(asset) =
|
||||
self.get_associated_asset_from_obj_name(&obj_name, Some(R))
|
||||
{
|
||||
let mut columns = BTreeMap::new();
|
||||
columns.insert(column_name.clone(), R);
|
||||
self.assets.push(ParseAssetsResult {
|
||||
kind: asset.kind,
|
||||
path: asset.path.clone(),
|
||||
access_type: Some(R),
|
||||
columns: Some(columns),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Ignore wildcards, expressions, etc.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Visitor for AssetCollector {
|
||||
@@ -218,51 +343,154 @@ impl Visitor for AssetCollector {
|
||||
statement: &sqlparser::ast::Statement,
|
||||
) -> std::ops::ControlFlow<Self::Break> {
|
||||
match statement {
|
||||
sqlparser::ast::Statement::Query(_) => {
|
||||
// don't forget pop() in post_visit_statement
|
||||
self.current_access_type_stack.push(R);
|
||||
sqlparser::ast::Statement::Query(q) => {
|
||||
if let Some(select) = q.body.as_select() {
|
||||
// First, handle table references (adds table-level assets)
|
||||
for t in &select.from {
|
||||
self.handle_table_with_joins(t, Some(R));
|
||||
}
|
||||
// Then, extract column-level assets
|
||||
self.extract_column_assets(&select.projection, &select.from);
|
||||
}
|
||||
}
|
||||
|
||||
sqlparser::ast::Statement::Insert(insert) => {
|
||||
let access_type = if insert.returning.is_some() { RW } else { W };
|
||||
self.current_access_type_stack.push(access_type);
|
||||
match insert.table {
|
||||
TableObject::TableName(ref name) => {
|
||||
if let Some(asset) = self.get_associated_asset_from_obj_name(name) {
|
||||
self.assets.push(asset);
|
||||
if let Some(asset) =
|
||||
self.get_associated_asset_from_obj_name(name, Some(access_type))
|
||||
{
|
||||
// Add table-level asset
|
||||
self.assets.push(ParseAssetsResult {
|
||||
kind: asset.kind,
|
||||
path: asset.path.clone(),
|
||||
access_type: asset.access_type,
|
||||
columns: None,
|
||||
});
|
||||
|
||||
// Extract column information for INSERT with explicit columns (Write access)
|
||||
if !insert.columns.is_empty() {
|
||||
for col in &insert.columns {
|
||||
let columns = BTreeMap::from([(col.value.clone(), W)]);
|
||||
self.assets.push(ParseAssetsResult {
|
||||
kind: asset.kind,
|
||||
path: asset.path.clone(),
|
||||
access_type: Some(W),
|
||||
columns: Some(columns),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Extract column information from RETURNING clause (Read access)
|
||||
if let Some(returning) = &insert.returning {
|
||||
for item in returning {
|
||||
match item {
|
||||
SelectItem::UnnamedExpr(Expr::Identifier(ident))
|
||||
| SelectItem::ExprWithAlias {
|
||||
expr: Expr::Identifier(ident),
|
||||
..
|
||||
} => {
|
||||
let mut col_map = BTreeMap::new();
|
||||
col_map.insert(ident.value.clone(), R);
|
||||
self.assets.push(ParseAssetsResult {
|
||||
kind: asset.kind,
|
||||
path: asset.path.clone(),
|
||||
access_type: Some(R),
|
||||
columns: Some(col_map),
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
// Ignore wildcards and complex expressions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.current_access_type_stack.pop();
|
||||
}
|
||||
|
||||
sqlparser::ast::Statement::Update { returning, table, from, .. } => {
|
||||
sqlparser::ast::Statement::Update { returning, table, from, assignments, .. } => {
|
||||
if let Some(from_tables) = from {
|
||||
let from_tables = match from_tables {
|
||||
sqlparser::ast::UpdateTableFromKind::AfterSet(tables) => tables,
|
||||
sqlparser::ast::UpdateTableFromKind::BeforeSet(tables) => tables,
|
||||
};
|
||||
self.current_access_type_stack.push(R);
|
||||
for table_with_joins in from_tables {
|
||||
self.handle_table_with_joins(table_with_joins);
|
||||
self.handle_table_with_joins(table_with_joins, Some(R));
|
||||
}
|
||||
self.current_access_type_stack.pop();
|
||||
}
|
||||
|
||||
let access_type = if returning.is_some() { RW } else { W };
|
||||
self.current_access_type_stack.push(access_type);
|
||||
self.handle_table_with_joins(table, Some(access_type));
|
||||
|
||||
self.handle_table_with_joins(table);
|
||||
// Extract column information from UPDATE SET clauses (Write access)
|
||||
// Only process if it's a single table update
|
||||
if let TableFactor::Table { name, .. } = &table.relation {
|
||||
if let Some(asset) =
|
||||
self.get_associated_asset_from_obj_name(name, Some(access_type))
|
||||
{
|
||||
// Process each assignment to extract column names
|
||||
for assignment in assignments {
|
||||
// assignment.target is an AssignmentTarget enum
|
||||
// We only handle simple column names (ColumnName variant)
|
||||
if let sqlparser::ast::AssignmentTarget::ColumnName(col_name) =
|
||||
&assignment.target
|
||||
{
|
||||
// For simple column updates, this is typically a single ident
|
||||
if col_name.0.len() == 1 {
|
||||
if let Some(col_ident) =
|
||||
col_name.0.first().and_then(|p| p.as_ident())
|
||||
{
|
||||
let mut col_map = BTreeMap::new();
|
||||
col_map.insert(col_ident.value.clone(), W);
|
||||
self.assets.push(ParseAssetsResult {
|
||||
kind: asset.kind,
|
||||
path: asset.path.clone(),
|
||||
access_type: Some(W),
|
||||
columns: Some(col_map),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.current_access_type_stack.pop();
|
||||
// Extract column information from RETURNING clause (Read access)
|
||||
if let Some(returning_items) = returning {
|
||||
for item in returning_items {
|
||||
match item {
|
||||
SelectItem::UnnamedExpr(Expr::Identifier(ident))
|
||||
| SelectItem::ExprWithAlias {
|
||||
expr: Expr::Identifier(ident),
|
||||
..
|
||||
} => {
|
||||
let mut col_map = BTreeMap::new();
|
||||
col_map.insert(ident.value.clone(), R);
|
||||
self.assets.push(ParseAssetsResult {
|
||||
kind: asset.kind,
|
||||
path: asset.path.clone(),
|
||||
access_type: Some(R),
|
||||
columns: Some(col_map),
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
// Ignore wildcards and complex expressions
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sqlparser::ast::Statement::Delete(delete) => {
|
||||
let access_type = if delete.returning.is_some() { RW } else { W };
|
||||
self.current_access_type_stack.push(access_type);
|
||||
for name in &delete.tables {
|
||||
if let Some(asset) = self.get_associated_asset_from_obj_name(name) {
|
||||
if let Some(asset) =
|
||||
self.get_associated_asset_from_obj_name(name, Some(access_type))
|
||||
{
|
||||
self.assets.push(asset);
|
||||
}
|
||||
}
|
||||
@@ -271,25 +499,22 @@ impl Visitor for AssetCollector {
|
||||
sqlparser::ast::FromTable::WithoutKeyword(tables) => tables,
|
||||
};
|
||||
for table_with_joins in tables {
|
||||
self.handle_table_with_joins(table_with_joins);
|
||||
self.handle_table_with_joins(table_with_joins, Some(access_type));
|
||||
}
|
||||
self.current_access_type_stack.pop();
|
||||
}
|
||||
|
||||
sqlparser::ast::Statement::CreateTable(create_table) => {
|
||||
self.current_access_type_stack.push(W);
|
||||
if let Some(asset) = self.get_associated_asset_from_obj_name(&create_table.name) {
|
||||
if let Some(asset) =
|
||||
self.get_associated_asset_from_obj_name(&create_table.name, Some(W))
|
||||
{
|
||||
self.assets.push(asset);
|
||||
}
|
||||
self.current_access_type_stack.pop();
|
||||
}
|
||||
|
||||
sqlparser::ast::Statement::CreateView { name, .. } => {
|
||||
self.current_access_type_stack.push(W);
|
||||
if let Some(asset) = self.get_associated_asset_from_obj_name(name) {
|
||||
if let Some(asset) = self.get_associated_asset_from_obj_name(name, Some(W)) {
|
||||
self.assets.push(asset);
|
||||
}
|
||||
self.current_access_type_stack.pop();
|
||||
}
|
||||
|
||||
sqlparser::ast::Statement::Copy { target: CopyTarget::File { filename }, .. } => {
|
||||
@@ -339,14 +564,8 @@ impl Visitor for AssetCollector {
|
||||
|
||||
fn post_visit_statement(
|
||||
&mut self,
|
||||
statement: &sqlparser::ast::Statement,
|
||||
_statement: &sqlparser::ast::Statement,
|
||||
) -> std::ops::ControlFlow<Self::Break> {
|
||||
match statement {
|
||||
sqlparser::ast::Statement::Query(_) => {
|
||||
self.current_access_type_stack.pop();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
std::ops::ControlFlow::Continue(())
|
||||
}
|
||||
|
||||
@@ -409,17 +628,20 @@ mod tests {
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::S3Object,
|
||||
path: "/a.parquet".to_string(),
|
||||
access_type: Some(R)
|
||||
access_type: Some(R),
|
||||
columns: None
|
||||
},
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::S3Object,
|
||||
path: "/c.parquet".to_string(),
|
||||
access_type: Some(W)
|
||||
access_type: Some(W),
|
||||
columns: None
|
||||
},
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::S3Object,
|
||||
path: "snd/b.parquet".to_string(),
|
||||
access_type: Some(R)
|
||||
access_type: Some(R),
|
||||
columns: None
|
||||
},
|
||||
])
|
||||
);
|
||||
@@ -438,7 +660,8 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "my_dl".to_string(),
|
||||
access_type: None
|
||||
access_type: None,
|
||||
columns: None
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -455,7 +678,8 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "my_dl/table1".to_string(),
|
||||
access_type: Some(R)
|
||||
access_type: Some(R),
|
||||
columns: None
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -473,7 +697,8 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "my_dt/table1".to_string(),
|
||||
access_type: Some(W)
|
||||
access_type: Some(W),
|
||||
columns: None
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -504,7 +729,8 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "my_dl/table1".to_string(),
|
||||
access_type: Some(W)
|
||||
access_type: Some(W),
|
||||
columns: None
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -521,7 +747,8 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "main/table1".to_string(),
|
||||
access_type: Some(W)
|
||||
access_type: Some(W),
|
||||
columns: None
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -543,7 +770,8 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "main/friends".to_string(),
|
||||
access_type: Some(RW)
|
||||
access_type: Some(RW),
|
||||
columns: None
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -561,7 +789,8 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "main".to_string(),
|
||||
access_type: None
|
||||
access_type: None,
|
||||
columns: None
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -579,7 +808,8 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "main/table1".to_string(),
|
||||
access_type: Some(W)
|
||||
access_type: Some(W),
|
||||
columns: None
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -597,7 +827,8 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "main/table1".to_string(),
|
||||
access_type: Some(W)
|
||||
access_type: Some(W),
|
||||
columns: Some(BTreeMap::from([("id".to_string(), W)])),
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -615,7 +846,8 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Resource,
|
||||
path: "u/user/pg_resource/table1".to_string(),
|
||||
access_type: Some(R)
|
||||
access_type: Some(R),
|
||||
columns: None
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -632,7 +864,8 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "main/table1".to_string(),
|
||||
access_type: Some(W)
|
||||
access_type: Some(W),
|
||||
columns: Some(BTreeMap::from([("id".to_string(), W)])),
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -650,7 +883,8 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "main/sch.table1".to_string(),
|
||||
access_type: Some(RW)
|
||||
access_type: Some(RW),
|
||||
columns: Some(BTreeMap::from([("id".to_string(), W)])),
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -669,8 +903,289 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "main/sch.table1".to_string(),
|
||||
access_type: Some(RW)
|
||||
access_type: Some(RW),
|
||||
columns: Some(BTreeMap::from([("id".to_string(), W)])),
|
||||
},])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_single_table_column_detection() {
|
||||
let input = r#"
|
||||
ATTACH 'ducklake://my_dl' AS dl;
|
||||
SELECT a, b FROM dl.table1;
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
|
||||
let result = s.unwrap();
|
||||
|
||||
// Should have one asset with merged columns
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].path, "my_dl/table1");
|
||||
assert_eq!(result[0].access_type, Some(R));
|
||||
|
||||
// Check that both columns are present in the merged asset
|
||||
let columns = result[0].columns.as_ref().expect("Should have columns");
|
||||
assert_eq!(columns.len(), 2);
|
||||
assert_eq!(columns.get("a"), Some(&R));
|
||||
assert_eq!(columns.get("b"), Some(&R));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_explicit_table_prefix_columns() {
|
||||
let input = r#"
|
||||
ATTACH 'ducklake://my_dl' AS dl;
|
||||
SELECT dl.table1.a, dl.table1.b FROM dl.table1;
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
|
||||
// Should detect columns with explicit table prefix
|
||||
let result = s.unwrap();
|
||||
// Check we have the table asset
|
||||
|
||||
// Check we have column assets
|
||||
assert!(result.iter().any(|a| {
|
||||
a.path == "my_dl/table1"
|
||||
&& a.columns
|
||||
.as_ref()
|
||||
.map_or(false, |cols| cols.contains_key("a"))
|
||||
}));
|
||||
assert!(result.iter().any(|a| {
|
||||
a.path == "my_dl/table1"
|
||||
&& a.columns
|
||||
.as_ref()
|
||||
.map_or(false, |cols| cols.contains_key("b"))
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_multi_table_no_simple_columns() {
|
||||
let input = r#"
|
||||
ATTACH 'ducklake://my_dl' AS dl;
|
||||
SELECT a, b FROM dl.table1, dl.table2;
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
|
||||
// Simple columns (a, b) should NOT be detected with multiple tables
|
||||
// Only table-level assets should be present
|
||||
let result = s.unwrap();
|
||||
|
||||
// Should have 2 table assets
|
||||
assert_eq!(result.iter().filter(|a| a.columns.is_none()).count(), 2);
|
||||
|
||||
// Should have NO column assets (ambiguous which table they belong to)
|
||||
assert_eq!(result.iter().filter(|a| a.columns.is_some()).count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_multi_table_with_qualified_columns() {
|
||||
let input = r#"
|
||||
ATTACH 'ducklake://my_dl1' AS dl1;
|
||||
ATTACH 'ducklake://my_dl2' AS dl2;
|
||||
SELECT table1.a, table2.b FROM dl1.table1, dl2.table2;
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
|
||||
// Qualified columns should be detected even with multiple tables
|
||||
let result = s.unwrap();
|
||||
|
||||
// Check we have column assets for both tables
|
||||
assert!(result.iter().any(|a| {
|
||||
a.path == "my_dl1/table1"
|
||||
&& a.columns
|
||||
.as_ref()
|
||||
.map_or(false, |cols| cols.contains_key("a"))
|
||||
}));
|
||||
assert!(result.iter().any(|a| {
|
||||
a.path == "my_dl2/table2"
|
||||
&& a.columns
|
||||
.as_ref()
|
||||
.map_or(false, |cols| cols.contains_key("b"))
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_use_with_simple_columns() {
|
||||
let input = r#"
|
||||
ATTACH 'ducklake://my_dl' AS dl;
|
||||
USE dl;
|
||||
SELECT a, b, c FROM table1;
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
|
||||
let result = s.unwrap();
|
||||
|
||||
// Should detect columns since it's a single table
|
||||
assert!(result.iter().any(|a| {
|
||||
a.path == "my_dl/table1"
|
||||
&& a.columns
|
||||
.as_ref()
|
||||
.map_or(false, |cols| cols.contains_key("a"))
|
||||
}));
|
||||
assert!(result.iter().any(|a| {
|
||||
a.path == "my_dl/table1"
|
||||
&& a.columns
|
||||
.as_ref()
|
||||
.map_or(false, |cols| cols.contains_key("b"))
|
||||
}));
|
||||
assert!(result.iter().any(|a| {
|
||||
a.path == "my_dl/table1"
|
||||
&& a.columns
|
||||
.as_ref()
|
||||
.map_or(false, |cols| cols.contains_key("c"))
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_wildcard_no_columns() {
|
||||
let input = r#"
|
||||
ATTACH 'ducklake://my_dl' AS dl;
|
||||
SELECT * FROM dl.table1;
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
|
||||
let result = s.unwrap();
|
||||
|
||||
// Wildcard should NOT create column assets, only table asset
|
||||
assert_eq!(result.len(), 1);
|
||||
assert!(result[0].columns.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_columns_with_alias() {
|
||||
let input = r#"
|
||||
ATTACH 'ducklake://my_dl' AS dl;
|
||||
SELECT a AS column_a, b AS column_b FROM dl.table1;
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
|
||||
let result = s.unwrap();
|
||||
|
||||
// Should detect columns even when aliased
|
||||
assert!(result.iter().any(|a| {
|
||||
a.path == "my_dl/table1"
|
||||
&& a.columns
|
||||
.as_ref()
|
||||
.map_or(false, |cols| cols.contains_key("a"))
|
||||
}));
|
||||
assert!(result.iter().any(|a| {
|
||||
a.path == "my_dl/table1"
|
||||
&& a.columns
|
||||
.as_ref()
|
||||
.map_or(false, |cols| cols.contains_key("b"))
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_columns_with_table_alias() {
|
||||
let input = r#"
|
||||
ATTACH 'ducklake://my_dl' AS dl;
|
||||
SELECT t.a, t.b FROM dl.table1 AS t;
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
|
||||
let result = s.unwrap();
|
||||
|
||||
// Should detect columns using the table alias
|
||||
assert!(result.iter().any(|a| {
|
||||
a.path == "my_dl/table1"
|
||||
&& a.columns
|
||||
.as_ref()
|
||||
.map_or(false, |cols| cols.contains_key("a"))
|
||||
}));
|
||||
assert!(result.iter().any(|a| {
|
||||
a.path == "my_dl/table1"
|
||||
&& a.columns
|
||||
.as_ref()
|
||||
.map_or(false, |cols| cols.contains_key("b"))
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_insert_with_columns() {
|
||||
let input = r#"
|
||||
ATTACH 'ducklake://my_dl' AS dl;
|
||||
INSERT INTO dl.table1 (name, age, email) VALUES ('John', 30, 'john@example.com');
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
|
||||
let result = s.unwrap();
|
||||
|
||||
// Should have one asset with merged columns
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].path, "my_dl/table1");
|
||||
assert_eq!(result[0].access_type, Some(W));
|
||||
|
||||
// Check that all columns are present in the merged asset
|
||||
let columns = result[0].columns.as_ref().expect("Should have columns");
|
||||
assert_eq!(columns.len(), 3);
|
||||
assert_eq!(columns.get("name"), Some(&W));
|
||||
assert_eq!(columns.get("age"), Some(&W));
|
||||
assert_eq!(columns.get("email"), Some(&W));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_insert_without_columns() {
|
||||
let input = r#"
|
||||
ATTACH 'ducklake://my_dl' AS dl;
|
||||
INSERT INTO dl.table1 VALUES ('John', 30);
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
|
||||
let result = s.unwrap();
|
||||
|
||||
// Should have one asset without column information
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].path, "my_dl/table1");
|
||||
assert_eq!(result[0].access_type, Some(W));
|
||||
assert!(result[0].columns.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_update_multiple_columns() {
|
||||
let input = r#"
|
||||
ATTACH 'ducklake://my_dl' AS dl;
|
||||
UPDATE dl.table1 SET name = 'Jane', age = 25, active = true;
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
|
||||
let result = s.unwrap();
|
||||
|
||||
// Should have one asset with merged columns
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].path, "my_dl/table1");
|
||||
assert_eq!(result[0].access_type, Some(W));
|
||||
|
||||
// Check that all columns are present
|
||||
let columns = result[0].columns.as_ref().expect("Should have columns");
|
||||
assert_eq!(columns.len(), 3);
|
||||
assert_eq!(columns.get("name"), Some(&W));
|
||||
assert_eq!(columns.get("age"), Some(&W));
|
||||
assert_eq!(columns.get("active"), Some(&W));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sql_asset_parser_update_returning() {
|
||||
let input = r#"
|
||||
ATTACH 'ducklake://my_dl' AS dl;
|
||||
UPDATE dl.table1 SET name = 'Jane', age = 26 RETURNING id, name;
|
||||
"#;
|
||||
let s = parse_assets(input).map(|s| s.assets);
|
||||
|
||||
let result = s.unwrap();
|
||||
|
||||
// Should have RW access type when RETURNING is used
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].path, "my_dl/table1");
|
||||
assert_eq!(result[0].access_type, Some(RW));
|
||||
|
||||
// Check that columns are present with correct access types
|
||||
// name and age are written (W), id and name are read (R)
|
||||
// name should be RW (both written and read)
|
||||
let columns = result[0].columns.as_ref().expect("Should have columns");
|
||||
assert_eq!(columns.len(), 3);
|
||||
assert_eq!(columns.get("name"), Some(&RW)); // Written in SET, read in RETURNING
|
||||
assert_eq!(columns.get("age"), Some(&W)); // Only written
|
||||
assert_eq!(columns.get("id"), Some(&R)); // Only read
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,7 @@ impl Visit for AssetsFinder {
|
||||
kind,
|
||||
path: path.to_string(),
|
||||
access_type: None,
|
||||
columns: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -177,8 +178,12 @@ impl Visit for AssetsFinder {
|
||||
if asset_was_used(&self.assets, (kind, path)) {
|
||||
continue;
|
||||
}
|
||||
self.assets
|
||||
.push(ParseAssetsResult { kind, access_type: None, path: path.clone() });
|
||||
self.assets.push(ParseAssetsResult {
|
||||
kind,
|
||||
access_type: None,
|
||||
path: path.clone(),
|
||||
columns: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Restore state - identifiers declared in this block go out of scope
|
||||
@@ -294,8 +299,12 @@ impl AssetsFinder {
|
||||
let path = parse_asset_syntax(&value, false)
|
||||
.map(|(_, p)| p)
|
||||
.unwrap_or(&value);
|
||||
self.assets
|
||||
.push(ParseAssetsResult { kind, path: path.to_string(), access_type });
|
||||
self.assets.push(ParseAssetsResult {
|
||||
kind,
|
||||
path: path.to_string(),
|
||||
access_type,
|
||||
columns: None,
|
||||
});
|
||||
}
|
||||
_ => return Err(()),
|
||||
}
|
||||
@@ -305,6 +314,8 @@ impl AssetsFinder {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -321,7 +332,8 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::S3Object,
|
||||
path: "/test.csv".to_string(),
|
||||
access_type: Some(R)
|
||||
access_type: Some(R),
|
||||
columns: None,
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -340,7 +352,8 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "dt".to_string(),
|
||||
access_type: None
|
||||
access_type: None,
|
||||
columns: None,
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -360,7 +373,8 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "dt/friends".to_string(),
|
||||
access_type: Some(R)
|
||||
access_type: Some(R),
|
||||
columns: None,
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -383,12 +397,17 @@ mod tests {
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "dt/analytics".to_string(),
|
||||
access_type: Some(R)
|
||||
access_type: Some(R),
|
||||
columns: None,
|
||||
},
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "dt/friends".to_string(),
|
||||
access_type: Some(RW)
|
||||
access_type: Some(RW),
|
||||
columns: Some(BTreeMap::from([(
|
||||
"name".to_string(),
|
||||
AssetUsageAccessType::W
|
||||
)])),
|
||||
},
|
||||
])
|
||||
);
|
||||
@@ -418,17 +437,20 @@ mod tests {
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "another1/customers".to_string(),
|
||||
access_type: Some(W)
|
||||
access_type: Some(W),
|
||||
columns: None,
|
||||
},
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "another2".to_string(),
|
||||
access_type: None
|
||||
access_type: None,
|
||||
columns: None,
|
||||
},
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "main/friends".to_string(),
|
||||
access_type: Some(R)
|
||||
access_type: Some(R),
|
||||
columns: None,
|
||||
},
|
||||
])
|
||||
);
|
||||
@@ -452,12 +474,14 @@ mod tests {
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "another1".to_string(),
|
||||
access_type: None
|
||||
access_type: None,
|
||||
columns: None,
|
||||
},
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::Ducklake,
|
||||
path: "main".to_string(),
|
||||
access_type: None
|
||||
access_type: None,
|
||||
columns: None,
|
||||
},
|
||||
])
|
||||
);
|
||||
@@ -478,7 +502,8 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "main/myschema.friends".to_string(),
|
||||
access_type: Some(R)
|
||||
access_type: Some(R),
|
||||
columns: None,
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -499,7 +524,8 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "dt/public.users".to_string(),
|
||||
access_type: Some(RW)
|
||||
access_type: Some(RW),
|
||||
columns: None,
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -518,7 +544,8 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "dt".to_string(),
|
||||
access_type: None
|
||||
access_type: None,
|
||||
columns: None,
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -539,7 +566,8 @@ mod tests {
|
||||
Ok(vec![ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "dt/users".to_string(),
|
||||
access_type: Some(R)
|
||||
access_type: Some(R),
|
||||
columns: None,
|
||||
},])
|
||||
);
|
||||
}
|
||||
@@ -562,12 +590,14 @@ mod tests {
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "dt/private.users".to_string(),
|
||||
access_type: Some(R)
|
||||
access_type: Some(R),
|
||||
columns: None,
|
||||
},
|
||||
ParseAssetsResult {
|
||||
kind: AssetKind::DataTable,
|
||||
path: "dt/test".to_string(),
|
||||
access_type: Some(W)
|
||||
access_type: Some(W),
|
||||
columns: None,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ pub fn parse_assets(input: &str) -> anyhow::Result<ParseAssetsOutput> {
|
||||
kind: AssetKind::Resource,
|
||||
path: delegate_to_git_repo_details.resource,
|
||||
access_type: Some(AssetUsageAccessType::R),
|
||||
columns: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,6 +22,7 @@ pub fn parse_assets(input: &str) -> anyhow::Result<ParseAssetsOutput> {
|
||||
kind: AssetKind::Resource,
|
||||
path: pinned_res,
|
||||
access_type: Some(AssetUsageAccessType::R),
|
||||
columns: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -31,6 +33,7 @@ pub fn parse_assets(input: &str) -> anyhow::Result<ParseAssetsOutput> {
|
||||
kind: AssetKind::Resource,
|
||||
path: resource,
|
||||
access_type: Some(AssetUsageAccessType::R),
|
||||
columns: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Serialize, PartialEq, Clone, Copy, Debug)]
|
||||
#[serde(rename_all(serialize = "lowercase"))]
|
||||
@@ -19,12 +20,14 @@ pub enum AssetKind {
|
||||
DataTable,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, PartialEq)]
|
||||
#[derive(Serialize, Debug, PartialEq, Clone)]
|
||||
pub struct ParseAssetsResult {
|
||||
pub kind: AssetKind,
|
||||
pub path: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub access_type: Option<AssetUsageAccessType>, // None in case of ambiguity
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub columns: Option<BTreeMap<String, AssetUsageAccessType>>, // Map column name to access type, "*" represents wildcard
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, PartialEq)]
|
||||
@@ -66,6 +69,8 @@ pub fn merge_assets(assets: Vec<ParseAssetsResult>) -> Vec<ParseAssetsResult> {
|
||||
(Some(R), Some(R)) => Some(R),
|
||||
(Some(W), Some(W)) => Some(W),
|
||||
};
|
||||
// merge columns: union the column sets and merge access types per column
|
||||
existing.columns = merge_column_maps(existing.columns.take(), asset.columns);
|
||||
} else {
|
||||
arr.push(asset);
|
||||
}
|
||||
@@ -74,6 +79,36 @@ pub fn merge_assets(assets: Vec<ParseAssetsResult>) -> Vec<ParseAssetsResult> {
|
||||
arr
|
||||
}
|
||||
|
||||
fn merge_column_maps(
|
||||
existing: Option<BTreeMap<String, AssetUsageAccessType>>,
|
||||
new: Option<BTreeMap<String, AssetUsageAccessType>>,
|
||||
) -> Option<BTreeMap<String, AssetUsageAccessType>> {
|
||||
match (existing, new) {
|
||||
(None, None) => None,
|
||||
(Some(map), None) | (None, Some(map)) => Some(map),
|
||||
(Some(mut existing_map), Some(new_map)) => {
|
||||
for (col_name, new_access) in new_map {
|
||||
existing_map
|
||||
.entry(col_name)
|
||||
.and_modify(|existing_access| {
|
||||
*existing_access = merge_access_types(*existing_access, new_access);
|
||||
})
|
||||
.or_insert(new_access);
|
||||
}
|
||||
Some(existing_map)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_access_types(a: AssetUsageAccessType, b: AssetUsageAccessType) -> AssetUsageAccessType {
|
||||
match (a, b) {
|
||||
(R, W) | (W, R) => RW,
|
||||
(RW, _) | (_, RW) => RW,
|
||||
(R, R) => R,
|
||||
(W, W) => W,
|
||||
}
|
||||
}
|
||||
|
||||
// Will return false if the user assigned an asset to a variable like:
|
||||
// let sql = wmill.datatable('main')
|
||||
// But never used it. In that case we don't know which table is being used,
|
||||
|
||||
@@ -1145,16 +1145,22 @@ Windmill Community Edition {GIT_VERSION}
|
||||
let db = db.clone();
|
||||
let h = tokio::spawn(async move {
|
||||
// Initialize last_event_id to current max to avoid processing old events on startup
|
||||
let mut last_event_id: i64 = match windmill_common::notify_events::get_latest_event_id(&db).await {
|
||||
Ok(id) => {
|
||||
tracing::info!("Initialized notify event polling with last_event_id: {}", id);
|
||||
id
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Could not get latest event id, starting from 0: {e:#}");
|
||||
0
|
||||
}
|
||||
};
|
||||
let mut last_event_id: i64 =
|
||||
match windmill_common::notify_events::get_latest_event_id(&db).await {
|
||||
Ok(id) => {
|
||||
tracing::info!(
|
||||
"Initialized notify event polling with last_event_id: {}",
|
||||
id
|
||||
);
|
||||
id
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Could not get latest event id, starting from 0: {e:#}"
|
||||
);
|
||||
0
|
||||
}
|
||||
};
|
||||
let mut last_settings_reload = Instant::now();
|
||||
let mut monitor_iteration: u64 = 0;
|
||||
let rd_shift: u8 = rand::rng().random_range(0..200);
|
||||
@@ -1382,36 +1388,57 @@ async fn process_notify_event(
|
||||
tx: &KillpillSender,
|
||||
server_mode: bool,
|
||||
worker_mode: bool,
|
||||
#[cfg(feature = "parquet")]
|
||||
disable_s3_store: bool,
|
||||
#[cfg(feature = "parquet")] disable_s3_store: bool,
|
||||
) {
|
||||
match channel {
|
||||
"notify_config_change" => {
|
||||
if payload == "server" && server_mode {
|
||||
tracing::error!("Server config change detected but server config is obsolete: {}", payload);
|
||||
tracing::error!(
|
||||
"Server config change detected but server config is obsolete: {}",
|
||||
payload
|
||||
);
|
||||
} else if worker_mode && payload == format!("worker__{}", *WORKER_GROUP) {
|
||||
tracing::info!("Worker config change detected: {}", payload);
|
||||
reload_worker_config(db, tx.clone(), true).await;
|
||||
} else {
|
||||
tracing::debug!("config changed but did not target this server/worker");
|
||||
}
|
||||
},
|
||||
}
|
||||
"notify_webhook_change" => {
|
||||
tracing::info!("Webhook change detected, invalidating webhook cache: {}", payload);
|
||||
tracing::info!(
|
||||
"Webhook change detected, invalidating webhook cache: {}",
|
||||
payload
|
||||
);
|
||||
windmill_api::webhook_util::WEBHOOK_CACHE.remove(payload);
|
||||
},
|
||||
}
|
||||
"notify_workspace_envs_change" => {
|
||||
tracing::info!("Workspace envs change detected, invalidating workspace envs cache: {}", payload);
|
||||
tracing::info!(
|
||||
"Workspace envs change detected, invalidating workspace envs cache: {}",
|
||||
payload
|
||||
);
|
||||
windmill_common::variables::CUSTOM_ENVS_CACHE.remove(payload);
|
||||
},
|
||||
}
|
||||
"notify_workspace_key_change" => {
|
||||
tracing::info!("Workspace key change detected, invalidating workspace key cache: {}", payload);
|
||||
tracing::info!(
|
||||
"Workspace key change detected, invalidating workspace key cache: {}",
|
||||
payload
|
||||
);
|
||||
windmill_common::variables::WORKSPACE_CRYPT_CACHE.remove(payload);
|
||||
},
|
||||
}
|
||||
"notify_workspace_premium_change" => {
|
||||
tracing::info!("Workspace premium change detected, invalidating workspace premium cache: {}", payload);
|
||||
tracing::info!(
|
||||
"Workspace premium change detected, invalidating workspace premium cache: {}",
|
||||
payload
|
||||
);
|
||||
windmill_common::workspaces::TEAM_PLAN_CACHE.remove(payload);
|
||||
},
|
||||
}
|
||||
"notify_workspace_rate_limit_change" => {
|
||||
tracing::info!(
|
||||
"Workspace rate limit change detected, invalidating rate limit cache: {}",
|
||||
payload
|
||||
);
|
||||
windmill_common::workspaces::PUBLIC_APP_RATE_LIMIT_CACHE.remove(payload);
|
||||
}
|
||||
"notify_runnable_version_change" => {
|
||||
tracing::info!("Runnable version change detected: {}", payload);
|
||||
match payload.split(':').collect::<Vec<&str>>().as_slice() {
|
||||
@@ -1446,39 +1473,48 @@ async fn process_notify_event(
|
||||
}
|
||||
}
|
||||
"flow" => {
|
||||
let dynamic_input_key = windmill_common::jobs::generate_dynamic_input_key(workspace_id, path);
|
||||
let dynamic_input_key =
|
||||
windmill_common::jobs::generate_dynamic_input_key(
|
||||
workspace_id,
|
||||
path,
|
||||
);
|
||||
windmill_common::DYNAMIC_INPUT_CACHE.remove(&dynamic_input_key);
|
||||
windmill_common::FLOW_VERSION_CACHE.remove(&key);
|
||||
},
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!("Unknown runnable version change payload: {}", payload);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!("Unknown runnable version change payload: {}", payload);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
#[cfg(feature = "http_trigger")]
|
||||
"notify_http_trigger_change" => {
|
||||
tracing::info!("HTTP trigger change detected: {}", payload);
|
||||
match windmill_api::triggers::http::refresh_routers(db).await {
|
||||
Ok((true, _)) => {
|
||||
tracing::info!("Refreshed HTTP routers (trigger change)");
|
||||
},
|
||||
}
|
||||
Ok((false, _)) => {
|
||||
tracing::warn!("Should have refreshed HTTP routers (trigger change) but did not");
|
||||
},
|
||||
tracing::warn!(
|
||||
"Should have refreshed HTTP routers (trigger change) but did not"
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("Error refreshing HTTP routers (trigger change): {err:#}");
|
||||
}
|
||||
};
|
||||
},
|
||||
}
|
||||
"notify_token_invalidation" => {
|
||||
tracing::info!("Token invalidation detected for token: {}...", payload.get(..8).unwrap_or(payload));
|
||||
tracing::info!(
|
||||
"Token invalidation detected for token: {}...",
|
||||
payload.get(..8).unwrap_or(payload)
|
||||
);
|
||||
windmill_api::auth::invalidate_token_from_cache(payload);
|
||||
},
|
||||
}
|
||||
"notify_global_setting_change" => {
|
||||
tracing::info!("Global setting change detected: {}", payload);
|
||||
match payload {
|
||||
@@ -1486,176 +1522,150 @@ async fn process_notify_event(
|
||||
if let Err(e) = reload_base_url_setting(conn).await {
|
||||
tracing::error!(error = %e, "Could not reload base url setting");
|
||||
}
|
||||
},
|
||||
}
|
||||
OAUTH_SETTING => {
|
||||
if let Err(e) = reload_base_url_setting(conn).await {
|
||||
tracing::error!(error = %e, "Could not reload oauth setting");
|
||||
}
|
||||
},
|
||||
}
|
||||
CUSTOM_TAGS_SETTING => {
|
||||
if let Err(e) = reload_custom_tags_setting(db).await {
|
||||
tracing::error!(error = %e, "Could not reload custom tags setting");
|
||||
}
|
||||
},
|
||||
}
|
||||
LICENSE_KEY_SETTING => {
|
||||
if let Err(e) = reload_license_key(&db.into()).await {
|
||||
tracing::error!("Failed to reload license key: {e:#}");
|
||||
}
|
||||
},
|
||||
}
|
||||
DEFAULT_TAGS_PER_WORKSPACE_SETTING => {
|
||||
if let Err(e) = load_tag_per_workspace_enabled(db).await {
|
||||
tracing::error!("Error loading default tag per workspace: {e:#}");
|
||||
}
|
||||
},
|
||||
}
|
||||
DEFAULT_TAGS_WORKSPACES_SETTING => {
|
||||
if let Err(e) = load_tag_per_workspace_workspaces(db).await {
|
||||
tracing::error!("Error loading default tag per workspace workspaces: {e:#}");
|
||||
tracing::error!(
|
||||
"Error loading default tag per workspace workspaces: {e:#}"
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
SMTP_SETTING => {
|
||||
reload_smtp_config(db).await;
|
||||
},
|
||||
}
|
||||
TEAMS_SETTING => {
|
||||
tracing::info!("Teams setting changed.");
|
||||
},
|
||||
}
|
||||
INDEXER_SETTING => {
|
||||
reload_indexer_config(db).await;
|
||||
},
|
||||
TIMEOUT_WAIT_RESULT_SETTING => {
|
||||
reload_timeout_wait_result_setting(conn).await
|
||||
},
|
||||
RETENTION_PERIOD_SECS_SETTING => {
|
||||
reload_retention_period_setting(conn).await
|
||||
},
|
||||
}
|
||||
TIMEOUT_WAIT_RESULT_SETTING => reload_timeout_wait_result_setting(conn).await,
|
||||
RETENTION_PERIOD_SECS_SETTING => reload_retention_period_setting(conn).await,
|
||||
MONITOR_LOGS_ON_OBJECT_STORE_SETTING => {
|
||||
reload_delete_logs_periodically_setting(conn).await
|
||||
},
|
||||
JOB_DEFAULT_TIMEOUT_SECS_SETTING => {
|
||||
reload_job_default_timeout_setting(conn).await
|
||||
},
|
||||
}
|
||||
JOB_DEFAULT_TIMEOUT_SECS_SETTING => reload_job_default_timeout_setting(conn).await,
|
||||
#[cfg(feature = "parquet")]
|
||||
OBJECT_STORE_CONFIG_SETTING => {
|
||||
if !disable_s3_store {
|
||||
reload_object_store_setting(db).await;
|
||||
}
|
||||
},
|
||||
SCIM_TOKEN_SETTING => {
|
||||
reload_scim_token_setting(conn).await
|
||||
},
|
||||
EXTRA_PIP_INDEX_URL_SETTING => {
|
||||
reload_extra_pip_index_url_setting(conn).await
|
||||
},
|
||||
PIP_INDEX_URL_SETTING => {
|
||||
reload_pip_index_url_setting(conn).await
|
||||
},
|
||||
}
|
||||
SCIM_TOKEN_SETTING => reload_scim_token_setting(conn).await,
|
||||
EXTRA_PIP_INDEX_URL_SETTING => reload_extra_pip_index_url_setting(conn).await,
|
||||
PIP_INDEX_URL_SETTING => reload_pip_index_url_setting(conn).await,
|
||||
INSTANCE_PYTHON_VERSION_SETTING => {
|
||||
reload_instance_python_version_setting(conn).await
|
||||
},
|
||||
NPM_CONFIG_REGISTRY_SETTING => {
|
||||
reload_npm_config_registry_setting(conn).await
|
||||
},
|
||||
BUNFIG_INSTALL_SCOPES_SETTING => {
|
||||
reload_bunfig_install_scopes_setting(conn).await
|
||||
},
|
||||
NUGET_CONFIG_SETTING => {
|
||||
reload_nuget_config_setting(conn).await
|
||||
},
|
||||
POWERSHELL_REPO_URL_SETTING => {
|
||||
reload_powershell_repo_url_setting(conn).await
|
||||
},
|
||||
POWERSHELL_REPO_PAT_SETTING => {
|
||||
reload_powershell_repo_pat_setting(conn).await
|
||||
},
|
||||
MAVEN_REPOS_SETTING => {
|
||||
reload_maven_repos_setting(conn).await
|
||||
},
|
||||
NO_DEFAULT_MAVEN_SETTING => {
|
||||
reload_no_default_maven_setting(conn).await
|
||||
},
|
||||
RUBY_REPOS_SETTING => {
|
||||
reload_ruby_repos_setting(conn).await
|
||||
},
|
||||
HUB_API_SECRET_SETTING => {
|
||||
reload_hub_api_secret_setting(conn).await
|
||||
},
|
||||
}
|
||||
NPM_CONFIG_REGISTRY_SETTING => reload_npm_config_registry_setting(conn).await,
|
||||
BUNFIG_INSTALL_SCOPES_SETTING => reload_bunfig_install_scopes_setting(conn).await,
|
||||
NUGET_CONFIG_SETTING => reload_nuget_config_setting(conn).await,
|
||||
POWERSHELL_REPO_URL_SETTING => reload_powershell_repo_url_setting(conn).await,
|
||||
POWERSHELL_REPO_PAT_SETTING => reload_powershell_repo_pat_setting(conn).await,
|
||||
MAVEN_REPOS_SETTING => reload_maven_repos_setting(conn).await,
|
||||
NO_DEFAULT_MAVEN_SETTING => reload_no_default_maven_setting(conn).await,
|
||||
RUBY_REPOS_SETTING => reload_ruby_repos_setting(conn).await,
|
||||
HUB_API_SECRET_SETTING => reload_hub_api_secret_setting(conn).await,
|
||||
KEEP_JOB_DIR_SETTING => {
|
||||
load_keep_job_dir(conn).await;
|
||||
},
|
||||
}
|
||||
OTEL_TRACING_PROXY_SETTING => {
|
||||
reload_otel_tracing_proxy_setting(conn).await;
|
||||
if worker_mode {
|
||||
tracing::info!("OTEL tracing proxy setting changed, restarting worker");
|
||||
send_delayed_killpill(tx, 4, "OTEL tracing proxy setting change").await;
|
||||
}
|
||||
},
|
||||
}
|
||||
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING => {
|
||||
load_require_preexisting_user(db).await;
|
||||
},
|
||||
}
|
||||
EXPOSE_METRICS_SETTING => {
|
||||
tracing::info!("Metrics setting changed, restarting");
|
||||
send_delayed_killpill(tx, 40, "metrics setting change").await;
|
||||
},
|
||||
}
|
||||
EMAIL_DOMAIN_SETTING => {
|
||||
tracing::info!("Email domain setting changed");
|
||||
if server_mode {
|
||||
send_delayed_killpill(tx, 4, "email domain setting change").await;
|
||||
}
|
||||
},
|
||||
}
|
||||
EXPOSE_DEBUG_METRICS_SETTING => {
|
||||
if let Err(e) = load_metrics_debug_enabled(conn).await {
|
||||
tracing::error!(error = %e, "Could not reload debug metrics setting");
|
||||
}
|
||||
},
|
||||
}
|
||||
APP_WORKSPACED_ROUTE_SETTING => {
|
||||
if let Err(e) = reload_app_workspaced_route_setting(db).await {
|
||||
tracing::error!(error = %e, "Could not reload app workspaced route setting");
|
||||
}
|
||||
},
|
||||
}
|
||||
OTEL_SETTING => {
|
||||
tracing::info!("OTEL setting changed, restarting");
|
||||
send_delayed_killpill(tx, 4, "OTEL setting change").await;
|
||||
},
|
||||
}
|
||||
REQUEST_SIZE_LIMIT_SETTING => {
|
||||
if server_mode {
|
||||
tracing::info!("Request limit size change detected, killing server expecting to be restarted");
|
||||
send_delayed_killpill(tx, 4, "request size limit change").await;
|
||||
}
|
||||
},
|
||||
}
|
||||
SAML_METADATA_SETTING => {
|
||||
tracing::info!("SAML metadata change detected, killing server expecting to be restarted");
|
||||
tracing::info!(
|
||||
"SAML metadata change detected, killing server expecting to be restarted"
|
||||
);
|
||||
send_delayed_killpill(tx, 0, "SAML metadata change").await;
|
||||
},
|
||||
}
|
||||
HUB_BASE_URL_SETTING => {
|
||||
if let Err(e) = reload_hub_base_url_setting(conn, server_mode).await {
|
||||
tracing::error!(error = %e, "Could not reload hub base url setting");
|
||||
}
|
||||
},
|
||||
}
|
||||
CRITICAL_ERROR_CHANNELS_SETTING => {
|
||||
if let Err(e) = reload_critical_error_channels_setting(db).await {
|
||||
tracing::error!(error = %e, "Could not reload critical error emails setting");
|
||||
}
|
||||
},
|
||||
}
|
||||
CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING => {
|
||||
if let Err(e) = reload_critical_alerts_on_db_oversize(db).await {
|
||||
tracing::error!(error = %e, "Could not reload critical alerts on db oversize setting");
|
||||
}
|
||||
},
|
||||
}
|
||||
JWT_SECRET_SETTING => {
|
||||
if let Err(e) = reload_jwt_secret_setting(db).await {
|
||||
tracing::error!(error = %e, "Could not reload jwt secret setting");
|
||||
}
|
||||
},
|
||||
}
|
||||
CRITICAL_ALERT_MUTE_UI_SETTING => {
|
||||
tracing::info!("Critical alert UI setting changed");
|
||||
if let Err(e) = reload_critical_alert_mute_ui_setting(conn).await {
|
||||
tracing::error!(error = %e, "Could not reload critical alert UI setting");
|
||||
}
|
||||
},
|
||||
}
|
||||
_ => {
|
||||
tracing::info!("Unrecognized Global Setting Change Payload: {:?}", payload);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!("Unknown notification channel: {}", channel);
|
||||
}
|
||||
|
||||
@@ -11,13 +11,13 @@ path = "src/lib.rs"
|
||||
[features]
|
||||
default = []
|
||||
private = ["windmill-audit/private", "windmill-common/private"]
|
||||
enterprise = ["windmill-queue/enterprise", "windmill-audit/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "windmill-worker/enterprise", "dep:windmill-autoscaling"]
|
||||
enterprise = ["windmill-queue/enterprise", "windmill-audit/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "windmill-worker/enterprise"]
|
||||
stripe = []
|
||||
agent_worker_server = ["dep:windmill-parser-py-imports"]
|
||||
agent_worker_server = []
|
||||
enterprise_saml = ["dep:samael", "dep:libxml"]
|
||||
benchmark = []
|
||||
embedding = ["dep:tinyvector", "dep:hf-hub", "dep:tokenizers", "dep:candle-core", "dep:candle-transformers", "dep:candle-nn"]
|
||||
parquet = ["dep:datafusion", "dep:object_store", "windmill-common/parquet", "windmill-worker/parquet", "dep:aws-sigv4", "dep:aws-sdk-config"]
|
||||
parquet = ["dep:datafusion", "dep:object_store", "windmill-common/parquet", "windmill-worker/parquet"]
|
||||
prometheus = ["windmill-common/prometheus", "windmill-queue/prometheus", "dep:prometheus", "windmill-worker/prometheus"]
|
||||
openidconnect = ["dep:openidconnect", "windmill-common/openidconnect"]
|
||||
tantivy = ["dep:windmill-indexer"]
|
||||
@@ -38,8 +38,8 @@ deno_core = ["dep:deno_core", "dep:deno_error"]
|
||||
gcp_trigger = ["dep:thiserror", "dep:google-cloud-pubsub", "dep:google-cloud-googleapis", "dep:tonic"]
|
||||
cloud = ["windmill-common/cloud"]
|
||||
mcp = ["dep:windmill-mcp", "windmill-mcp/server", "windmill-mcp/auth"]
|
||||
bedrock = ["dep:aws-sdk-bedrock", "dep:aws-sdk-bedrockruntime", "windmill-common/bedrock", "dep:aws-config", "dep:aws-sdk-config", "dep:aws-credential-types", "dep:aws-smithy-types"]
|
||||
python = ["dep:windmill-parser-py-imports"]
|
||||
bedrock = ["dep:aws-sdk-bedrock", "dep:aws-sdk-bedrockruntime", "windmill-common/bedrock", "dep:aws-config"]
|
||||
python = []
|
||||
|
||||
[dependencies]
|
||||
windmill-mcp = { workspace = true, optional = true }
|
||||
@@ -50,10 +50,10 @@ windmill-parser.workspace = true
|
||||
windmill-parser-sql.workspace = true
|
||||
windmill-parser-ts.workspace = true
|
||||
windmill-parser-py.workspace = true
|
||||
windmill-parser-py-imports = { workspace = true, optional = true }
|
||||
windmill-parser-py-imports.workspace = true
|
||||
windmill-git-sync.workspace = true
|
||||
windmill-indexer = { workspace = true, optional = true }
|
||||
windmill-autoscaling = { workspace = true, optional = true }
|
||||
windmill-autoscaling.workspace = true
|
||||
windmill-worker.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
@@ -152,13 +152,13 @@ aws-sdk-ssooidc = { workspace = true, optional = true }
|
||||
aws-sdk-sts = { workspace = true, optional = true }
|
||||
rustls = { workspace = true }
|
||||
|
||||
aws-sigv4 = { workspace = true, optional = true }
|
||||
aws-sdk-config = { workspace = true, optional = true }
|
||||
aws-sigv4.workspace = true
|
||||
aws-sdk-config.workspace = true
|
||||
aws-config = { workspace = true, optional = true }
|
||||
aws-credential-types = { workspace = true, optional = true }
|
||||
aws-credential-types.workspace = true
|
||||
aws-sdk-bedrock = { workspace = true, optional = true }
|
||||
aws-sdk-bedrockruntime = { workspace = true, optional = true }
|
||||
aws-smithy-types = { workspace = true, optional = true }
|
||||
aws-smithy-types.workspace = true
|
||||
async-trait.workspace = true
|
||||
google-cloud-pubsub = { workspace = true, optional = true }
|
||||
google-cloud-googleapis = { workspace = true , optional = true }
|
||||
@@ -169,6 +169,7 @@ tar.workspace = true
|
||||
flate2.workspace = true
|
||||
backon = {workspace = true, optional = true}
|
||||
strum = { workspace = true, optional = true }
|
||||
dashmap.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
deno_core = { workspace = true, optional = true }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.624.0
|
||||
version: 1.625.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -2383,6 +2383,9 @@ paths:
|
||||
type: string
|
||||
operator_settings:
|
||||
$ref: "#/components/schemas/OperatorSettings"
|
||||
public_app_execution_limit_per_minute:
|
||||
type: integer
|
||||
description: Rate limit for public app executions per minute per server. NULL or 0 means disabled.
|
||||
|
||||
/w/{workspace}/workspaces/get_deploy_to:
|
||||
get:
|
||||
@@ -4152,6 +4155,35 @@ paths:
|
||||
type: string
|
||||
example: "Updated mute critical alert UI settings for workspace: workspace_id"
|
||||
|
||||
/w/{workspace}/workspaces/public_app_rate_limit:
|
||||
post:
|
||||
summary: Set public app rate limit for this workspace
|
||||
operationId: setPublicAppRateLimit
|
||||
tags:
|
||||
- setting
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
description: Public app rate limit configuration
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
public_app_execution_limit_per_minute:
|
||||
type: integer
|
||||
description: Rate limit for public app executions per minute per server. NULL or 0 to disable.
|
||||
example: 100
|
||||
responses:
|
||||
"200":
|
||||
description: Successfully updated public app rate limit settings.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
example: "Updated public app rate limit for workspace: workspace_id"
|
||||
|
||||
/oauth/login_callback/{client_name}:
|
||||
post:
|
||||
security: []
|
||||
@@ -5403,6 +5435,34 @@ paths:
|
||||
required:
|
||||
- app
|
||||
|
||||
/apps/hub/get_raw/{id}:
|
||||
get:
|
||||
summary: get hub raw app by id
|
||||
operationId: getHubRawAppById
|
||||
tags:
|
||||
- app
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/PathId"
|
||||
responses:
|
||||
"200":
|
||||
description: raw app
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
app:
|
||||
type: object
|
||||
properties:
|
||||
summary:
|
||||
type: string
|
||||
value: {}
|
||||
required:
|
||||
- summary
|
||||
- value
|
||||
required:
|
||||
- app
|
||||
|
||||
/apps_u/public_app_by_custom_path/{custom_path}:
|
||||
get:
|
||||
summary: get public app by custom path
|
||||
@@ -14958,7 +15018,7 @@ paths:
|
||||
type: string
|
||||
favorite_kind:
|
||||
type: string
|
||||
enum: [flow, app, script, raw_app]
|
||||
enum: [flow, app, script, raw_app, asset]
|
||||
responses:
|
||||
"200":
|
||||
description: star item
|
||||
@@ -14981,7 +15041,7 @@ paths:
|
||||
type: string
|
||||
favorite_kind:
|
||||
type: string
|
||||
enum: [flow, app, script, raw_app]
|
||||
enum: [flow, app, script, raw_app, asset]
|
||||
responses:
|
||||
"200":
|
||||
description: unstar item
|
||||
@@ -16526,6 +16586,11 @@ paths:
|
||||
$ref: "#/components/schemas/AssetUsageKind"
|
||||
access_type:
|
||||
$ref: "#/components/schemas/AssetUsageAccessType"
|
||||
columns:
|
||||
type: object
|
||||
description: The columns used (for tables)
|
||||
additionalProperties:
|
||||
$ref: "#/components/schemas/AssetUsageAccessType"
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -16606,6 +16671,30 @@ paths:
|
||||
access_type:
|
||||
$ref: "#/components/schemas/AssetUsageAccessType"
|
||||
|
||||
/w/{workspace}/assets/list_favorites:
|
||||
get:
|
||||
summary: List all favorite assets for the authenticated user
|
||||
operationId: listFavoriteAssets
|
||||
tags:
|
||||
- asset
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
responses:
|
||||
"200":
|
||||
description: list of favorite assets
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [path]
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
description: The asset path
|
||||
|
||||
|
||||
/mcp/w/{workspace}/list_tools:
|
||||
get:
|
||||
summary: list available MCP tools
|
||||
@@ -20710,6 +20799,17 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
filters:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
value: {}
|
||||
required:
|
||||
- key
|
||||
- value
|
||||
server_id:
|
||||
type: string
|
||||
last_server_ping:
|
||||
@@ -20728,6 +20828,7 @@ components:
|
||||
- kafka_resource_path
|
||||
- group_id
|
||||
- topics
|
||||
- filters
|
||||
|
||||
NewKafkaTrigger:
|
||||
type: object
|
||||
@@ -20746,6 +20847,17 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
filters:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
value: {}
|
||||
required:
|
||||
- key
|
||||
- value
|
||||
mode:
|
||||
$ref: "#/components/schemas/TriggerMode"
|
||||
error_handler_path:
|
||||
@@ -20762,6 +20874,7 @@ components:
|
||||
- kafka_resource_path
|
||||
- group_id
|
||||
- topics
|
||||
- filters
|
||||
|
||||
EditKafkaTrigger:
|
||||
type: object
|
||||
@@ -20774,6 +20887,17 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
filters:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
value: {}
|
||||
required:
|
||||
- key
|
||||
- value
|
||||
path:
|
||||
type: string
|
||||
script_path:
|
||||
@@ -20793,6 +20917,7 @@ components:
|
||||
- kafka_resource_path
|
||||
- group_id
|
||||
- topics
|
||||
- filters
|
||||
- is_flow
|
||||
|
||||
NatsTrigger:
|
||||
|
||||
@@ -126,6 +126,7 @@ pub fn global_service() -> Router {
|
||||
Router::new()
|
||||
.route("/hub/list", get(list_hub_apps))
|
||||
.route("/hub/get/:id", get(get_hub_app_by_id))
|
||||
.route("/hub/get_raw/:id", get(get_hub_raw_app_by_id))
|
||||
}
|
||||
|
||||
#[derive(FromRow, Deserialize, Serialize)]
|
||||
@@ -1312,6 +1313,24 @@ pub async fn get_hub_app_by_id(
|
||||
Ok(Json(value))
|
||||
}
|
||||
|
||||
pub async fn get_hub_raw_app_by_id(
|
||||
Path(id): Path<i32>,
|
||||
Extension(db): Extension<DB>,
|
||||
) -> JsonResult<Box<serde_json::value::RawValue>> {
|
||||
let value = http_get_from_hub(
|
||||
&HTTP_CLIENT,
|
||||
&format!("{}/raw_apps/{}/json", *HUB_BASE_URL.read().await, id),
|
||||
false,
|
||||
None,
|
||||
Some(&db),
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.map_err(to_anyhow)?;
|
||||
Ok(Json(value))
|
||||
}
|
||||
|
||||
async fn delete_app(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -1910,6 +1929,15 @@ async fn execute_component(
|
||||
}
|
||||
};
|
||||
|
||||
// Check rate limit for anonymous (public) executions
|
||||
if matches!(policy.execution_mode, ExecutionMode::Anonymous) && opt_authed.is_none() {
|
||||
if let Some(limit) = crate::workspaces::get_public_app_rate_limit(&db, &w_id).await? {
|
||||
if limit > 0 {
|
||||
crate::public_app_rate_limit::check_and_increment(&w_id, limit)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execution is publisher and an user is authenticated: check if the user is authorized to
|
||||
// execute the app.
|
||||
if let (ExecutionMode::Publisher, Some(authed)) = (policy.execution_mode, opt_authed.as_ref()) {
|
||||
|
||||
@@ -18,6 +18,7 @@ pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/list", get(list_assets))
|
||||
.route("/list_by_usages", post(list_assets_by_usages))
|
||||
.route("/list_favorites", get(list_favorites))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -158,6 +159,7 @@ async fn list_assets(
|
||||
'path', asset.usage_path,
|
||||
'kind', asset.usage_kind,
|
||||
'access_type', asset.usage_access_type,
|
||||
'columns', asset.columns,
|
||||
'created_at', asset.created_at,
|
||||
'metadata', (CASE
|
||||
WHEN asset.usage_kind = 'job' THEN
|
||||
@@ -266,11 +268,12 @@ async fn list_assets_by_usages(
|
||||
for usage in body.usages {
|
||||
let assets = sqlx::query_scalar!(
|
||||
r#"SELECT
|
||||
jsonb_build_object(
|
||||
jsonb_strip_nulls(jsonb_build_object(
|
||||
'path', path,
|
||||
'kind', kind,
|
||||
'access_type', usage_access_type
|
||||
) as "list!: _"
|
||||
'access_type', usage_access_type,
|
||||
'columns', columns
|
||||
)) as "list!: _"
|
||||
FROM asset
|
||||
WHERE workspace_id = $1 AND usage_path = $2 AND usage_kind = $3
|
||||
ORDER BY path, kind"#,
|
||||
@@ -284,3 +287,29 @@ async fn list_assets_by_usages(
|
||||
}
|
||||
Ok(Json(assets_vec))
|
||||
}
|
||||
|
||||
async fn list_favorites(
|
||||
authed: ApiAuthed,
|
||||
Path(w_id): Path<String>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
) -> JsonResult<Vec<Value>> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let favorites = sqlx::query_scalar!(
|
||||
r#"SELECT
|
||||
jsonb_strip_nulls(jsonb_build_object(
|
||||
'path', favorite.path
|
||||
)) as "favorite_asset!: _"
|
||||
FROM favorite
|
||||
WHERE favorite.workspace_id = $1
|
||||
AND favorite.usr = $2
|
||||
AND favorite_kind = 'asset'
|
||||
"#,
|
||||
&w_id,
|
||||
&authed.username
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
Ok(Json(favorites))
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ pub enum FavoriteKind {
|
||||
App,
|
||||
#[allow(non_camel_case_types)]
|
||||
Raw_App,
|
||||
Asset,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct Favorite {
|
||||
|
||||
@@ -473,12 +473,14 @@ async fn create_flow(
|
||||
workspace_id, path, summary, description,
|
||||
dependency_job, lock_error_logs, draft_only, tag,
|
||||
dedicated_worker, visible_to_runner_only, on_behalf_of_email,
|
||||
ws_error_handler_muted,
|
||||
value, schema, edited_by, edited_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4,
|
||||
NULL, '', $5, $6,
|
||||
$7, $8, $9,
|
||||
$10, $11::text::json, $12, now()
|
||||
$10,
|
||||
$11, $12::text::json, $13, now()
|
||||
)"#,
|
||||
w_id,
|
||||
nf.path,
|
||||
@@ -489,6 +491,7 @@ async fn create_flow(
|
||||
nf.dedicated_worker,
|
||||
nf.visible_to_runner_only.unwrap_or(false),
|
||||
nf.on_behalf_of_email.and(Some(&authed.email)),
|
||||
nf.ws_error_handler_muted.unwrap_or(false),
|
||||
sqlx::types::Json(&nf.value) as _,
|
||||
schema_str,
|
||||
&authed.username,
|
||||
@@ -897,12 +900,13 @@ async fn update_flow(
|
||||
dedicated_worker = $5,
|
||||
visible_to_runner_only = $6,
|
||||
on_behalf_of_email = $7,
|
||||
value = $8,
|
||||
schema = $9::text::json,
|
||||
edited_by = $10,
|
||||
ws_error_handler_muted = $8,
|
||||
value = $9,
|
||||
schema = $10::text::json,
|
||||
edited_by = $11,
|
||||
edited_at = now()
|
||||
WHERE
|
||||
path = $11 AND workspace_id = $12",
|
||||
path = $12 AND workspace_id = $13",
|
||||
if is_new_path { flow_path } else { &nf.path },
|
||||
nf.summary,
|
||||
nf.description.as_deref().unwrap_or(""),
|
||||
@@ -910,6 +914,7 @@ async fn update_flow(
|
||||
nf.dedicated_worker,
|
||||
nf.visible_to_runner_only.unwrap_or(false),
|
||||
nf.on_behalf_of_email.and(Some(&authed.email)),
|
||||
nf.ws_error_handler_muted.unwrap_or(false),
|
||||
sqlx::types::Json(&nf.value) as _,
|
||||
schema_str,
|
||||
authed.username,
|
||||
|
||||
@@ -6378,10 +6378,18 @@ fn register_potential_assets_on_inline_execution(
|
||||
match assets {
|
||||
Some(Ok(assets)) => {
|
||||
for asset in assets {
|
||||
let columns = asset.columns.as_ref().map(|cols| {
|
||||
cols.iter()
|
||||
.map(|(col_name, col_access_type)| {
|
||||
(col_name.clone(), (*col_access_type).into())
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
register_runtime_asset(InsertRuntimeAssetParams {
|
||||
access_type: asset.access_type.map(|a| a.into()),
|
||||
asset_kind: asset.kind.into(),
|
||||
asset_path: asset.path,
|
||||
columns,
|
||||
job_id,
|
||||
workspace_id: w_id.to_string(),
|
||||
created_at: None,
|
||||
|
||||
@@ -167,6 +167,7 @@ mod teams_approvals_oss;
|
||||
#[cfg(feature = "native_trigger")]
|
||||
pub mod native_triggers;
|
||||
mod public_app_layer;
|
||||
mod public_app_rate_limit;
|
||||
mod static_assets;
|
||||
#[cfg(all(feature = "stripe", feature = "enterprise", feature = "private"))]
|
||||
pub mod stripe_ee;
|
||||
|
||||
48
backend/windmill-api/src/public_app_rate_limit.rs
Normal file
48
backend/windmill-api/src/public_app_rate_limit.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Author: Windmill Labs, Inc
|
||||
* Copyright: Windmill Labs, Inc 2024
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use chrono::Utc;
|
||||
use dashmap::DashMap;
|
||||
use hyper::StatusCode;
|
||||
use std::sync::LazyLock;
|
||||
use windmill_common::error::{Error, Result};
|
||||
|
||||
struct RateLimitEntry {
|
||||
count: i32,
|
||||
minute_bucket: i64,
|
||||
}
|
||||
|
||||
static RATE_LIMIT_COUNTER: LazyLock<DashMap<String, RateLimitEntry>> =
|
||||
LazyLock::new(DashMap::new);
|
||||
|
||||
pub fn check_and_increment(workspace_id: &str, limit: i32) -> Result<()> {
|
||||
let current_minute = Utc::now().timestamp() / 60;
|
||||
|
||||
let mut entry = RATE_LIMIT_COUNTER
|
||||
.entry(workspace_id.to_string())
|
||||
.or_insert(RateLimitEntry { count: 0, minute_bucket: current_minute });
|
||||
|
||||
if entry.minute_bucket != current_minute {
|
||||
entry.count = 0;
|
||||
entry.minute_bucket = current_minute;
|
||||
}
|
||||
|
||||
if entry.count >= limit {
|
||||
return Err(Error::Generic(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
format!(
|
||||
"Rate limit exceeded for public app executions in workspace '{}'. \
|
||||
Limit: {} per minute per server.",
|
||||
workspace_id, limit
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
entry.count += 1;
|
||||
Ok(())
|
||||
}
|
||||
144
backend/windmill-api/src/triggers/filter.rs
Normal file
144
backend/windmill-api/src/triggers/filter.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
use serde::{
|
||||
de::{self, MapAccess, Visitor},
|
||||
Deserialize, Deserializer,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct JsonFilter {
|
||||
pub key: String,
|
||||
pub value: Value,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Filter {
|
||||
JsonFilter(JsonFilter),
|
||||
}
|
||||
|
||||
struct SupersetVisitor<'a> {
|
||||
key: &'a str,
|
||||
value_to_check: &'a Value,
|
||||
}
|
||||
|
||||
impl<'de, 'a> Visitor<'de> for SupersetVisitor<'a> {
|
||||
type Value = bool;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("a JSON object with a specific key at the top level")
|
||||
}
|
||||
|
||||
fn visit_map<V>(self, mut map: V) -> std::result::Result<Self::Value, V::Error>
|
||||
where
|
||||
V: MapAccess<'de>,
|
||||
{
|
||||
let mut result = false;
|
||||
let mut found = false;
|
||||
|
||||
// Must consume entire map to satisfy deserializer contract
|
||||
while let Some(key) = map.next_key::<String>()? {
|
||||
if !found && key == self.key {
|
||||
let json_value: Value = map.next_value()?;
|
||||
result = is_superset(&json_value, self.value_to_check);
|
||||
found = true;
|
||||
} else {
|
||||
// Skip values we don't need (cheaper than full deserialization)
|
||||
let _ = map.next_value::<de::IgnoredAny>()?;
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_superset(json_value: &Value, value_to_check: &Value) -> bool {
|
||||
match (json_value, value_to_check) {
|
||||
(Value::Object(json_map), Value::Object(check_map)) => {
|
||||
check_map.iter().all(|(k, v)| {
|
||||
json_map
|
||||
.get(k)
|
||||
.map_or(false, |json_val| is_superset(json_val, v))
|
||||
})
|
||||
}
|
||||
(Value::Array(json_array), Value::Array(check_array)) => {
|
||||
check_array.iter().all(|check_item| {
|
||||
json_array
|
||||
.iter()
|
||||
.any(|json_item| is_superset(json_item, check_item))
|
||||
})
|
||||
}
|
||||
_ => json_value == value_to_check,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_value_superset<'a, 'de, D>(
|
||||
deserializer: D,
|
||||
key: &'a str,
|
||||
value_to_check: &'a Value,
|
||||
) -> std::result::Result<bool, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
deserializer.deserialize_map(SupersetVisitor { key, value_to_check })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_filter_with_other_top_level_keys() {
|
||||
let payload = r#"{"event_type": "test", "other": "data"}"#;
|
||||
let key = "event_type";
|
||||
let value = json!("test");
|
||||
|
||||
let mut deserializer = serde_json::Deserializer::from_str(payload);
|
||||
let result = is_value_superset(&mut deserializer, key, &value).unwrap();
|
||||
assert!(result, "Should match when key exists with correct value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_with_key_not_first() {
|
||||
let payload = r#"{"other": "data", "event_type": "test"}"#;
|
||||
let key = "event_type";
|
||||
let value = json!("test");
|
||||
|
||||
let mut deserializer = serde_json::Deserializer::from_str(payload);
|
||||
let result = is_value_superset(&mut deserializer, key, &value).unwrap();
|
||||
assert!(result, "Should match even when key is not first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_with_nested_object() {
|
||||
let payload = r#"{"data": {"status": "active", "count": 5}, "other": "value"}"#;
|
||||
let key = "data";
|
||||
let value = json!({"status": "active"});
|
||||
|
||||
let mut deserializer = serde_json::Deserializer::from_str(payload);
|
||||
let result = is_value_superset(&mut deserializer, key, &value).unwrap();
|
||||
assert!(result, "Should match when nested object is superset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_no_match() {
|
||||
let payload = r#"{"event_type": "other", "data": "value"}"#;
|
||||
let key = "event_type";
|
||||
let value = json!("test");
|
||||
|
||||
let mut deserializer = serde_json::Deserializer::from_str(payload);
|
||||
let result = is_value_superset(&mut deserializer, key, &value).unwrap();
|
||||
assert!(!result, "Should not match when value differs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_key_not_found() {
|
||||
let payload = r#"{"other": "data"}"#;
|
||||
let key = "event_type";
|
||||
let value = json!("test");
|
||||
|
||||
let mut deserializer = serde_json::Deserializer::from_str(payload);
|
||||
let result = is_value_superset(&mut deserializer, key, &value).unwrap();
|
||||
assert!(!result, "Should not match when key doesn't exist");
|
||||
}
|
||||
}
|
||||
@@ -963,7 +963,13 @@ async fn route_job(
|
||||
|
||||
let s3_object = s3_object.map_err(|err| {
|
||||
tracing::warn!("Error retrieving file from S3: {:?}", err);
|
||||
Error::internal_err(format!("Error retrieving file: {}", err.to_string()))
|
||||
let mut msg = format!("Error retrieving file: {err}");
|
||||
let mut source = std::error::Error::source(&err);
|
||||
while let Some(e) = source {
|
||||
msg.push_str(&format!("\n caused by: {e}"));
|
||||
source = e.source();
|
||||
}
|
||||
Error::internal_err(msg)
|
||||
})?;
|
||||
|
||||
let mut response_headers = http::HeaderMap::new();
|
||||
|
||||
@@ -30,6 +30,7 @@ pub mod sqs;
|
||||
#[cfg(feature = "websocket")]
|
||||
pub mod websocket;
|
||||
|
||||
pub mod filter;
|
||||
pub mod global_handler;
|
||||
mod handler;
|
||||
mod listener;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::WebsocketTrigger;
|
||||
use crate::triggers::{
|
||||
filter::{is_value_superset, Filter, JsonFilter},
|
||||
listener::ListeningTrigger,
|
||||
trigger_helpers::{
|
||||
trigger_runnable, trigger_runnable_and_wait_for_raw_result,
|
||||
@@ -13,12 +14,9 @@ use async_trait::async_trait;
|
||||
use futures::{stream::SplitSink, SinkExt, StreamExt};
|
||||
use http::Response;
|
||||
use itertools::Itertools;
|
||||
use serde::{
|
||||
de::{self, MapAccess, Visitor},
|
||||
Deserialize, Deserializer,
|
||||
};
|
||||
use serde_json::{value::RawValue, Value};
|
||||
use std::{borrow::Cow, collections::HashMap, fmt, sync::Arc};
|
||||
use serde::Deserialize;
|
||||
use serde_json::value::RawValue;
|
||||
use std::{borrow::Cow, collections::HashMap, sync::Arc};
|
||||
use tokio::{net::TcpStream, sync::RwLock};
|
||||
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
|
||||
use windmill_common::{
|
||||
@@ -443,18 +441,6 @@ impl Listener for WebsocketTrigger {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct JsonFilter {
|
||||
key: String,
|
||||
value: Value,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Filter {
|
||||
JsonFilter(JsonFilter),
|
||||
}
|
||||
|
||||
pub struct ReturnMessageChannels {
|
||||
send_message_tx: tokio::sync::mpsc::Sender<String>,
|
||||
killpill_rx: tokio::sync::broadcast::Receiver<()>,
|
||||
@@ -477,71 +463,6 @@ enum InitialMessage {
|
||||
RunnableResult { path: String, args: Box<RawValue>, is_flow: bool },
|
||||
}
|
||||
|
||||
struct SupersetVisitor<'a> {
|
||||
key: &'a str,
|
||||
value_to_check: &'a Value,
|
||||
}
|
||||
|
||||
impl<'de, 'a> Visitor<'de> for SupersetVisitor<'a> {
|
||||
type Value = bool;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("a JSON object with a specific key at the top level")
|
||||
}
|
||||
|
||||
fn visit_map<V>(self, mut map: V) -> std::result::Result<Self::Value, V::Error>
|
||||
where
|
||||
V: MapAccess<'de>,
|
||||
{
|
||||
while let Some(key) = map.next_key::<String>()? {
|
||||
if key == self.key {
|
||||
// Deserialize the value for the key and check if it's a superset
|
||||
let json_value: Value = map.next_value()?;
|
||||
return Ok(is_superset(&json_value, self.value_to_check));
|
||||
} else {
|
||||
// Skip the value if it's not the one we're interested in
|
||||
let _ = map.next_value::<de::IgnoredAny>()?;
|
||||
}
|
||||
}
|
||||
// If the key was not found, return false
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_superset(json_value: &Value, value_to_check: &Value) -> bool {
|
||||
match (json_value, value_to_check) {
|
||||
(Value::Object(json_map), Value::Object(check_map)) => {
|
||||
// Check that all keys and values in check_map exist and match in json_map
|
||||
check_map.iter().all(|(k, v)| {
|
||||
json_map
|
||||
.get(k)
|
||||
.map_or(false, |json_val| is_superset(json_val, v))
|
||||
})
|
||||
}
|
||||
(Value::Array(json_array), Value::Array(check_array)) => {
|
||||
// Check that all elements in check_array exist in json_array
|
||||
check_array.iter().all(|check_item| {
|
||||
json_array
|
||||
.iter()
|
||||
.any(|json_item| is_superset(json_item, check_item))
|
||||
})
|
||||
}
|
||||
_ => json_value == value_to_check,
|
||||
}
|
||||
}
|
||||
|
||||
// A function to deserialize and check if the value at the given key is a superset of a passed value
|
||||
fn is_value_superset<'a, 'de, D>(
|
||||
deserializer: D,
|
||||
key: &'a str,
|
||||
value_to_check: &'a Value,
|
||||
) -> std::result::Result<bool, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
deserializer.deserialize_map(SupersetVisitor { key, value_to_check })
|
||||
}
|
||||
|
||||
fn raw_value_to_args_hashmap(
|
||||
args: Option<&Box<RawValue>>,
|
||||
) -> Result<HashMap<String, Box<RawValue>>> {
|
||||
|
||||
@@ -176,6 +176,7 @@ pub fn workspaced_service() -> Router {
|
||||
post(acknowledge_all_critical_alerts),
|
||||
)
|
||||
.route("/critical_alerts/mute", post(mute_critical_alerts))
|
||||
.route("/public_app_rate_limit", post(edit_public_app_rate_limit))
|
||||
.route("/operator_settings", post(update_operator_settings))
|
||||
.route(
|
||||
"/create_workspace_fork_branch",
|
||||
@@ -287,6 +288,8 @@ pub struct WorkspaceSettings {
|
||||
pub error_handler: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub success_handler: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub public_app_execution_limit_per_minute: Option<i32>,
|
||||
}
|
||||
|
||||
/// #[derive(sqlx::Type, Serialize, Deserialize, Debug)]
|
||||
@@ -625,7 +628,8 @@ async fn get_settings(
|
||||
git_app_installations,
|
||||
auto_invite,
|
||||
error_handler,
|
||||
success_handler
|
||||
success_handler,
|
||||
public_app_execution_limit_per_minute
|
||||
FROM
|
||||
workspace_settings
|
||||
WHERE
|
||||
@@ -4401,6 +4405,72 @@ pub async fn mute_critical_alerts() -> Error {
|
||||
Error::NotFound("Critical Alerts require EE".to_string())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct EditPublicAppRateLimitRequest {
|
||||
pub public_app_execution_limit_per_minute: Option<i32>,
|
||||
}
|
||||
|
||||
async fn edit_public_app_rate_limit(
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
authed: ApiAuthed,
|
||||
Json(req): Json<EditPublicAppRateLimitRequest>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings SET public_app_execution_limit_per_minute = $1 WHERE workspace_id = $2",
|
||||
req.public_app_execution_limit_per_minute,
|
||||
&w_id
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// Cache is invalidated via DB trigger -> notify_event -> polling in main.rs
|
||||
|
||||
handle_deployment_metadata(
|
||||
&authed.email,
|
||||
&authed.username,
|
||||
&db,
|
||||
&w_id,
|
||||
DeployedObject::Settings { setting_type: "public_app_rate_limit".to_string() },
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(format!(
|
||||
"Updated public app rate limit for workspace: {}",
|
||||
&w_id
|
||||
))
|
||||
}
|
||||
|
||||
// 5 minutes fallback TTL (in addition to event-based invalidation)
|
||||
const PUBLIC_APP_RATE_LIMIT_CACHE_TTL_SECS: i64 = 300;
|
||||
|
||||
pub async fn get_public_app_rate_limit(db: &DB, w_id: &str) -> Result<Option<i32>> {
|
||||
use windmill_common::workspaces::PUBLIC_APP_RATE_LIMIT_CACHE;
|
||||
|
||||
let now = Utc::now().timestamp();
|
||||
|
||||
if let Some((rate_limit, cached_at)) = PUBLIC_APP_RATE_LIMIT_CACHE.get(w_id) {
|
||||
if now - cached_at < PUBLIC_APP_RATE_LIMIT_CACHE_TTL_SECS {
|
||||
return Ok(rate_limit);
|
||||
}
|
||||
}
|
||||
|
||||
let result: Option<Option<i32>> = sqlx::query_scalar(
|
||||
"SELECT public_app_execution_limit_per_minute FROM workspace_settings WHERE workspace_id = $1",
|
||||
)
|
||||
.bind(w_id)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
let rate_limit = result.flatten();
|
||||
PUBLIC_APP_RATE_LIMIT_CACHE.insert(w_id.to_string(), (rate_limit, now));
|
||||
Ok(rate_limit)
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct ChangeOperatorSettings {
|
||||
#[serde(default)]
|
||||
|
||||
@@ -10,7 +10,7 @@ path = "./src/lib.rs"
|
||||
|
||||
[features]
|
||||
enterprise = ["windmill-queue/enterprise", "windmill-common/enterprise"]
|
||||
private = ["dep:kube", "dep:k8s-openapi"]
|
||||
private = []
|
||||
default = []
|
||||
|
||||
[dependencies]
|
||||
@@ -22,8 +22,8 @@ tracing.workspace = true
|
||||
windmill-common = { workspace = true, default-features = false }
|
||||
windmill-queue.workspace = true
|
||||
anyhow.workspace = true
|
||||
kube = { workspace = true, optional = true }
|
||||
k8s-openapi = { workspace = true, optional = true }
|
||||
kube.workspace = true
|
||||
k8s-openapi.workspace = true
|
||||
tokio.workspace = true
|
||||
thiserror.workspace = true
|
||||
axum.workspace = true
|
||||
|
||||
@@ -7,20 +7,20 @@ edition.workspace = true
|
||||
[features]
|
||||
default = []
|
||||
enterprise = []
|
||||
private = ["dep:aws-sdk-rds", "dep:systemstat", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-types"]
|
||||
private = ["dep:aws-sdk-rds"]
|
||||
jemalloc = ["dep:tikv-jemalloc-ctl"]
|
||||
tantivy = []
|
||||
prometheus = ["dep:prometheus"]
|
||||
benchmark = []
|
||||
parquet = ["dep:object_store", "dep:aws-sdk-sts", "dep:aws-smithy-types-convert", "dep:datafusion", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-types", "dep:globset"]
|
||||
aws_auth = ["dep:aws-sdk-sts", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-types"]
|
||||
parquet = ["dep:object_store", "dep:aws-sdk-sts", "dep:aws-smithy-types-convert", "dep:datafusion"]
|
||||
aws_auth = ["dep:aws-sdk-sts"]
|
||||
otel = ["dep:opentelemetry-semantic-conventions", "dep:opentelemetry-otlp", "dep:opentelemetry_sdk",
|
||||
"dep:tracing-opentelemetry", "dep:opentelemetry-appender-tracing", "dep:tonic", "dep:opentelemetry"]
|
||||
smtp = ["dep:mail-send"]
|
||||
scoped_cache = []
|
||||
cloud = []
|
||||
openidconnect = ["dep:openidconnect"]
|
||||
bedrock = ["dep:aws-sdk-bedrockruntime", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-types"]
|
||||
bedrock = ["dep:aws-sdk-bedrockruntime"]
|
||||
|
||||
[lib]
|
||||
name = "windmill_common"
|
||||
@@ -63,10 +63,10 @@ cron.workspace = true
|
||||
magic-crypt.workspace = true
|
||||
object_store = { workspace = true, optional = true }
|
||||
prometheus = { workspace = true, optional = true }
|
||||
aws-config = { workspace = true, optional = true }
|
||||
aws-config.workspace = true
|
||||
aws-sdk-sts = { workspace = true, optional = true }
|
||||
aws-credential-types = { workspace = true, optional = true }
|
||||
aws-smithy-types = { workspace = true, optional = true }
|
||||
aws-credential-types.workspace = true
|
||||
aws-smithy-types.workspace = true
|
||||
aws-sdk-bedrockruntime = { workspace = true, optional = true }
|
||||
base64.workspace = true
|
||||
bitflags.workspace = true
|
||||
@@ -100,7 +100,7 @@ url.workspace = true
|
||||
urlencoding.workspace = true
|
||||
async-recursion.workspace = true
|
||||
pep440_rs.workspace = true
|
||||
systemstat = { workspace = true, optional = true }
|
||||
systemstat.workspace = true
|
||||
size.workspace = true
|
||||
|
||||
semver.workspace = true
|
||||
@@ -109,7 +109,7 @@ quick_cache.workspace = true
|
||||
pin-project-lite.workspace = true
|
||||
futures.workspace = true
|
||||
tempfile.workspace = true
|
||||
globset = { workspace = true, optional = true }
|
||||
globset.workspace = true
|
||||
|
||||
opentelemetry-semantic-conventions = { workspace = true, optional = true }
|
||||
opentelemetry-otlp = { workspace = true, optional = true }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgExecutor;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::{error, scripts::ScriptHash};
|
||||
|
||||
@@ -37,23 +38,15 @@ pub enum AssetUsageAccessType {
|
||||
RW,
|
||||
}
|
||||
|
||||
pub struct Asset {
|
||||
pub path: String,
|
||||
pub kind: AssetKind,
|
||||
}
|
||||
|
||||
pub struct AssetUsage {
|
||||
pub path: String,
|
||||
pub kind: AssetUsageKind,
|
||||
pub access_type: AssetUsageAccessType,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Hash, sqlx::Type)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Hash)]
|
||||
pub struct AssetWithAltAccessType {
|
||||
pub path: String,
|
||||
pub kind: AssetKind,
|
||||
pub access_type: Option<AssetUsageAccessType>,
|
||||
pub alt_access_type: Option<AssetUsageAccessType>,
|
||||
/// Map of column name to access type for column-level access tracking
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub columns: Option<BTreeMap<String, AssetUsageAccessType>>,
|
||||
}
|
||||
|
||||
pub async fn insert_static_asset_usage<'e>(
|
||||
@@ -63,15 +56,22 @@ pub async fn insert_static_asset_usage<'e>(
|
||||
usage_path: &str,
|
||||
usage_kind: AssetUsageKind,
|
||||
) -> error::Result<()> {
|
||||
// Convert columns BTreeMap to JSONB format
|
||||
let columns_json = asset
|
||||
.columns
|
||||
.as_ref()
|
||||
.map(|cols| serde_json::to_value(cols).unwrap_or(serde_json::Value::Null));
|
||||
|
||||
sqlx::query!(
|
||||
r#"INSERT INTO asset (workspace_id, path, kind, usage_access_type, usage_path, usage_kind)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING"#,
|
||||
r#"INSERT INTO asset (workspace_id, path, kind, usage_access_type, usage_path, usage_kind, columns)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT DO NOTHING"#,
|
||||
workspace_id,
|
||||
asset.path,
|
||||
asset.kind as AssetKind,
|
||||
(asset.access_type.or(asset.alt_access_type)) as Option<AssetUsageAccessType>,
|
||||
usage_path,
|
||||
usage_kind as AssetUsageKind
|
||||
usage_kind as AssetUsageKind,
|
||||
columns_json as Option<serde_json::Value>
|
||||
)
|
||||
.execute(executor)
|
||||
.await?;
|
||||
@@ -125,6 +125,28 @@ pub fn merge_asset_usage_access_types(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn merge_asset_columns(
|
||||
a: &Option<BTreeMap<String, AssetUsageAccessType>>,
|
||||
b: &Option<BTreeMap<String, AssetUsageAccessType>>,
|
||||
) -> Option<BTreeMap<String, AssetUsageAccessType>> {
|
||||
match (a, b) {
|
||||
(None, None) => None,
|
||||
(Some(cols), None) | (None, Some(cols)) => Some(cols.clone()),
|
||||
(Some(cols_a), Some(cols_b)) => {
|
||||
let mut merged = cols_a.clone();
|
||||
for (col, access_b) in cols_b {
|
||||
let access_a = merged.get(col);
|
||||
let merged_access =
|
||||
merge_asset_usage_access_types(access_a.cloned(), Some(*access_b));
|
||||
if let Some(access) = merged_access {
|
||||
merged.insert(col.clone(), access);
|
||||
}
|
||||
}
|
||||
Some(merged)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<windmill_parser::asset_parser::AssetKind> for AssetKind {
|
||||
fn from(parser_kind: windmill_parser::asset_parser::AssetKind) -> Self {
|
||||
match parser_kind {
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
use std::{collections::HashMap, sync::OnceLock};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
sync::OnceLock,
|
||||
};
|
||||
|
||||
use itertools::Itertools;
|
||||
use serde_json::value::RawValue;
|
||||
use sqlx::{Pool, Postgres, QueryBuilder};
|
||||
use sqlx::{types::Json, Pool, Postgres, QueryBuilder};
|
||||
use tokio::sync::mpsc;
|
||||
use windmill_parser::asset_parser::parse_asset_syntax;
|
||||
|
||||
use crate::{
|
||||
assets::{merge_asset_usage_access_types, AssetKind, AssetUsageAccessType, AssetUsageKind},
|
||||
assets::{
|
||||
merge_asset_columns, merge_asset_usage_access_types, AssetKind, AssetUsageAccessType,
|
||||
AssetUsageKind,
|
||||
},
|
||||
error,
|
||||
};
|
||||
|
||||
@@ -59,6 +65,7 @@ pub struct InsertRuntimeAssetParams {
|
||||
pub job_id: uuid::Uuid,
|
||||
pub access_type: Option<AssetUsageAccessType>,
|
||||
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub columns: Option<BTreeMap<String, AssetUsageAccessType>>,
|
||||
}
|
||||
|
||||
async fn insert_runtime_assets(
|
||||
@@ -66,13 +73,14 @@ async fn insert_runtime_assets(
|
||||
assets: &[InsertRuntimeAssetParams],
|
||||
) -> error::Result<()> {
|
||||
for chunk in assets.chunks(1000) {
|
||||
let mut query_builder = QueryBuilder::new("INSERT INTO asset (workspace_id, path, kind, usage_access_type, usage_path, usage_kind, created_at) ");
|
||||
let mut query_builder = QueryBuilder::new("INSERT INTO asset (workspace_id, path, kind, usage_access_type, usage_path, columns, usage_kind, created_at) ");
|
||||
query_builder.push_values(chunk, |mut b, asset| {
|
||||
b.push_bind(&asset.workspace_id)
|
||||
.push_bind(&asset.asset_path)
|
||||
.push_bind(&asset.asset_kind)
|
||||
.push_bind(&asset.access_type)
|
||||
.push_bind(asset.job_id.to_string())
|
||||
.push_bind(Json(&asset.columns))
|
||||
.push_bind(&AssetUsageKind::Job)
|
||||
.push_bind(&asset.created_at);
|
||||
});
|
||||
@@ -104,6 +112,7 @@ async fn prune_runtime_assets(
|
||||
// Same job used the same asset multiple times
|
||||
last_same_job.access_type =
|
||||
merge_asset_usage_access_types(last_same_job.access_type, asset.access_type);
|
||||
last_same_job.columns = merge_asset_columns(&last_same_job.columns, &asset.columns);
|
||||
} else if v.len() < max_n {
|
||||
v.push(asset);
|
||||
}
|
||||
|
||||
@@ -793,8 +793,8 @@ pub async fn build_s3_client(s3_resource_ref: &S3Resource) -> error::Result<Arc<
|
||||
let store = store_builder.build().map_err(|err| {
|
||||
tracing::error!("Error building object store client: {:?}", err);
|
||||
error::Error::internal_err(format!(
|
||||
"Error building object store client: {}",
|
||||
err.to_string()
|
||||
"Error building object store client: {:?}",
|
||||
err
|
||||
))
|
||||
})?;
|
||||
|
||||
@@ -860,8 +860,8 @@ fn build_azure_blob_client(
|
||||
let store = store_builder.build().map_err(|err| {
|
||||
tracing::error!("Error building object store client: {:?}", err);
|
||||
error::Error::internal_err(format!(
|
||||
"Error building object store client: {}",
|
||||
err.to_string()
|
||||
"Error building object store client: {:?}",
|
||||
err
|
||||
))
|
||||
})?;
|
||||
|
||||
@@ -900,8 +900,8 @@ async fn build_gcs_client(gcs_resource_ref: &GcsResource) -> error::Result<Arc<d
|
||||
.map_err(|err| {
|
||||
tracing::error!("Error building GCS object store client: {:?}", err);
|
||||
error::Error::internal_err(format!(
|
||||
"Error building GCS object store client: {}",
|
||||
err.to_string()
|
||||
"Error building GCS object store client: {:?}",
|
||||
err
|
||||
))
|
||||
})?;
|
||||
|
||||
@@ -1257,21 +1257,21 @@ pub fn lfs_to_object_store_resource(
|
||||
match lfs {
|
||||
LargeFileStorage::S3Storage(_) | LargeFileStorage::S3AwsOidc(_) => {
|
||||
let s3_resource: S3Resource = serde_json::from_value(resource_value).map_err(|e| {
|
||||
error::Error::internal_err(format!("Error parsing S3 resource: {}", e))
|
||||
error::Error::internal_err(format!("Error parsing S3 resource: {e:?}"))
|
||||
})?;
|
||||
Ok(ObjectStoreResource::S3(s3_resource))
|
||||
}
|
||||
LargeFileStorage::AzureBlobStorage(_) | LargeFileStorage::AzureWorkloadIdentity(_) => {
|
||||
let azure_blob_resource: AzureBlobResource = serde_json::from_value(resource_value)
|
||||
.map_err(|e| {
|
||||
error::Error::internal_err(format!("Error parsing Azure Blob resource: {}", e))
|
||||
error::Error::internal_err(format!("Error parsing Azure Blob resource: {e:?}"))
|
||||
})?;
|
||||
Ok(ObjectStoreResource::Azure(azure_blob_resource))
|
||||
}
|
||||
LargeFileStorage::GoogleCloudStorage(_) => {
|
||||
let gcs_resource: GcsResource =
|
||||
serde_json::from_value(resource_value).map_err(|e| {
|
||||
error::Error::internal_err(format!("Error parsing GCS resource: {}", e))
|
||||
error::Error::internal_err(format!("Error parsing GCS resource: {e:?}"))
|
||||
})?;
|
||||
Ok(ObjectStoreResource::Gcs(gcs_resource))
|
||||
}
|
||||
|
||||
@@ -119,6 +119,8 @@ pub struct TeamPlanStatus {
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref TEAM_PLAN_CACHE: Cache<String, TeamPlanStatus> = Cache::new(5000);
|
||||
// Value: (rate_limit, cached_at_timestamp)
|
||||
pub static ref PUBLIC_APP_RATE_LIMIT_CACHE: Cache<String, (Option<i32>, i64)> = Cache::new(1000);
|
||||
}
|
||||
|
||||
#[cfg(feature = "cloud")]
|
||||
|
||||
@@ -10,7 +10,7 @@ path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
private = ["otel_proxy"]
|
||||
private = []
|
||||
mcp = ["dep:windmill-mcp"]
|
||||
prometheus = ["dep:prometheus", "windmill-common/prometheus"]
|
||||
enterprise = ["windmill-queue/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "dep:pem", "dep:tokio-util"]
|
||||
@@ -25,7 +25,7 @@ deno_core = ["dep:deno_fetch", "dep:deno_webidl", "dep:deno_web", "dep:deno_net"
|
||||
"dep:deno_ast", "dep:deno_tls", "dep:deno_permissions", "dep:deno_io", "dep:deno_runtime", "dep:deno_telemetry", "dep:deno_error", "dep:winapi", "dep:rustls-pemfile",
|
||||
"quickjs"]
|
||||
libffi_mac = ["dep:libffi-sys"]
|
||||
otel = ["windmill-common/otel", "dep:opentelemetry", "dep:tracing-opentelemetry", "dep:opentelemetry-proto", "dep:prost"]
|
||||
otel = ["windmill-common/otel", "dep:opentelemetry", "dep:tracing-opentelemetry"]
|
||||
dind = ["dep:bollard"]
|
||||
php = ["dep:windmill-parser-php"]
|
||||
mysql = ["dep:mysql_async"]
|
||||
@@ -39,7 +39,6 @@ ruby = ["dep:windmill-parser-ruby"]
|
||||
duckdb = ["dep:libloading"]
|
||||
quickjs = ["dep:rquickjs"]
|
||||
bedrock = ["dep:aws-sdk-bedrockruntime", "windmill-common/bedrock"]
|
||||
otel_proxy = ["dep:hudsucker", "dep:hyper-http-proxy", "dep:hyper-tls", "dep:hyper-util", "dep:rcgen", "otel"]
|
||||
|
||||
[dependencies]
|
||||
windmill-queue.workspace = true
|
||||
@@ -64,9 +63,9 @@ windmill-parser-graphql.workspace = true
|
||||
windmill-parser-php = { workspace = true, optional = true }
|
||||
windmill-git-sync.workspace = true
|
||||
aws-sdk-bedrockruntime = { workspace = true, optional = true }
|
||||
aws-config = { workspace = true, optional = true }
|
||||
aws-credential-types = { workspace = true, optional = true }
|
||||
aws-smithy-types = { workspace = true, optional = true }
|
||||
aws-config.workspace = true
|
||||
aws-credential-types.workspace = true
|
||||
aws-smithy-types.workspace = true
|
||||
flume.workspace = true
|
||||
sqlx.workspace = true
|
||||
uuid.workspace = true
|
||||
@@ -141,19 +140,19 @@ process-wrap.workspace = true
|
||||
async-once-cell.workspace = true
|
||||
libloading = { workspace = true, optional = true }
|
||||
|
||||
opentelemetry-proto = { workspace = true, optional = true }
|
||||
opentelemetry-proto.workspace = true
|
||||
opentelemetry = { workspace = true, optional = true }
|
||||
tracing-opentelemetry = { workspace = true, optional = true }
|
||||
prost = { workspace = true, optional = true }
|
||||
prost.workspace = true
|
||||
axum.workspace = true
|
||||
bollard = { workspace = true, optional = true }
|
||||
oracle = { workspace = true, optional = true }
|
||||
rquickjs = { workspace = true, optional = true }
|
||||
hudsucker = { workspace = true, optional = true }
|
||||
hyper-http-proxy = { workspace = true, optional = true }
|
||||
hyper-tls = { workspace = true, optional = true }
|
||||
hyper-util = { workspace = true, optional = true }
|
||||
rcgen = { workspace = true, optional = true }
|
||||
hudsucker.workspace = true
|
||||
hyper-http-proxy.workspace = true
|
||||
hyper-tls.workspace = true
|
||||
hyper-util.workspace = true
|
||||
rcgen.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
deno_fetch = { workspace = true, optional = true }
|
||||
|
||||
@@ -29,12 +29,6 @@ mount {
|
||||
mandatory: false
|
||||
}
|
||||
|
||||
mount {
|
||||
src: "/bin"
|
||||
dst: "/bin"
|
||||
is_bind: true
|
||||
}
|
||||
|
||||
mount {
|
||||
src: "/opt/microsoft"
|
||||
dst: "/opt/microsoft"
|
||||
|
||||
@@ -486,7 +486,8 @@ lazy_static::lazy_static! {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::warn!(
|
||||
"nsjail test failed: {}. Jobs will run without nsjail sandboxing. \
|
||||
To enable nsjail: install nsjail binary or use windmill image with -nsjail suffix",
|
||||
nsjail should be included in all standard windmill images. \
|
||||
Check that the nsjail binary is installed and working correctly.",
|
||||
stderr.trim()
|
||||
);
|
||||
None
|
||||
@@ -495,7 +496,8 @@ lazy_static::lazy_static! {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
tracing::warn!(
|
||||
"nsjail not found at '{}'. Jobs will run without nsjail sandboxing. \
|
||||
To enable nsjail: install nsjail binary or use windmill image with -nsjail suffix",
|
||||
nsjail should be included in all standard windmill images. \
|
||||
Check that the nsjail binary is installed at the expected path.",
|
||||
nsjail_path
|
||||
);
|
||||
} else {
|
||||
@@ -1533,7 +1535,11 @@ pub async fn run_worker(
|
||||
|
||||
let is_dedicated_worker: bool = {
|
||||
let config = WORKER_CONFIG.read().await;
|
||||
config.dedicated_worker.is_some() || config.dedicated_workers.as_ref().is_some_and(|dws| !dws.is_empty())
|
||||
config.dedicated_worker.is_some()
|
||||
|| config
|
||||
.dedicated_workers
|
||||
.as_ref()
|
||||
.is_some_and(|dws| !dws.is_empty())
|
||||
};
|
||||
|
||||
#[cfg(feature = "benchmark")]
|
||||
@@ -2045,7 +2051,9 @@ pub async fn run_worker(
|
||||
dedicated_workers.get(&key)
|
||||
})
|
||||
} else {
|
||||
job.runnable_path.as_ref().and_then(|path| dedicated_workers.get(path))
|
||||
job.runnable_path
|
||||
.as_ref()
|
||||
.and_then(|path| dedicated_workers.get(path))
|
||||
};
|
||||
if let Some(dedicated_worker_tx) = dedicated_worker_tx {
|
||||
let dedicated_job = DedicatedWorkerJob {
|
||||
@@ -2773,6 +2781,7 @@ async fn detect_and_store_runtime_assets_from_job_args(
|
||||
asset_kind: asset.kind,
|
||||
access_type: None,
|
||||
created_at: None,
|
||||
columns: None,
|
||||
};
|
||||
register_runtime_asset(asset);
|
||||
}
|
||||
|
||||
@@ -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.624.0";
|
||||
export const VERSION = "v1.625.0";
|
||||
|
||||
export async function login(email: string, password: string): Promise<string> {
|
||||
return await windmill.UserService.login({
|
||||
|
||||
2
cli/deno.lock
generated
2
cli/deno.lock
generated
@@ -1553,6 +1553,8 @@
|
||||
"https://deno.land/std@0.224.0/assert/unimplemented.ts": "8c55a5793e9147b4f1ef68cd66496b7d5ba7a9e7ca30c6da070c1a58da723d73",
|
||||
"https://deno.land/std@0.224.0/assert/unreachable.ts": "5ae3dbf63ef988615b93eb08d395dda771c96546565f9e521ed86f6510c29e19",
|
||||
"https://deno.land/std@0.224.0/cli/parse_args.ts": "5250832fb7c544d9111e8a41ad272c016f5a53f975ef84d5a9fe5fcb70566ece",
|
||||
"https://deno.land/std@0.224.0/encoding/_util.ts": "beacef316c1255da9bc8e95afb1fa56ed69baef919c88dc06ae6cb7a6103d376",
|
||||
"https://deno.land/std@0.224.0/encoding/hex.ts": "6270f25e5d85f99fcf315278670ba012b04b7c94b67715b53f30d03249687c07",
|
||||
"https://deno.land/std@0.224.0/fmt/colors.ts": "508563c0659dd7198ba4bbf87e97f654af3c34eb56ba790260f252ad8012e1c5",
|
||||
"https://deno.land/std@0.224.0/fs/_create_walk_entry.ts": "5d9d2aaec05bcf09a06748b1684224d33eba7a4de24cf4cf5599991ca6b5b412",
|
||||
"https://deno.land/std@0.224.0/fs/_get_file_info_type.ts": "da7bec18a7661dba360a1db475b826b18977582ce6fc9b25f3d4ee0403fe8cbd",
|
||||
|
||||
@@ -77,7 +77,7 @@ export {
|
||||
// }
|
||||
// });
|
||||
|
||||
export const VERSION = "1.624.0";
|
||||
export const VERSION = "1.625.0";
|
||||
|
||||
// Re-exported from constants.ts to maintain backwards compatibility
|
||||
export { WM_FORK_PREFIX } from "./core/constants.ts";
|
||||
|
||||
@@ -211,6 +211,180 @@ export async function updateScriptSchema(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Annotation parser — mirrors backend's WorkspaceDependenciesAnnotatedRefs::parse
|
||||
// (windmill-common/src/workspace_dependencies.rs) so the cache key captures
|
||||
// exactly the parts of scriptContent that affect lockfile generation.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type AnnotationMode = "manual" | "extra";
|
||||
|
||||
interface WorkspaceDepsAnnotation {
|
||||
mode: AnnotationMode;
|
||||
external: string[];
|
||||
inline: string | null;
|
||||
}
|
||||
|
||||
const LANG_ANNOTATION_CONFIG: Partial<
|
||||
Record<ScriptLanguage, { comment: string; keyword: string; validityRe?: RegExp }>
|
||||
> = {
|
||||
python3: { comment: "#", keyword: "requirements", validityRe: /^#\s?(\S+)\s*$/ },
|
||||
bun: { comment: "//", keyword: "package_json" },
|
||||
nativets: { comment: "//", keyword: "package_json" },
|
||||
go: { comment: "//", keyword: "go_mod" },
|
||||
php: { comment: "//", keyword: "composer_json" },
|
||||
};
|
||||
|
||||
export function extractWorkspaceDepsAnnotation(
|
||||
scriptContent: string,
|
||||
language: ScriptLanguage,
|
||||
): WorkspaceDepsAnnotation | null {
|
||||
const config = LANG_ANNOTATION_CONFIG[language];
|
||||
if (!config) return null;
|
||||
|
||||
const { comment, keyword, validityRe } = config;
|
||||
const extraMarker = `extra_${keyword}:`;
|
||||
const manualMarker = `${keyword}:`;
|
||||
|
||||
const lines = scriptContent.split("\n");
|
||||
|
||||
// Find first annotation line (mirrors Rust find_position)
|
||||
let pos = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const l = lines[i];
|
||||
if (l.startsWith(comment) && (l.includes(extraMarker) || l.includes(manualMarker))) {
|
||||
pos = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (pos === -1) return null;
|
||||
|
||||
const annotationLine = lines[pos];
|
||||
const mode: AnnotationMode = annotationLine.includes(extraMarker) ? "extra" : "manual";
|
||||
|
||||
// Parse external references from the annotation line
|
||||
const marker = mode === "extra" ? extraMarker : manualMarker;
|
||||
const unparsed = annotationLine.replaceAll(marker, "").replaceAll(comment, "");
|
||||
const external = unparsed
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
// Parse inline deps from subsequent lines
|
||||
const inlineParts: string[] = [];
|
||||
for (let i = pos + 1; i < lines.length; i++) {
|
||||
const l = lines[i];
|
||||
if (validityRe) {
|
||||
const match = validityRe.exec(l);
|
||||
if (match && match[1]) {
|
||||
inlineParts.push(match[1]);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if (!l.startsWith(comment)) {
|
||||
break;
|
||||
}
|
||||
inlineParts.push(l.substring(comment.length));
|
||||
}
|
||||
}
|
||||
|
||||
const inlineStr = inlineParts.join("\n");
|
||||
const inline = inlineStr.trim().length > 0 ? inlineStr : null;
|
||||
|
||||
return { mode, external, inline };
|
||||
}
|
||||
|
||||
export async function computeLockCacheKey(
|
||||
scriptContent: string,
|
||||
language: ScriptLanguage,
|
||||
rawWorkspaceDependencies: Record<string, string>,
|
||||
): Promise<string> {
|
||||
const annotation = extractWorkspaceDepsAnnotation(scriptContent, language);
|
||||
const annotationStr = annotation
|
||||
? `${annotation.mode}|${annotation.external.join(",")}|${annotation.inline ?? ""}`
|
||||
: "none";
|
||||
const sortedDepsKeys = Object.keys(rawWorkspaceDependencies).sort();
|
||||
const depsStr = sortedDepsKeys.map((k) => `${k}=${rawWorkspaceDependencies[k]}`).join(";");
|
||||
return await generateHash(`${language}|${annotationStr}|${depsStr}`);
|
||||
}
|
||||
|
||||
const lockCache = new Map<string, string>();
|
||||
|
||||
export function clearLockCache(): void {
|
||||
lockCache.clear();
|
||||
}
|
||||
|
||||
async function fetchScriptLock(
|
||||
workspace: Workspace,
|
||||
scriptContent: string,
|
||||
language: ScriptLanguage,
|
||||
remotePath: string,
|
||||
rawWorkspaceDependencies: Record<string, string>,
|
||||
): Promise<string> {
|
||||
const hasRawDeps = Object.keys(rawWorkspaceDependencies).length > 0;
|
||||
const cacheKey = hasRawDeps
|
||||
? await computeLockCacheKey(scriptContent, language, rawWorkspaceDependencies)
|
||||
: undefined;
|
||||
if (cacheKey && lockCache.has(cacheKey)) {
|
||||
log.info(`Using cached lockfile for ${remotePath}`);
|
||||
return lockCache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
const extraHeaders = getHeaders();
|
||||
const rawResponse = await fetch(
|
||||
`${workspace.remote}api/w/${workspace.workspaceId}/jobs/run/dependencies`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Cookie: `token=${workspace.token}`,
|
||||
"Content-Type": "application/json",
|
||||
...extraHeaders,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
raw_scripts: [
|
||||
{
|
||||
raw_code: scriptContent,
|
||||
language: language,
|
||||
script_path: remotePath,
|
||||
},
|
||||
],
|
||||
raw_workspace_dependencies: Object.keys(rawWorkspaceDependencies).length > 0
|
||||
? rawWorkspaceDependencies : null,
|
||||
entrypoint: remotePath,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
let responseText = "reading response failed";
|
||||
try {
|
||||
responseText = await rawResponse.text();
|
||||
const response = JSON.parse(responseText);
|
||||
const lock = response.lock;
|
||||
if (lock === undefined) {
|
||||
if (response?.["error"]?.["message"]) {
|
||||
throw new LockfileGenerationError(
|
||||
`Failed to generate lockfile: ${response?.["error"]?.["message"]}`
|
||||
);
|
||||
}
|
||||
throw new LockfileGenerationError(
|
||||
`Failed to generate lockfile: ${JSON.stringify(response, null, 2)}`
|
||||
);
|
||||
}
|
||||
if (cacheKey) {
|
||||
lockCache.set(cacheKey, lock);
|
||||
}
|
||||
return lock;
|
||||
} catch (e) {
|
||||
if (e instanceof LockfileGenerationError) {
|
||||
throw e;
|
||||
}
|
||||
throw new LockfileGenerationError(
|
||||
`Failed to generate lockfile:${rawResponse.statusText}, ${responseText}, ${e}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateScriptLock(
|
||||
workspace: Workspace,
|
||||
scriptContent: string,
|
||||
@@ -235,70 +409,28 @@ async function updateScriptLock(
|
||||
const dependencyPaths = Object.keys(rawWorkspaceDependencies).join(', ');
|
||||
log.info(`Generating script lock for ${remotePath} with raw workspace dependencies: ${dependencyPaths}`);
|
||||
}
|
||||
|
||||
// generate the script lock running a dependency job in Windmill and update it inplace
|
||||
// TODO: update this once the client is released
|
||||
const extraHeaders = getHeaders();
|
||||
const rawResponse = await fetch(
|
||||
`${workspace.remote}api/w/${workspace.workspaceId}/jobs/run/dependencies`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Cookie: `token=${workspace.token}`,
|
||||
"Content-Type": "application/json",
|
||||
...extraHeaders,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
raw_scripts: [
|
||||
{
|
||||
raw_code: scriptContent,
|
||||
language: language,
|
||||
script_path: remotePath,
|
||||
},
|
||||
],
|
||||
raw_workspace_dependencies: Object.keys(rawWorkspaceDependencies).length > 0
|
||||
? rawWorkspaceDependencies : null,
|
||||
entrypoint: remotePath,
|
||||
}),
|
||||
}
|
||||
|
||||
const lock = await fetchScriptLock(
|
||||
workspace,
|
||||
scriptContent,
|
||||
language,
|
||||
remotePath,
|
||||
rawWorkspaceDependencies,
|
||||
);
|
||||
|
||||
let responseText = "reading response failed";
|
||||
try {
|
||||
responseText = await rawResponse.text();
|
||||
const response = JSON.parse(responseText);
|
||||
const lock = response.lock;
|
||||
if (lock === undefined) {
|
||||
if (response?.["error"]?.["message"]) {
|
||||
throw new LockfileGenerationError(
|
||||
`Failed to generate lockfile: ${response?.["error"]?.["message"]}`
|
||||
);
|
||||
const lockPath = remotePath + ".script.lock";
|
||||
if (lock != "") {
|
||||
await Deno.writeTextFile(lockPath, lock);
|
||||
metadataContent.lock = "!inline " + lockPath.replaceAll(SEP, "/");
|
||||
} else {
|
||||
try {
|
||||
if (await Deno.stat(lockPath)) {
|
||||
await Deno.remove(lockPath);
|
||||
}
|
||||
throw new LockfileGenerationError(
|
||||
`Failed to generate lockfile: ${JSON.stringify(response, null, 2)}`
|
||||
);
|
||||
} catch (e) {
|
||||
log.info(colors.yellow(`Error removing lock file ${lockPath}: ${e}`));
|
||||
}
|
||||
const lockPath = remotePath + ".script.lock";
|
||||
if (lock != "") {
|
||||
await Deno.writeTextFile(lockPath, lock);
|
||||
metadataContent.lock = "!inline " + lockPath.replaceAll(SEP, "/");
|
||||
} else {
|
||||
try {
|
||||
if (await Deno.stat(lockPath)) {
|
||||
await Deno.remove(lockPath);
|
||||
}
|
||||
} catch (e) {
|
||||
log.info(colors.yellow(`Error removing lock file ${lockPath}: ${e}`));
|
||||
}
|
||||
metadataContent.lock = "";
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof LockfileGenerationError) {
|
||||
throw e;
|
||||
}
|
||||
throw new LockfileGenerationError(
|
||||
`Failed to generate lockfile:${rawResponse.statusText}, ${responseText}, ${e}`
|
||||
);
|
||||
metadataContent.lock = "";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
572
cli/test/lock_cache.test.ts
Normal file
572
cli/test/lock_cache.test.ts
Normal file
@@ -0,0 +1,572 @@
|
||||
/**
|
||||
* Lock Cache Tests
|
||||
*
|
||||
* Tests the in-memory lock cache used when fetching lockfiles for scripts with
|
||||
* raw_workspace_dependencies.
|
||||
*
|
||||
* Part 1: Unit tests for annotation parsing (mirrors backend).
|
||||
* Part 2: Unit tests for cache key computation.
|
||||
* Part 3: Behavioral tests comparing old logic (no cache, always fetches)
|
||||
* vs new logic (caches by key, skips duplicate fetches).
|
||||
*/
|
||||
|
||||
import {
|
||||
assertEquals,
|
||||
assertNotEquals,
|
||||
} from "https://deno.land/std@0.224.0/assert/mod.ts";
|
||||
import { encodeHex } from "https://deno.land/std@0.224.0/encoding/hex.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mirrors extractWorkspaceDepsAnnotation + computeLockCacheKey from
|
||||
// src/utils/metadata.ts so we can test the algorithm without pulling in the
|
||||
// full (unresolvable-in-tests) module graph.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type AnnotationMode = "manual" | "extra";
|
||||
|
||||
interface WorkspaceDepsAnnotation {
|
||||
mode: AnnotationMode;
|
||||
external: string[];
|
||||
inline: string | null;
|
||||
}
|
||||
|
||||
const LANG_ANNOTATION_CONFIG: Record<
|
||||
string,
|
||||
{ comment: string; keyword: string; validityRe?: RegExp } | undefined
|
||||
> = {
|
||||
python3: { comment: "#", keyword: "requirements", validityRe: /^#\s?(\S+)\s*$/ },
|
||||
bun: { comment: "//", keyword: "package_json" },
|
||||
nativets: { comment: "//", keyword: "package_json" },
|
||||
go: { comment: "//", keyword: "go_mod" },
|
||||
php: { comment: "//", keyword: "composer_json" },
|
||||
};
|
||||
|
||||
function extractWorkspaceDepsAnnotation(
|
||||
scriptContent: string,
|
||||
language: string,
|
||||
): WorkspaceDepsAnnotation | null {
|
||||
const config = LANG_ANNOTATION_CONFIG[language];
|
||||
if (!config) return null;
|
||||
|
||||
const { comment, keyword, validityRe } = config;
|
||||
const extraMarker = `extra_${keyword}:`;
|
||||
const manualMarker = `${keyword}:`;
|
||||
|
||||
const lines = scriptContent.split("\n");
|
||||
|
||||
let pos = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const l = lines[i];
|
||||
if (l.startsWith(comment) && (l.includes(extraMarker) || l.includes(manualMarker))) {
|
||||
pos = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (pos === -1) return null;
|
||||
|
||||
const annotationLine = lines[pos];
|
||||
const mode: AnnotationMode = annotationLine.includes(extraMarker) ? "extra" : "manual";
|
||||
|
||||
const marker = mode === "extra" ? extraMarker : manualMarker;
|
||||
const unparsed = annotationLine.replaceAll(marker, "").replaceAll(comment, "");
|
||||
const external = unparsed
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
const inlineParts: string[] = [];
|
||||
for (let i = pos + 1; i < lines.length; i++) {
|
||||
const l = lines[i];
|
||||
if (validityRe) {
|
||||
const match = validityRe.exec(l);
|
||||
if (match && match[1]) {
|
||||
inlineParts.push(match[1]);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if (!l.startsWith(comment)) {
|
||||
break;
|
||||
}
|
||||
inlineParts.push(l.substring(comment.length));
|
||||
}
|
||||
}
|
||||
|
||||
const inlineStr = inlineParts.join("\n");
|
||||
const inline = inlineStr.trim().length > 0 ? inlineStr : null;
|
||||
|
||||
return { mode, external, inline };
|
||||
}
|
||||
|
||||
async function computeLockCacheKey(
|
||||
scriptContent: string,
|
||||
language: string,
|
||||
rawWorkspaceDependencies: Record<string, string>,
|
||||
): Promise<string> {
|
||||
const annotation = extractWorkspaceDepsAnnotation(scriptContent, language);
|
||||
const annotationStr = annotation
|
||||
? `${annotation.mode}|${annotation.external.join(",")}|${annotation.inline ?? ""}`
|
||||
: "none";
|
||||
const sortedDepsKeys = Object.keys(rawWorkspaceDependencies).sort();
|
||||
const depsStr = sortedDepsKeys
|
||||
.map((k) => `${k}=${rawWorkspaceDependencies[k]}`)
|
||||
.join(";");
|
||||
const content = `${language}|${annotationStr}|${depsStr}`;
|
||||
const buf = new TextEncoder().encode(content);
|
||||
return encodeHex(await crypto.subtle.digest("SHA-256", buf));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers that mirror the two fetch strategies (old / new).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ScriptInput {
|
||||
scriptContent: string;
|
||||
language: string;
|
||||
remotePath: string;
|
||||
rawWorkspaceDependencies: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Old logic: always calls the remote for every script. */
|
||||
async function fetchScriptLockOld(
|
||||
input: ScriptInput,
|
||||
remoteFn: (input: ScriptInput) => Promise<string>,
|
||||
): Promise<string> {
|
||||
return await remoteFn(input);
|
||||
}
|
||||
|
||||
/** New logic: only caches when raw_workspace_dependencies are non-empty. */
|
||||
async function fetchScriptLockNew(
|
||||
input: ScriptInput,
|
||||
remoteFn: (input: ScriptInput) => Promise<string>,
|
||||
cache: Map<string, string>,
|
||||
): Promise<string> {
|
||||
const hasRawDeps = Object.keys(input.rawWorkspaceDependencies).length > 0;
|
||||
const cacheKey = hasRawDeps
|
||||
? await computeLockCacheKey(
|
||||
input.scriptContent,
|
||||
input.language,
|
||||
input.rawWorkspaceDependencies,
|
||||
)
|
||||
: undefined;
|
||||
if (cacheKey && cache.has(cacheKey)) {
|
||||
return cache.get(cacheKey)!;
|
||||
}
|
||||
const lock = await remoteFn(input);
|
||||
if (cacheKey) {
|
||||
cache.set(cacheKey, lock);
|
||||
}
|
||||
return lock;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Part 1 — Annotation parsing
|
||||
// =============================================================================
|
||||
|
||||
Deno.test("python: manual requirements with external refs + inline deps", () => {
|
||||
const code = `# requirements: default, base
|
||||
#requests==2.31.0
|
||||
#pandas>=1.5.0
|
||||
|
||||
def main():
|
||||
pass`;
|
||||
const r = extractWorkspaceDepsAnnotation(code, "python3")!;
|
||||
assertEquals(r.mode, "manual");
|
||||
assertEquals(r.external, ["default", "base"]);
|
||||
assertEquals(r.inline, "requests==2.31.0\npandas>=1.5.0");
|
||||
});
|
||||
|
||||
Deno.test("python: extra_requirements mode", () => {
|
||||
const code = `# extra_requirements: utils
|
||||
#numpy>=1.24.0
|
||||
|
||||
def main():
|
||||
pass`;
|
||||
const r = extractWorkspaceDepsAnnotation(code, "python3")!;
|
||||
assertEquals(r.mode, "extra");
|
||||
assertEquals(r.external, ["utils"]);
|
||||
assertEquals(r.inline, "numpy>=1.24.0");
|
||||
});
|
||||
|
||||
Deno.test("python: empty requirements (opt-out)", () => {
|
||||
const code = `# requirements:
|
||||
def main():
|
||||
pass`;
|
||||
const r = extractWorkspaceDepsAnnotation(code, "python3")!;
|
||||
assertEquals(r.mode, "manual");
|
||||
assertEquals(r.external, []);
|
||||
assertEquals(r.inline, null);
|
||||
});
|
||||
|
||||
Deno.test("python: no annotation → null", () => {
|
||||
const code = `def main():
|
||||
print("hello")`;
|
||||
assertEquals(extractWorkspaceDepsAnnotation(code, "python3"), null);
|
||||
});
|
||||
|
||||
Deno.test("bun: package_json annotation with inline", () => {
|
||||
const code = `// package_json: utils, base
|
||||
//{
|
||||
// "dependencies": {
|
||||
// "axios": "^1.6.0"
|
||||
// }
|
||||
//}
|
||||
|
||||
export function main() {}`;
|
||||
const r = extractWorkspaceDepsAnnotation(code, "bun")!;
|
||||
assertEquals(r.mode, "manual");
|
||||
assertEquals(r.external, ["utils", "base"]);
|
||||
assertEquals(r.inline, `{
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0"
|
||||
}
|
||||
}`);
|
||||
});
|
||||
|
||||
Deno.test("go: go_mod annotation", () => {
|
||||
const code = `// go_mod: base,
|
||||
//github.com/gin-gonic/gin v1.9.1
|
||||
|
||||
package main
|
||||
func main() {}`;
|
||||
const r = extractWorkspaceDepsAnnotation(code, "go")!;
|
||||
assertEquals(r.mode, "manual");
|
||||
assertEquals(r.external, ["base"]);
|
||||
assertEquals(r.inline, "github.com/gin-gonic/gin v1.9.1");
|
||||
});
|
||||
|
||||
Deno.test("unsupported language → null", () => {
|
||||
assertEquals(extractWorkspaceDepsAnnotation("print(1)", "deno"), null);
|
||||
assertEquals(extractWorkspaceDepsAnnotation("print(1)", "bash"), null);
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Part 2 — Cache key computation
|
||||
// =============================================================================
|
||||
|
||||
Deno.test("same annotation + language + deps → same key", async () => {
|
||||
const code = `# requirements: default
|
||||
#requests==2.31.0
|
||||
print("hello")`;
|
||||
const deps = { "dependencies/requirements.in": "requests==2.31.0" };
|
||||
const a = await computeLockCacheKey(code, "python3", deps);
|
||||
const b = await computeLockCacheKey(code, "python3", deps);
|
||||
assertEquals(a, b);
|
||||
});
|
||||
|
||||
Deno.test("different code, same annotation → same key", async () => {
|
||||
const codeA = `# requirements: default
|
||||
#requests==2.31.0
|
||||
print("hello")`;
|
||||
const codeB = `# requirements: default
|
||||
#requests==2.31.0
|
||||
print("world")`;
|
||||
const deps = { "dependencies/requirements.in": "requests==2.31.0" };
|
||||
assertEquals(
|
||||
await computeLockCacheKey(codeA, "python3", deps),
|
||||
await computeLockCacheKey(codeB, "python3", deps),
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("different annotation inline → different key", async () => {
|
||||
const codeA = `# requirements: default
|
||||
#requests==2.31.0
|
||||
print("hello")`;
|
||||
const codeB = `# requirements: default
|
||||
#flask==3.0.0
|
||||
print("hello")`;
|
||||
const deps = { "dependencies/requirements.in": "requests==2.31.0" };
|
||||
assertNotEquals(
|
||||
await computeLockCacheKey(codeA, "python3", deps),
|
||||
await computeLockCacheKey(codeB, "python3", deps),
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("different annotation external refs → different key", async () => {
|
||||
const codeA = `# requirements: default
|
||||
print("hello")`;
|
||||
const codeB = `# requirements: base
|
||||
print("hello")`;
|
||||
const deps = { "dependencies/requirements.in": "requests==2.31.0" };
|
||||
assertNotEquals(
|
||||
await computeLockCacheKey(codeA, "python3", deps),
|
||||
await computeLockCacheKey(codeB, "python3", deps),
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("manual vs extra mode → different key", async () => {
|
||||
const codeA = `# requirements: default
|
||||
print("hello")`;
|
||||
const codeB = `# extra_requirements: default
|
||||
print("hello")`;
|
||||
const deps = { "dependencies/requirements.in": "requests==2.31.0" };
|
||||
assertNotEquals(
|
||||
await computeLockCacheKey(codeA, "python3", deps),
|
||||
await computeLockCacheKey(codeB, "python3", deps),
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("no annotation, same code → same key", async () => {
|
||||
const deps = { "dependencies/requirements.in": "requests==2.31.0" };
|
||||
assertEquals(
|
||||
await computeLockCacheKey("print('a')", "python3", deps),
|
||||
await computeLockCacheKey("print('b')", "python3", deps),
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("different deps → different key", async () => {
|
||||
const code = `# requirements: default
|
||||
print("hello")`;
|
||||
assertNotEquals(
|
||||
await computeLockCacheKey(code, "python3", { d: "a" }),
|
||||
await computeLockCacheKey(code, "python3", { d: "b" }),
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("different language → different key", async () => {
|
||||
const deps = { d: "v" };
|
||||
assertNotEquals(
|
||||
await computeLockCacheKey("x", "bun", deps),
|
||||
await computeLockCacheKey("x", "python3", deps),
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("dep key order does not matter", async () => {
|
||||
const code = "print('hello')";
|
||||
assertEquals(
|
||||
await computeLockCacheKey(code, "python3", { a: "1", b: "2" }),
|
||||
await computeLockCacheKey(code, "python3", { b: "2", a: "1" }),
|
||||
);
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Part 3 — Multi-script fetch behavior: old logic vs new logic
|
||||
// =============================================================================
|
||||
|
||||
function makeRemoteFn(): {
|
||||
remoteFn: (input: ScriptInput) => Promise<string>;
|
||||
callCount: () => number;
|
||||
} {
|
||||
const calls: ScriptInput[] = [];
|
||||
return {
|
||||
remoteFn: async (input: ScriptInput) => {
|
||||
calls.push(input);
|
||||
const depsStr = Object.entries(input.rawWorkspaceDependencies).sort().map(([k,v]) => `${k}=${v}`).join(",");
|
||||
return `lock for ${input.language}:${input.scriptContent}:${depsStr}`;
|
||||
},
|
||||
callCount: () => calls.length,
|
||||
};
|
||||
}
|
||||
|
||||
// -- Two scripts, same annotation + language + deps -------------------------
|
||||
|
||||
Deno.test("old logic: two scripts same annotation → 2 remote calls", async () => {
|
||||
const { remoteFn, callCount } = makeRemoteFn();
|
||||
const deps = { "dependencies/requirements.in": "requests==2.31.0" };
|
||||
|
||||
const scripts: ScriptInput[] = [
|
||||
{ scriptContent: "# requirements: default\nprint(1)", language: "python3", remotePath: "a", rawWorkspaceDependencies: deps },
|
||||
{ scriptContent: "# requirements: default\nprint(2)", language: "python3", remotePath: "b", rawWorkspaceDependencies: deps },
|
||||
];
|
||||
|
||||
for (const s of scripts) await fetchScriptLockOld(s, remoteFn);
|
||||
assertEquals(callCount(), 2);
|
||||
});
|
||||
|
||||
Deno.test("new logic: two scripts same annotation → 1 remote call (cache shared)", async () => {
|
||||
const { remoteFn, callCount } = makeRemoteFn();
|
||||
const cache = new Map<string, string>();
|
||||
const deps = { "dependencies/requirements.in": "requests==2.31.0" };
|
||||
|
||||
const scripts: ScriptInput[] = [
|
||||
{ scriptContent: "# requirements: default\nprint(1)", language: "python3", remotePath: "a", rawWorkspaceDependencies: deps },
|
||||
{ scriptContent: "# requirements: default\nprint(2)", language: "python3", remotePath: "b", rawWorkspaceDependencies: deps },
|
||||
];
|
||||
|
||||
const results: string[] = [];
|
||||
for (const s of scripts) results.push(await fetchScriptLockNew(s, remoteFn, cache));
|
||||
assertEquals(callCount(), 1);
|
||||
assertEquals(results[0], results[1]);
|
||||
});
|
||||
|
||||
// -- Two scripts, different annotations + same deps -------------------------
|
||||
|
||||
Deno.test("new logic: different annotations same deps → 2 remote calls", async () => {
|
||||
const { remoteFn, callCount } = makeRemoteFn();
|
||||
const cache = new Map<string, string>();
|
||||
const deps = { "dependencies/requirements.in": "requests==2.31.0" };
|
||||
|
||||
const scripts: ScriptInput[] = [
|
||||
{ scriptContent: "# requirements: default\nprint(1)", language: "python3", remotePath: "a", rawWorkspaceDependencies: deps },
|
||||
{ scriptContent: "# requirements: base\nprint(2)", language: "python3", remotePath: "b", rawWorkspaceDependencies: deps },
|
||||
];
|
||||
|
||||
const results: string[] = [];
|
||||
for (const s of scripts) results.push(await fetchScriptLockNew(s, remoteFn, cache));
|
||||
assertEquals(callCount(), 2);
|
||||
assertNotEquals(results[0], results[1]);
|
||||
});
|
||||
|
||||
// -- Two scripts, same annotation + different deps --------------------------
|
||||
|
||||
Deno.test("new logic: same annotation different deps → 2 remote calls", async () => {
|
||||
const { remoteFn, callCount } = makeRemoteFn();
|
||||
const cache = new Map<string, string>();
|
||||
|
||||
const scripts: ScriptInput[] = [
|
||||
{ scriptContent: "# requirements: default\nprint(1)", language: "python3", remotePath: "a",
|
||||
rawWorkspaceDependencies: { "dependencies/requirements.in": "requests==2.31.0" } },
|
||||
{ scriptContent: "# requirements: default\nprint(1)", language: "python3", remotePath: "b",
|
||||
rawWorkspaceDependencies: { "dependencies/requirements.in": "requests==2.32.0" } },
|
||||
];
|
||||
|
||||
const results: string[] = [];
|
||||
for (const s of scripts) results.push(await fetchScriptLockNew(s, remoteFn, cache));
|
||||
assertEquals(callCount(), 2);
|
||||
assertNotEquals(results[0], results[1]);
|
||||
});
|
||||
|
||||
// -- Many scripts, same annotation + deps -----------------------------------
|
||||
|
||||
Deno.test("old logic: 5 scripts same annotation+deps → 5 remote calls", async () => {
|
||||
const { remoteFn, callCount } = makeRemoteFn();
|
||||
const deps = { "dependencies/requirements.in": "requests==2.31.0" };
|
||||
const ann = "# requirements: default\n";
|
||||
|
||||
const scripts: ScriptInput[] = [
|
||||
{ scriptContent: ann + "print(1)", language: "python3", remotePath: "a", rawWorkspaceDependencies: deps },
|
||||
{ scriptContent: ann + "print(2)", language: "python3", remotePath: "b", rawWorkspaceDependencies: deps },
|
||||
{ scriptContent: ann + "print(3)", language: "python3", remotePath: "c", rawWorkspaceDependencies: deps },
|
||||
{ scriptContent: ann + "print(1)", language: "python3", remotePath: "d", rawWorkspaceDependencies: deps },
|
||||
{ scriptContent: ann + "print(2)", language: "python3", remotePath: "e", rawWorkspaceDependencies: deps },
|
||||
];
|
||||
|
||||
for (const s of scripts) await fetchScriptLockOld(s, remoteFn);
|
||||
assertEquals(callCount(), 5);
|
||||
});
|
||||
|
||||
Deno.test("new logic: 5 scripts same annotation+deps → 1 remote call", async () => {
|
||||
const { remoteFn, callCount } = makeRemoteFn();
|
||||
const cache = new Map<string, string>();
|
||||
const deps = { "dependencies/requirements.in": "requests==2.31.0" };
|
||||
const ann = "# requirements: default\n";
|
||||
|
||||
const scripts: ScriptInput[] = [
|
||||
{ scriptContent: ann + "print(1)", language: "python3", remotePath: "a", rawWorkspaceDependencies: deps },
|
||||
{ scriptContent: ann + "print(2)", language: "python3", remotePath: "b", rawWorkspaceDependencies: deps },
|
||||
{ scriptContent: ann + "print(3)", language: "python3", remotePath: "c", rawWorkspaceDependencies: deps },
|
||||
{ scriptContent: ann + "print(1)", language: "python3", remotePath: "d", rawWorkspaceDependencies: deps },
|
||||
{ scriptContent: ann + "print(2)", language: "python3", remotePath: "e", rawWorkspaceDependencies: deps },
|
||||
];
|
||||
|
||||
const results: string[] = [];
|
||||
for (const s of scripts) results.push(await fetchScriptLockNew(s, remoteFn, cache));
|
||||
assertEquals(callCount(), 1);
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
assertEquals(results[0], results[i]);
|
||||
}
|
||||
});
|
||||
|
||||
// -- Many scripts, 2 annotation groups + same deps -------------------------
|
||||
|
||||
Deno.test("new logic: 4 scripts with 2 annotation groups → 2 remote calls", async () => {
|
||||
const { remoteFn, callCount } = makeRemoteFn();
|
||||
const cache = new Map<string, string>();
|
||||
const deps = { "dependencies/requirements.in": "requests==2.31.0" };
|
||||
|
||||
const scripts: ScriptInput[] = [
|
||||
{ scriptContent: "# requirements: default\nprint(1)", language: "python3", remotePath: "a", rawWorkspaceDependencies: deps },
|
||||
{ scriptContent: "# requirements: base\nprint(2)", language: "python3", remotePath: "b", rawWorkspaceDependencies: deps },
|
||||
{ scriptContent: "# requirements: default\nprint(3)", language: "python3", remotePath: "c", rawWorkspaceDependencies: deps },
|
||||
{ scriptContent: "# requirements: base\nprint(4)", language: "python3", remotePath: "d", rawWorkspaceDependencies: deps },
|
||||
];
|
||||
|
||||
const results: string[] = [];
|
||||
for (const s of scripts) results.push(await fetchScriptLockNew(s, remoteFn, cache));
|
||||
assertEquals(callCount(), 2);
|
||||
assertEquals(results[0], results[2]); // same annotation "default"
|
||||
assertEquals(results[1], results[3]); // same annotation "base"
|
||||
assertNotEquals(results[0], results[1]);
|
||||
});
|
||||
|
||||
// -- Scripts with no workspace deps (empty) ---------------------------------
|
||||
|
||||
Deno.test("new logic: empty deps → no caching", async () => {
|
||||
const { remoteFn, callCount } = makeRemoteFn();
|
||||
const cache = new Map<string, string>();
|
||||
|
||||
const scripts: ScriptInput[] = [
|
||||
{ scriptContent: "print(1)", language: "python3", remotePath: "a", rawWorkspaceDependencies: {} },
|
||||
{ scriptContent: "print(1)", language: "python3", remotePath: "b", rawWorkspaceDependencies: {} },
|
||||
];
|
||||
|
||||
for (const s of scripts) await fetchScriptLockNew(s, remoteFn, cache);
|
||||
assertEquals(callCount(), 2);
|
||||
assertEquals(cache.size, 0);
|
||||
});
|
||||
|
||||
// -- No annotation scripts with raw deps → share cache ---------------------
|
||||
|
||||
Deno.test("new logic: no annotation + same deps → 1 remote call", async () => {
|
||||
const { remoteFn, callCount } = makeRemoteFn();
|
||||
const cache = new Map<string, string>();
|
||||
const deps = { "dependencies/requirements.in": "requests==2.31.0" };
|
||||
|
||||
const scripts: ScriptInput[] = [
|
||||
{ scriptContent: "print(1)", language: "python3", remotePath: "a", rawWorkspaceDependencies: deps },
|
||||
{ scriptContent: "print(2)", language: "python3", remotePath: "b", rawWorkspaceDependencies: deps },
|
||||
];
|
||||
|
||||
const results: string[] = [];
|
||||
for (const s of scripts) results.push(await fetchScriptLockNew(s, remoteFn, cache));
|
||||
assertEquals(callCount(), 1);
|
||||
assertEquals(results[0], results[1]);
|
||||
});
|
||||
|
||||
// -- Mix of annotated and non-annotated scripts -----------------------------
|
||||
|
||||
Deno.test("new logic: mix of annotated and non-annotated → separate cache groups", async () => {
|
||||
const { remoteFn, callCount } = makeRemoteFn();
|
||||
const cache = new Map<string, string>();
|
||||
const deps = { "dependencies/requirements.in": "requests==2.31.0" };
|
||||
|
||||
const scripts: ScriptInput[] = [
|
||||
{ scriptContent: "# requirements: default\nprint(1)", language: "python3", remotePath: "a", rawWorkspaceDependencies: deps },
|
||||
{ scriptContent: "print(2)", language: "python3", remotePath: "b", rawWorkspaceDependencies: deps },
|
||||
{ scriptContent: "# requirements: default\nprint(3)", language: "python3", remotePath: "c", rawWorkspaceDependencies: deps },
|
||||
{ scriptContent: "print(4)", language: "python3", remotePath: "d", rawWorkspaceDependencies: deps },
|
||||
];
|
||||
|
||||
const results: string[] = [];
|
||||
for (const s of scripts) results.push(await fetchScriptLockNew(s, remoteFn, cache));
|
||||
assertEquals(callCount(), 2); // one for annotated group, one for no-annotation group
|
||||
assertEquals(results[0], results[2]); // both annotated "default"
|
||||
assertEquals(results[1], results[3]); // both no annotation
|
||||
assertNotEquals(results[0], results[1]); // annotated ≠ non-annotated
|
||||
});
|
||||
|
||||
// -- Cache returns correct lock value ---------------------------------------
|
||||
|
||||
Deno.test("new logic: cached value matches original remote response", async () => {
|
||||
const cache = new Map<string, string>();
|
||||
const deps = { "dependencies/requirements.in": "requests==2.31.0" };
|
||||
|
||||
let callIdx = 0;
|
||||
const remoteFn = async (_input: ScriptInput) => {
|
||||
callIdx++;
|
||||
return "resolved-lock-content-abc123";
|
||||
};
|
||||
|
||||
const r1 = await fetchScriptLockNew(
|
||||
{ scriptContent: "# requirements: default\nprint(1)", language: "python3", remotePath: "a", rawWorkspaceDependencies: deps },
|
||||
remoteFn, cache,
|
||||
);
|
||||
const r2 = await fetchScriptLockNew(
|
||||
{ scriptContent: "# requirements: default\nprint(2)", language: "python3", remotePath: "b", rawWorkspaceDependencies: deps },
|
||||
remoteFn, cache,
|
||||
);
|
||||
|
||||
assertEquals(callIdx, 1);
|
||||
assertEquals(r1, "resolved-lock-content-abc123");
|
||||
assertEquals(r2, "resolved-lock-content-abc123");
|
||||
});
|
||||
@@ -906,7 +906,9 @@ Deno.test("readDirRecursive reads all files correctly", async () => {
|
||||
// Integration Tests (use withTestBackend for automated backend setup)
|
||||
// =============================================================================
|
||||
|
||||
import { yamlParseFile } from "../deps.ts";
|
||||
import { withTestBackend } from "./test_backend.ts";
|
||||
import { shouldSkipOnCI } from "./cargo_backend.ts";
|
||||
|
||||
Deno.test({
|
||||
name: "Integration: Pull creates correct local structure",
|
||||
@@ -1950,3 +1952,212 @@ excludes: []
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// ws_error_handler_muted Persistence Tests
|
||||
// =============================================================================
|
||||
|
||||
Deno.test({
|
||||
name: "Integration: Script ws_error_handler_muted is persisted through push/pull",
|
||||
ignore: shouldSkipOnCI(), // Requires EE features
|
||||
sanitizeResources: false,
|
||||
sanitizeOps: false,
|
||||
async fn() {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await Deno.writeTextFile(
|
||||
`${tempDir}/wmill.yaml`,
|
||||
`defaultTs: bun
|
||||
includes:
|
||||
- "**"
|
||||
excludes: []
|
||||
`,
|
||||
);
|
||||
|
||||
const uniqueId = Date.now();
|
||||
await ensureDir(`${tempDir}/f/test`);
|
||||
|
||||
// Create a script with ws_error_handler_muted: true
|
||||
const scriptName = `f/test/muted_script_${uniqueId}`;
|
||||
const script = createScriptFixture(scriptName, "deno");
|
||||
await Deno.writeTextFile(`${tempDir}/${script.contentFile.path}`, script.contentFile.content);
|
||||
// Add ws_error_handler_muted to the metadata
|
||||
const metadataWithMuted = script.metadataFile.content + `ws_error_handler_muted: true\n`;
|
||||
await Deno.writeTextFile(`${tempDir}/${script.metadataFile.path}`, metadataWithMuted);
|
||||
|
||||
// Push
|
||||
const pushResult = await backend.runCLICommand(
|
||||
["sync", "push", "--yes", "--includes", `f/test/muted_script_${uniqueId}**`],
|
||||
tempDir,
|
||||
);
|
||||
assertEquals(
|
||||
pushResult.code,
|
||||
0,
|
||||
`Push should succeed.\nstdout: ${pushResult.stdout}\nstderr: ${pushResult.stderr}`,
|
||||
);
|
||||
|
||||
// Verify via API that ws_error_handler_muted was persisted
|
||||
const apiResp = await backend.apiRequest!(
|
||||
`/api/w/${backend.workspace}/scripts/get/p/${scriptName}`,
|
||||
);
|
||||
assertEquals(apiResp.status, 200, "API should return the script");
|
||||
const scriptData = await apiResp.json();
|
||||
assertEquals(
|
||||
scriptData.ws_error_handler_muted,
|
||||
true,
|
||||
"API should return ws_error_handler_muted: true for the pushed script",
|
||||
);
|
||||
|
||||
// Pull into a fresh directory and verify the field round-trips
|
||||
const pullDir = await Deno.makeTempDir({ prefix: "wmill_muted_script_pull_" });
|
||||
try {
|
||||
await Deno.writeTextFile(
|
||||
`${pullDir}/wmill.yaml`,
|
||||
`defaultTs: bun
|
||||
includes:
|
||||
- "f/test/muted_script_${uniqueId}**"
|
||||
excludes: []
|
||||
`,
|
||||
);
|
||||
|
||||
const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], pullDir);
|
||||
assertEquals(
|
||||
pullResult.code,
|
||||
0,
|
||||
`Pull should succeed.\nstdout: ${pullResult.stdout}\nstderr: ${pullResult.stderr}`,
|
||||
);
|
||||
|
||||
// Verify ws_error_handler_muted is in the pulled metadata
|
||||
const pulledMetadata = await Deno.readTextFile(`${pullDir}/${script.metadataFile.path}`);
|
||||
assertStringIncludes(
|
||||
pulledMetadata,
|
||||
"ws_error_handler_muted: true",
|
||||
"Pulled script metadata should contain ws_error_handler_muted: true",
|
||||
);
|
||||
|
||||
// Verify push from pulled dir is idempotent (no changes)
|
||||
const push2 = await backend.runCLICommand(
|
||||
["sync", "push", "--dry-run", "--includes", `f/test/muted_script_${uniqueId}**`],
|
||||
pullDir,
|
||||
);
|
||||
assertEquals(push2.code, 0, `Second push dry-run should succeed: ${push2.stderr}`);
|
||||
const output = (push2.stdout + push2.stderr).toLowerCase();
|
||||
assert(
|
||||
output.includes("0 change") || output.includes("no change") || output.includes("nothing"),
|
||||
`Should have no changes after push-pull cycle for script with ws_error_handler_muted. Output: ${output}`,
|
||||
);
|
||||
} finally {
|
||||
await Deno.remove(pullDir, { recursive: true }).catch(() => {});
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
Deno.test({
|
||||
name: "Integration: Flow ws_error_handler_muted is persisted through push/pull",
|
||||
ignore: shouldSkipOnCI(), // Requires EE features
|
||||
sanitizeResources: false,
|
||||
sanitizeOps: false,
|
||||
async fn() {
|
||||
await withTestBackend(async (backend, tempDir) => {
|
||||
await Deno.writeTextFile(
|
||||
`${tempDir}/wmill.yaml`,
|
||||
`defaultTs: bun
|
||||
includes:
|
||||
- "**"
|
||||
excludes: []
|
||||
`,
|
||||
);
|
||||
|
||||
const uniqueId = Date.now();
|
||||
const flowName = `f/test/muted_flow_${uniqueId}`;
|
||||
const flowFixture = createFlowFixture(flowName);
|
||||
|
||||
// Create flow directory and files
|
||||
await ensureDir(`${tempDir}/f/test/muted_flow_${uniqueId}${getFolderSuffix("flow")}`);
|
||||
for (const [key, file] of Object.entries(flowFixture)) {
|
||||
if (key === "metadata") {
|
||||
// Add ws_error_handler_muted to flow metadata
|
||||
const contentWithMuted = file.content + `ws_error_handler_muted: true\n`;
|
||||
await Deno.writeTextFile(`${tempDir}/${file.path}`, contentWithMuted);
|
||||
} else {
|
||||
await Deno.writeTextFile(`${tempDir}/${file.path}`, file.content);
|
||||
}
|
||||
}
|
||||
|
||||
// Push
|
||||
const pushResult = await backend.runCLICommand(
|
||||
["sync", "push", "--yes", "--includes", `f/test/muted_flow_${uniqueId}*/**`],
|
||||
tempDir,
|
||||
);
|
||||
assertEquals(
|
||||
pushResult.code,
|
||||
0,
|
||||
`Push should succeed.\nstdout: ${pushResult.stdout}\nstderr: ${pushResult.stderr}`,
|
||||
);
|
||||
|
||||
// Verify via API that ws_error_handler_muted was persisted
|
||||
const apiResp = await backend.apiRequest!(
|
||||
`/api/w/${backend.workspace}/flows/get/${flowName}`,
|
||||
);
|
||||
assertEquals(apiResp.status, 200, "API should return the flow");
|
||||
const flowData = await apiResp.json();
|
||||
assertEquals(
|
||||
flowData.ws_error_handler_muted,
|
||||
true,
|
||||
"API should return ws_error_handler_muted: true for the pushed flow",
|
||||
);
|
||||
|
||||
// Pull into a fresh directory and verify the field round-trips
|
||||
const pullDir = await Deno.makeTempDir({ prefix: "wmill_muted_flow_pull_" });
|
||||
try {
|
||||
await Deno.writeTextFile(
|
||||
`${pullDir}/wmill.yaml`,
|
||||
`defaultTs: bun
|
||||
includes:
|
||||
- "f/test/muted_flow_${uniqueId}*/**"
|
||||
excludes: []
|
||||
`,
|
||||
);
|
||||
|
||||
const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], pullDir);
|
||||
assertEquals(
|
||||
pullResult.code,
|
||||
0,
|
||||
`Pull should succeed.\nstdout: ${pullResult.stdout}\nstderr: ${pullResult.stderr}`,
|
||||
);
|
||||
|
||||
// Verify ws_error_handler_muted is in the pulled flow.yaml
|
||||
const flowYamlPath = `${pullDir}/${flowFixture.metadata.path}`;
|
||||
const pulledFlowYaml = await Deno.readTextFile(flowYamlPath);
|
||||
assertStringIncludes(
|
||||
pulledFlowYaml,
|
||||
"ws_error_handler_muted: true",
|
||||
"Pulled flow.yaml should contain ws_error_handler_muted: true",
|
||||
);
|
||||
|
||||
// Parse the YAML to confirm it's a proper boolean value
|
||||
// deno-lint-ignore no-explicit-any
|
||||
const parsed = await yamlParseFile(flowYamlPath) as any;
|
||||
assertEquals(
|
||||
parsed.ws_error_handler_muted,
|
||||
true,
|
||||
"ws_error_handler_muted should be boolean true in parsed flow YAML",
|
||||
);
|
||||
|
||||
// Verify push from pulled dir is idempotent (no changes)
|
||||
const push2 = await backend.runCLICommand(
|
||||
["sync", "push", "--dry-run", "--includes", `f/test/muted_flow_${uniqueId}*/**`],
|
||||
pullDir,
|
||||
);
|
||||
assertEquals(push2.code, 0, `Second push dry-run should succeed: ${push2.stderr}`);
|
||||
const output = (push2.stdout + push2.stderr).toLowerCase();
|
||||
assert(
|
||||
output.includes("0 change") || output.includes("no change") || output.includes("nothing"),
|
||||
`Should have no changes after push-pull cycle for flow with ws_error_handler_muted. Output: ${output}`,
|
||||
);
|
||||
} finally {
|
||||
await Deno.remove(pullDir, { recursive: true }).catch(() => {});
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
# With nsjail enabled:
|
||||
# docker run -p 5679:5679 --privileged windmill-debugger --nsjail
|
||||
|
||||
# Stage 1: Get nsjail and windmill from the official windmill image
|
||||
FROM ghcr.io/windmill-labs/windmill-ee-nsjail:main AS windmill-source
|
||||
# Stage 1: Get nsjail and windmill from the official windmill EE image
|
||||
FROM ghcr.io/windmill-labs/windmill-ee:main AS windmill-source
|
||||
|
||||
# Stage 2: Build the debug service
|
||||
FROM oven/bun:1 AS runtime
|
||||
|
||||
@@ -17,12 +17,7 @@
|
||||
# docker run -p 3001:3001 -p 3002:3002 -p 3003:3003 windmill-extra
|
||||
|
||||
# ============================================================================
|
||||
# Stage 1: Get nsjail from the nsjail image
|
||||
# ============================================================================
|
||||
FROM ghcr.io/windmill-labs/windmill-ee-nsjail:main AS nsjail-source
|
||||
|
||||
# ============================================================================
|
||||
# Stage 2: Build final extra services image from windmill-slim
|
||||
# Build final extra services image from windmill-ee-slim (includes nsjail)
|
||||
# ============================================================================
|
||||
FROM ghcr.io/windmill-labs/windmill-ee-slim:latest AS final
|
||||
|
||||
@@ -40,11 +35,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
# Install additional system dependencies
|
||||
# - shellcheck: for bash LSP
|
||||
# - libprotobuf-dev, libnl-route-3-dev: for nsjail runtime
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
shellcheck \
|
||||
libprotobuf-dev \
|
||||
libnl-route-3-dev \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -68,9 +60,6 @@ RUN /usr/local/go/bin/go install -v golang.org/x/tools/gopls@latest
|
||||
# Copy Deno for Deno LSP
|
||||
COPY --from=denoland/deno:2.2.1 --chmod=755 /usr/bin/deno /usr/bin/deno
|
||||
|
||||
# Copy nsjail from nsjail image (for sandboxed debugging)
|
||||
COPY --from=nsjail-source /bin/nsjail /bin/nsjail
|
||||
|
||||
# ============================================================================
|
||||
# LSP Setup
|
||||
# ============================================================================
|
||||
|
||||
@@ -51,6 +51,9 @@ RUN /usr/bin/java -jar /usr/bin/coursier about
|
||||
# Ruby
|
||||
RUN apt-get install -y ruby ruby-bundler
|
||||
|
||||
# iptables
|
||||
RUN apt-get install -y iptables
|
||||
|
||||
# Fix UV cache permissions for non-root user support (uid 1000, etc.)
|
||||
# The uv tool install ansible command populates the UV cache with root-owned files
|
||||
RUN chmod -R a+rw /tmp/windmill/cache/uv && \
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
FROM debian:bookworm-slim AS nsjail
|
||||
|
||||
WORKDIR /nsjail
|
||||
|
||||
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.* \
|
||||
libprotobuf-dev=3.21.* \
|
||||
libnl-route-3-dev=3.7.* \
|
||||
make=4.3-4.1 \
|
||||
pkg-config=1.8.* \
|
||||
protobuf-compiler=3.21.*
|
||||
|
||||
|
||||
RUN git clone -b master --single-branch https://github.com/google/nsjail.git . && git checkout dccf911fd2659e7b08ce9507c25b2b38ec2c5800
|
||||
RUN make
|
||||
|
||||
FROM alpine:3.14 AS oracledb-client
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
# Oracle DB Client for amd64
|
||||
COPY --from=ghcr.io/oracle/oraclelinux9-instantclient:23 /usr/lib/oracle/23/client64/lib /opt/oracle/23/amd64/lib
|
||||
|
||||
# Oracle DB Client for arm64
|
||||
RUN mkdir -p /opt/oracle/23/arm64 \
|
||||
&& cd /opt/oracle/23/arm64 \
|
||||
&& wget https://download.oracle.com/otn_software/linux/instantclient/instantclient-basiclite-linux-arm64.zip \
|
||||
&& unzip instantclient-basiclite-linux-arm64.zip && rm instantclient-basiclite-linux-arm64.zip && mv instantclient* ./lib
|
||||
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
|
||||
mv /opt/oracle/23/arm64/lib /opt/oracle/23/lib; \
|
||||
else \
|
||||
mv /opt/oracle/23/amd64/lib /opt/oracle/23/lib; \
|
||||
fi
|
||||
|
||||
FROM ghcr.io/windmill-labs/windmill-ee:dev
|
||||
|
||||
RUN apt-get update && apt-get install -y libprotobuf-dev libnl-route-3-dev
|
||||
|
||||
# Rust
|
||||
COPY --from=rust:1.80.1 /usr/local/cargo /usr/local/cargo
|
||||
COPY --from=rust:1.80.1 /usr/local/rustup /usr/local/rustup
|
||||
RUN /usr/local/cargo/bin/cargo install cargo-sweep --version ^0.7
|
||||
|
||||
# Ansible
|
||||
RUN uv tool install ansible && [ -d "$(uv tool dir)/ansible/bin/" ] && find "$(uv tool dir)/ansible/bin/" -mindepth 1 -maxdepth 1 -type f -executable -regextype posix-extended -regex '^((.+/)?)[^.]+' -print0 | xargs -0 ln -s -t "$UV_TOOL_BIN_DIR/" || true
|
||||
|
||||
# dotnet SDK
|
||||
RUN wget https://dot.net/v1/dotnet-install.sh -O dotnet-install.sh \
|
||||
&& chmod +x dotnet-install.sh \
|
||||
&& ./dotnet-install.sh --channel 9.0 --install-dir /usr/share/dotnet \
|
||||
&& ln -s /usr/share/dotnet/dotnet /usr/bin/dotnet \
|
||||
&& rm 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
|
||||
RUN echo /opt/oracle/23/lib > /etc/ld.so.conf.d/oracle-instantclient.conf && ldconfig
|
||||
|
||||
# Nushell
|
||||
COPY --from=ghcr.io/nushell/nushell:0.101.0-bookworm /usr/bin/nu /usr/bin/nu
|
||||
|
||||
# Java
|
||||
RUN apt-get install -y default-jdk
|
||||
RUN curl -fLo coursier https://github.com/coursier/coursier/releases/download/v2.1.24/coursier \
|
||||
&& mv ./coursier /usr/bin/coursier \
|
||||
&& chmod +x /usr/bin/coursier
|
||||
RUN /usr/bin/java -jar /usr/bin/coursier about
|
||||
|
||||
# Ruby
|
||||
RUN apt-get install -y ruby ruby-bundler
|
||||
|
||||
# iptables
|
||||
RUN apt-get install -y iptables
|
||||
|
||||
COPY --from=nsjail /nsjail/nsjail /bin/nsjail
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
ARG DEBIAN_IMAGE=debian:bookworm-slim
|
||||
|
||||
FROM debian:bookworm-slim AS nsjail
|
||||
|
||||
WORKDIR /nsjail
|
||||
|
||||
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.* \
|
||||
libprotobuf-dev=3.21.* \
|
||||
libnl-route-3-dev=3.7.* \
|
||||
make=4.3-4.1 \
|
||||
pkg-config=1.8.* \
|
||||
protobuf-compiler=3.21.*
|
||||
|
||||
RUN git clone -b master --single-branch https://github.com/google/nsjail.git . && git checkout dccf911fd2659e7b08ce9507c25b2b38ec2c5800
|
||||
RUN make
|
||||
|
||||
FROM ${DEBIAN_IMAGE}
|
||||
|
||||
ARG APP=/usr/src/app
|
||||
@@ -39,6 +59,11 @@ COPY --from=oven/bun:1.3.8 /usr/local/bin/bun /usr/bin/bun
|
||||
# add the docker client to call docker from a worker if enabled
|
||||
COPY --from=docker:dind /usr/local/bin/docker /usr/local/bin/
|
||||
|
||||
# nsjail runtime deps and binary
|
||||
RUN apt-get update && apt-get install -y libprotobuf-dev libnl-route-3-dev \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=nsjail /nsjail/nsjail /bin/nsjail
|
||||
|
||||
WORKDIR ${APP}
|
||||
|
||||
COPY --from=ghcr.io/windmill-labs/windmill:dev --chmod=755 ${APP}/windmill ${APP}/windmill
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
ARG DEBIAN_IMAGE=debian:bookworm-slim
|
||||
|
||||
FROM debian:bookworm-slim AS nsjail
|
||||
|
||||
WORKDIR /nsjail
|
||||
|
||||
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.* \
|
||||
libprotobuf-dev=3.21.* \
|
||||
libnl-route-3-dev=3.7.* \
|
||||
make=4.3-4.1 \
|
||||
pkg-config=1.8.* \
|
||||
protobuf-compiler=3.21.*
|
||||
|
||||
RUN git clone -b master --single-branch https://github.com/google/nsjail.git . && git checkout dccf911fd2659e7b08ce9507c25b2b38ec2c5800
|
||||
RUN make
|
||||
|
||||
FROM ${DEBIAN_IMAGE}
|
||||
|
||||
ARG APP=/usr/src/app
|
||||
@@ -39,6 +59,11 @@ COPY --from=oven/bun:1.3.8 /usr/local/bin/bun /usr/bin/bun
|
||||
# add the docker client to call docker from a worker if enabled
|
||||
COPY --from=docker:dind /usr/local/bin/docker /usr/local/bin/
|
||||
|
||||
# nsjail runtime deps and binary
|
||||
RUN apt-get update && apt-get install -y libprotobuf-dev libnl-route-3-dev \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=nsjail /nsjail/nsjail /bin/nsjail
|
||||
|
||||
WORKDIR ${APP}
|
||||
|
||||
COPY --from=ghcr.io/windmill-labs/windmill-ee:dev --chmod=755 ${APP}/windmill ${APP}/windmill
|
||||
|
||||
@@ -75,13 +75,6 @@ def patch_docker_compose [
|
||||
$compose.services.windmill_server.pull_policy = "never"
|
||||
$compose.services.windmill_indexer.pull_policy = "never"
|
||||
|
||||
if ($custom_dockerfile | default 'none' | path basename) == "DockerfileNsjail" {
|
||||
$compose.services.windmill_worker.privileged = true
|
||||
$compose.services.windmill_worker_native.privileged = true
|
||||
|
||||
$compose.services.windmill_worker.environment ++= ["DISABLE_NSJAIL=false"]
|
||||
$compose.services.windmill_worker_native.environment ++= ["DISABLE_NSJAIL=false"]
|
||||
}
|
||||
return ($compose | to yaml)
|
||||
}
|
||||
|
||||
|
||||
@@ -113,8 +113,8 @@ it can build the image and run on local repository.
|
||||
|
||||
```bash
|
||||
# Issue the build
|
||||
docker/dev.nu up --features "python,static_frontend" docker/DockerfileNsjail --rebuild
|
||||
# Will create and run `main__nsjail__python-static_frontend`
|
||||
docker/dev.nu up --features "python,static_frontend" --rebuild
|
||||
# Will create and run `main-python-static_frontend`
|
||||
```
|
||||
|
||||
If you develop wasm parser for new language you can also pass `--wasm-pkg <language>` and it will include local parser to the image. For more information please see the script directly or run it with `--help` flag.
|
||||
|
||||
118
frontend/package-lock.json
generated
118
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "windmill-components",
|
||||
"version": "1.624.0",
|
||||
"version": "1.625.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "windmill-components",
|
||||
"version": "1.624.0",
|
||||
"version": "1.625.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
@@ -47,6 +47,7 @@
|
||||
"hash-sum": "^2.0.0",
|
||||
"highlight.js": "^11.8.0",
|
||||
"idb": "^8.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"lru-cache": "^11.1.0",
|
||||
"lucide-svelte": "^0.540.0",
|
||||
"minimatch": "^10.0.1",
|
||||
@@ -78,11 +79,11 @@
|
||||
"windmill-parser-wasm-java": "1.510.1",
|
||||
"windmill-parser-wasm-nu": "1.510.1",
|
||||
"windmill-parser-wasm-php": "1.574.1",
|
||||
"windmill-parser-wasm-py": "1.601.1",
|
||||
"windmill-parser-wasm-regex": "1.593.0",
|
||||
"windmill-parser-wasm-py": "1.623.1",
|
||||
"windmill-parser-wasm-regex": "1.623.1",
|
||||
"windmill-parser-wasm-ruby": "1.526.1",
|
||||
"windmill-parser-wasm-rust": "1.558.1",
|
||||
"windmill-parser-wasm-ts": "1.593.0",
|
||||
"windmill-parser-wasm-ts": "1.623.1",
|
||||
"windmill-parser-wasm-yaml": "1.593.0",
|
||||
"windmill-sql-datatype-parser-wasm": "1.512.0",
|
||||
"windmill-utils-internal": "^1.3.2",
|
||||
@@ -4308,6 +4309,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cosmiconfig": {
|
||||
"version": "8.3.6",
|
||||
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
|
||||
@@ -6926,7 +6933,6 @@
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"devOptional": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ini": {
|
||||
@@ -7129,6 +7135,12 @@
|
||||
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
@@ -7331,6 +7343,54 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/jszip/node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jszip/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -7572,6 +7632,21 @@
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lie/node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||
@@ -10781,6 +10856,12 @@
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/property-information": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
|
||||
@@ -11591,6 +11672,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -13351,7 +13438,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/validate-npm-package-license": {
|
||||
@@ -14517,14 +14603,14 @@
|
||||
"integrity": "sha512-COyid6B1RYs+bpzUCInsA4HY/WZkpDLfkQ90+AqU/TVTpzYSbAC2JCbIwy0cRElBvlhI4bQ+9Wg6hSQKMpEkpA=="
|
||||
},
|
||||
"node_modules/windmill-parser-wasm-py": {
|
||||
"version": "1.601.1",
|
||||
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-py/-/windmill-parser-wasm-py-1.601.1.tgz",
|
||||
"integrity": "sha512-xcNZE/8B29yfl6UuQDPSXMD+83/W2Hzt2uhn+WrNvy0+qzk6nLh/vJGrf2srLBngYX1TxhUI5Jgseg0PK9yvNw=="
|
||||
"version": "1.623.1",
|
||||
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-py/-/windmill-parser-wasm-py-1.623.1.tgz",
|
||||
"integrity": "sha512-lFBlZg6hvhHzsU5oPJq0478UyMTZ9UKVd8Hc8ggmxPIHZaJBeJ+56NR75hmGwg0VJcRff8ed+zEm1PgmjVhD+w=="
|
||||
},
|
||||
"node_modules/windmill-parser-wasm-regex": {
|
||||
"version": "1.593.0",
|
||||
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-regex/-/windmill-parser-wasm-regex-1.593.0.tgz",
|
||||
"integrity": "sha512-m8BvTGJc2710YODmKKDXiASfssoJ/YFJGfYRhRnQvltENvaRee2NZuf4XoUkOVJESDdCSEX6U6fVkFvF9rXp2Q=="
|
||||
"version": "1.623.1",
|
||||
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-regex/-/windmill-parser-wasm-regex-1.623.1.tgz",
|
||||
"integrity": "sha512-rW3pl4ysIXmVAmwxSTKhR2sUfScXKfTqxrUebDg0ZNcUzY97S8mNRdQGZfaQ1NWhWPRQNXfL/z/yaUOQh5ConQ=="
|
||||
},
|
||||
"node_modules/windmill-parser-wasm-ruby": {
|
||||
"version": "1.526.1",
|
||||
@@ -14537,9 +14623,9 @@
|
||||
"integrity": "sha512-21S7lm1KF8zO1187rbq14hzPHII2RdM2+D44MoAh1F6VoaScj+Puq0z5B1O/hwn/95R/a9jBlL2D8jbkXtlD1A=="
|
||||
},
|
||||
"node_modules/windmill-parser-wasm-ts": {
|
||||
"version": "1.593.0",
|
||||
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.593.0.tgz",
|
||||
"integrity": "sha512-NFY9gaEIpJOwGZJeGYDS3+/16QiPYdxFgmb1bKhXyEAdWjYMQ+otrTezf+K09lmvPzB+len37GlNCU72OgyQ6A=="
|
||||
"version": "1.623.1",
|
||||
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.623.1.tgz",
|
||||
"integrity": "sha512-FBwi/zXxjhZcCvi04oFdNivazru1ynIqSbafHSArfaaBWesBO3nye9UO/WXUlWZm5a7BExbU+3R/eVJrGaornw=="
|
||||
},
|
||||
"node_modules/windmill-parser-wasm-yaml": {
|
||||
"version": "1.593.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "windmill-components",
|
||||
"version": "1.624.0",
|
||||
"version": "1.625.0",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
@@ -117,6 +117,7 @@
|
||||
"hash-sum": "^2.0.0",
|
||||
"highlight.js": "^11.8.0",
|
||||
"idb": "^8.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"lru-cache": "^11.1.0",
|
||||
"lucide-svelte": "^0.540.0",
|
||||
"minimatch": "^10.0.1",
|
||||
@@ -148,11 +149,11 @@
|
||||
"windmill-parser-wasm-java": "1.510.1",
|
||||
"windmill-parser-wasm-nu": "1.510.1",
|
||||
"windmill-parser-wasm-php": "1.574.1",
|
||||
"windmill-parser-wasm-py": "1.601.1",
|
||||
"windmill-parser-wasm-regex": "1.593.0",
|
||||
"windmill-parser-wasm-py": "1.623.1",
|
||||
"windmill-parser-wasm-regex": "1.623.1",
|
||||
"windmill-parser-wasm-ruby": "1.526.1",
|
||||
"windmill-parser-wasm-rust": "1.558.1",
|
||||
"windmill-parser-wasm-ts": "1.593.0",
|
||||
"windmill-parser-wasm-ts": "1.623.1",
|
||||
"windmill-parser-wasm-yaml": "1.593.0",
|
||||
"windmill-sql-datatype-parser-wasm": "1.512.0",
|
||||
"windmill-utils-internal": "^1.3.2",
|
||||
|
||||
@@ -112,7 +112,6 @@
|
||||
} else if (kind === 'folder') {
|
||||
const folder = await FolderService.getFolder({ workspace, name: path.slice(2) })
|
||||
return folder.summary
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch summary for ${kind}:${path}`, error)
|
||||
@@ -122,7 +121,9 @@
|
||||
|
||||
async function fetchSummaries(diffs: WorkspaceItemDiff[]) {
|
||||
// Only fetch summaries for scripts, flows, and apps
|
||||
const itemsToFetch = diffs.filter((diff) => ['script', 'flow', 'app', 'folder'].includes(diff.kind))
|
||||
const itemsToFetch = diffs.filter((diff) =>
|
||||
['script', 'flow', 'app', 'folder'].includes(diff.kind)
|
||||
)
|
||||
|
||||
for (const diff of itemsToFetch) {
|
||||
const key = getItemKey(diff)
|
||||
@@ -915,7 +916,6 @@
|
||||
kind={diff.kind}
|
||||
canFavorite={false}
|
||||
workspaceId=""
|
||||
starred={false}
|
||||
>
|
||||
{#snippet customSummary()}
|
||||
{#if oldSummary != newSummary && isSelectable && existsInBothWorkspaces}
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
import { resource } from 'runed'
|
||||
import { capitalize, onlyAlphaNumAndUnderscore, pluralize } from '$lib/utils'
|
||||
import type { DbFeatures } from './apps/components/display/dbtable/dbFeatures'
|
||||
import Star from './Star.svelte'
|
||||
import type { Asset } from '$lib/gen'
|
||||
|
||||
/** Represents a selected table with its schema */
|
||||
export interface SelectedTable {
|
||||
@@ -50,6 +52,7 @@
|
||||
/** Tables that are already added and should show as disabled */
|
||||
disabledTables?: SelectedTable[]
|
||||
features?: DbFeatures
|
||||
asset?: Asset
|
||||
}
|
||||
let {
|
||||
dbType,
|
||||
@@ -67,7 +70,8 @@
|
||||
multiSelectMode = false,
|
||||
selectedTables = $bindable([]),
|
||||
disabledTables = [],
|
||||
features
|
||||
features,
|
||||
asset
|
||||
}: Props = $props()
|
||||
|
||||
// Helper to check if a table is selected in multi-select mode
|
||||
@@ -442,16 +446,26 @@
|
||||
{:else}
|
||||
<!-- Normal mode: show tables for selected schema -->
|
||||
{#each filteredTableKeys as tableKey}
|
||||
<!-- PLACEHOLDER -->
|
||||
<button
|
||||
class={'w-full text-sm font-normal flex gap-2 items-center h-10 cursor-pointer pl-3 pr-1 ' +
|
||||
(selected.tableKey === tableKey ? 'bg-gray-500/25' : 'hover:bg-gray-500/10')}
|
||||
(selected.tableKey === tableKey ? 'bg-surface-secondary' : 'hover:bg-surface-hover')}
|
||||
onclick={() => (selected.tableKey = tableKey)}
|
||||
>
|
||||
<Table2 class="text-primary shrink-0" size={16} />
|
||||
{#if asset}
|
||||
<Star
|
||||
kind="asset"
|
||||
path={`${asset.kind}://${asset.path == 'main' ? '' : asset.path}/${selected.schemaKey}.${tableKey}`}
|
||||
/>
|
||||
{:else}
|
||||
<Table2 class="text-primary shrink-0" size={14} />
|
||||
{/if}
|
||||
|
||||
<p
|
||||
class="db-manager-table-key truncate text-ellipsis grow text-left text-emphasis text-xs"
|
||||
>{tableKey}</p
|
||||
>
|
||||
{tableKey}
|
||||
</p>
|
||||
<DropdownV2
|
||||
items={() => [
|
||||
{
|
||||
|
||||
@@ -164,6 +164,11 @@
|
||||
})}
|
||||
initialTableKey={input.specificTable}
|
||||
initialSchemaKey={input.type == 'database' ? input.specificSchema : undefined}
|
||||
asset={_input.type == 'ducklake'
|
||||
? { kind: 'ducklake', path: _input.ducklake }
|
||||
: _input.resourcePath.startsWith('datatable://')
|
||||
? { kind: 'datatable', path: _input.resourcePath.substring('datatable://'.length) }
|
||||
: undefined}
|
||||
{dbType}
|
||||
refresh={() => refresh()}
|
||||
{dbSelector}
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
)
|
||||
|
||||
export function openDrawer(nInput: DbInput) {
|
||||
console.log('Opening DB Manager with input:', nInput)
|
||||
input = nInput
|
||||
if (isDatatableInput) {
|
||||
datatables.refetch()
|
||||
@@ -73,6 +74,8 @@
|
||||
input = undefined
|
||||
selectedDatatable = undefined
|
||||
dbManagerContent?.clearReplResult()
|
||||
if (window.location.hash.startsWith('#dbmanager:'))
|
||||
history.replaceState('', document.title, window.location.href.replace(/#dbmanager:.*$/, ''))
|
||||
}
|
||||
|
||||
let windowWidth = $state(window.innerWidth)
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { globalDbManagerDrawer, workspaceStore } from '$lib/stores'
|
||||
|
||||
import Select from './select/Select.svelte'
|
||||
import DbManagerDrawer from './DBManagerDrawer.svelte'
|
||||
import ExploreAssetButton, { assetCanBeExplored } from './ExploreAssetButton.svelte'
|
||||
import { usePromise } from '$lib/svelte5Utils.svelte'
|
||||
|
||||
@@ -32,7 +31,7 @@
|
||||
let ducklakes = usePromise(() =>
|
||||
WorkspaceService.listDucklakes({ workspace: $workspaceStore ?? '' })
|
||||
)
|
||||
let dbManagerDrawer: DbManagerDrawer | undefined = $state()
|
||||
let dbManagerDrawer = $derived(globalDbManagerDrawer.val)
|
||||
</script>
|
||||
|
||||
<div class={className}>
|
||||
@@ -53,6 +52,4 @@
|
||||
{dbManagerDrawer}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<DbManagerDrawer bind:this={dbManagerDrawer} />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { ResourceService } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { globalDbManagerDrawer, workspaceStore } from '$lib/stores'
|
||||
import { onMount, untrack } from 'svelte'
|
||||
import AppConnect from './AppConnectDrawer.svelte'
|
||||
import ResourceEditorDrawer from './ResourceEditorDrawer.svelte'
|
||||
@@ -9,7 +9,6 @@
|
||||
import { Loader2, Pen, Plus, RotateCw } from 'lucide-svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import Select from './select/Select.svelte'
|
||||
import DbManagerDrawer from './DBManagerDrawer.svelte'
|
||||
import ExploreAssetButton, { assetCanBeExplored } from './ExploreAssetButton.svelte'
|
||||
import DropdownV2 from './DropdownV2.svelte'
|
||||
|
||||
@@ -156,7 +155,7 @@
|
||||
|
||||
let appConnect: AppConnect | undefined = $state()
|
||||
let resourceEditor: ResourceEditorDrawer | undefined = $state()
|
||||
let dbManagerDrawer: DbManagerDrawer | undefined = $state()
|
||||
let dbManagerDrawer = $derived(globalDbManagerDrawer.val)
|
||||
let hovering = $state(false)
|
||||
</script>
|
||||
|
||||
@@ -300,5 +299,3 @@
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<DbManagerDrawer bind:this={dbManagerDrawer} />
|
||||
|
||||
@@ -1,41 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { preventDefault } from 'svelte/legacy'
|
||||
|
||||
import { FavoriteService } from '$lib/gen'
|
||||
import { starStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { Star, StarOff } from 'lucide-svelte'
|
||||
import { favoriteManager, type FavoriteKind } from './sidebar/FavoriteMenu.svelte'
|
||||
|
||||
interface Props {
|
||||
path: string
|
||||
kind: 'flow' | 'app' | 'script' | 'raw_app'
|
||||
starred?: boolean
|
||||
workspace_id: string
|
||||
onStarred?: (starred: boolean) => void
|
||||
kind: FavoriteKind
|
||||
summary?: string
|
||||
workspaceId?: string
|
||||
}
|
||||
|
||||
let { path, kind, starred = false, workspace_id, onStarred }: Props = $props()
|
||||
let { path, kind, workspaceId, summary }: Props = $props()
|
||||
|
||||
let buttonHover = $state(false)
|
||||
let starred = $derived(favoriteManager.isStarred(path, kind))
|
||||
|
||||
async function onClick() {
|
||||
buttonHover = false
|
||||
if (starred) {
|
||||
await FavoriteService.unstar({
|
||||
workspace: workspace_id,
|
||||
requestBody: { path, favorite_kind: kind }
|
||||
})
|
||||
sendUserToast('Unstarred')
|
||||
$starStore = $starStore + 1
|
||||
} else {
|
||||
await FavoriteService.star({
|
||||
workspace: workspace_id,
|
||||
requestBody: { path, favorite_kind: kind }
|
||||
})
|
||||
sendUserToast('Marked as favorite, it will appear first')
|
||||
$starStore = $starStore + 1
|
||||
}
|
||||
onStarred?.(!starred)
|
||||
if (starred) favoriteManager.unstar(path, kind, workspaceId)
|
||||
else favoriteManager.star(path, kind, workspaceId, summary)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -43,7 +27,7 @@
|
||||
onclick={preventDefault(onClick)}
|
||||
onmouseenter={() => (buttonHover = true)}
|
||||
onmouseleave={() => (buttonHover = false)}
|
||||
class="p-2"
|
||||
class="p-1"
|
||||
>
|
||||
{#if starred}
|
||||
{#if buttonHover}
|
||||
|
||||
@@ -2,13 +2,19 @@
|
||||
import Markdown from 'svelte-exmarkdown'
|
||||
import { ExternalLink } from 'lucide-svelte'
|
||||
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export let documentationLink: string | undefined = undefined
|
||||
export let markdownTooltip: string | undefined = undefined
|
||||
export let customBgClass: string | undefined = undefined
|
||||
const plugins = [gfmPlugin()]
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="shadow-lg max-w-sm break-words py-2 px-3 rounded-md text-xs font-normal text-primary bg-surface-secondary whitespace-normal text-left dark:border max-h-64 overflow-y-auto"
|
||||
class={twMerge(
|
||||
'shadow-lg max-w-sm break-words py-2 px-3 rounded-md text-xs font-normal text-primary whitespace-normal text-left dark:border max-h-64 overflow-y-auto',
|
||||
customBgClass || 'bg-surface-secondary'
|
||||
)}
|
||||
>
|
||||
{#if markdownTooltip}
|
||||
<div class="prose-sm">
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
--ag-input-disabled-border-color: rgba(186, 191, 199, 0.3);
|
||||
--ag-input-disabled-background-color: rgba(186, 191, 199, 0.15);
|
||||
--ag-borders: solid 1px;
|
||||
--ag-border-radius: 8px;
|
||||
--ag-border-radius: 4px;
|
||||
--ag-borders-side-button: none;
|
||||
--ag-side-button-selected-background-color: transparent;
|
||||
--ag-header-column-resize-handle-display: block;
|
||||
@@ -66,8 +66,9 @@
|
||||
--ag-widget-vertical-spacing: calc(var(--ag-grid-size) * 1.5);
|
||||
--ag-toggle-button-height: 18px;
|
||||
--ag-toggle-button-width: 28px;
|
||||
--ag-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu,
|
||||
Cantarell, 'Helvetica Neue', sans-serif;
|
||||
--ag-font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell,
|
||||
'Helvetica Neue', sans-serif;
|
||||
--ag-font-size: 0.7rem;
|
||||
--ag-icon-font-family: agGridalpine;
|
||||
--ag-selected-tab-underline-color: var(--ag-alpine-active-color);
|
||||
@@ -92,8 +93,8 @@
|
||||
--ag-control-panel-background-color: #222628;
|
||||
--ag-subheader-background-color: #000;
|
||||
--ag-input-disabled-background-color: #282c2f;
|
||||
--ag-input-focus-box-shadow: 0 0 2px 0.5px rgba(255, 255, 255, 0.5),
|
||||
0 0 4px 3px var(--ag-input-focus-border-color);
|
||||
--ag-input-focus-box-shadow:
|
||||
0 0 2px 0.5px rgba(255, 255, 255, 0.5), 0 0 4px 3px var(--ag-input-focus-border-color);
|
||||
--ag-card-shadow: 0 1px 20px 1px black;
|
||||
--ag-disabled-foreground-color: rgba(255, 255, 255, 0.5);
|
||||
--ag-chip-background-color: rgba(255, 255, 255, 0.07);
|
||||
@@ -119,8 +120,8 @@
|
||||
--ag-control-panel-background-color: #222628;
|
||||
--ag-subheader-background-color: #000;
|
||||
--ag-input-disabled-background-color: #282c2f;
|
||||
--ag-input-focus-box-shadow: 0 0 2px 0.5px rgba(255, 255, 255, 0.5),
|
||||
0 0 4px 3px var(--ag-input-focus-border-color);
|
||||
--ag-input-focus-box-shadow:
|
||||
0 0 2px 0.5px rgba(255, 255, 255, 0.5), 0 0 4px 3px var(--ag-input-focus-border-color);
|
||||
--ag-card-shadow: 0 1px 20px 1px black;
|
||||
--ag-disabled-foreground-color: rgba(255, 255, 255, 0.5);
|
||||
--ag-chip-background-color: rgba(255, 255, 255, 0.07);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user