From ad97bcf11003985e12446a316665dc43cf6fc6a0 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Wed, 9 Sep 2026 14:20:37 +0200 Subject: [PATCH] test: pin the two-key move, and stop the down migration breaking instances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revoking the sequence grant would strip a privilege a healthy instance had before this migration ran — the grant it adds is indistinguishable in the catalog from the one ALTER DEFAULT PRIVILEGES gives at creation time — so the down is a comment, matching the other grant-only migrations. The mirror rewrite is spread over three sites that have to agree and fails silently when they don't, so it gets a test: a draft carrying both path keys has both moved, and one carrying neither mirror does not gain one. It reads the value back over HTTP rather than with `sqlx::query!`, which would need an offline cache entry of its own. Also drops twelve `.sqlx` entries this branch added and then superseded, and corrects the doc and openapi text that still described only the typed path being rewritten. Co-Authored-By: Claude Opus 5 (1M context) --- ...6e8f2a00674eb87a5d025484751674656ca25.json | 59 ---------------- ...994caad0c7a1c05374f872f87ea838a412801.json | 26 ------- ...bf16fad254b0b070317d9bed4fd97541a0272.json | 26 ------- ...685fc9f4c2947ab6aef60b36d09c0edd1ce89.json | 36 ---------- ...c2d2e2319d9e87a9deb05238f311928201695.json | 62 ---------------- ...681ccec81817226a5d31927b10bf97152c8d9.json | 36 ---------- ...197c9249a79402742e0345a7fdf5f43843917.json | 26 ------- ...c9ef7af9869ceeee8030b6bd17d9103931e2f.json | 16 ----- ...8f2d467a10ae71ec0aa3f4aae603be1453798.json | 36 ---------- ...3828956701d9f9a08f8c202446ea06f292d46.json | 35 ---------- ...52dbff1d6ef4c7c0118574b98e80a7b179e94.json | 62 ---------------- ...d84637926113f1f6aef6b58e24c88930f1432.json | 62 ---------------- ...20260909110835_grant_draft_id_seq.down.sql | 7 +- backend/tests/drafts_move_mirror.rs | 70 +++++++++++++++++++ backend/tests/fixtures/drafts_move_mirror.sql | 16 +++++ backend/windmill-api-flows/src/flows.rs | 2 +- backend/windmill-api/openapi.yaml | 2 +- backend/windmill-api/src/apps.rs | 2 +- backend/windmill-api/src/drafts.rs | 4 +- 19 files changed, 96 insertions(+), 489 deletions(-) delete mode 100644 backend/.sqlx/query-10f8e68e31df7b6e0bcd10091a46e8f2a00674eb87a5d025484751674656ca25.json delete mode 100644 backend/.sqlx/query-30958446d29c3fa521bda176c23994caad0c7a1c05374f872f87ea838a412801.json delete mode 100644 backend/.sqlx/query-381d6ead880f0f0c32e6d87d053bf16fad254b0b070317d9bed4fd97541a0272.json delete mode 100644 backend/.sqlx/query-56c66db90fc43db69357c41d55d685fc9f4c2947ab6aef60b36d09c0edd1ce89.json delete mode 100644 backend/.sqlx/query-5980a2c9348982eab53e092496dc2d2e2319d9e87a9deb05238f311928201695.json delete mode 100644 backend/.sqlx/query-5aafeaefd99e25c66c705e1cbc8681ccec81817226a5d31927b10bf97152c8d9.json delete mode 100644 backend/.sqlx/query-666eea2c3d584885923d86b296e197c9249a79402742e0345a7fdf5f43843917.json delete mode 100644 backend/.sqlx/query-6e4f241221ff60557d989890b06c9ef7af9869ceeee8030b6bd17d9103931e2f.json delete mode 100644 backend/.sqlx/query-9031e85807fb009d212ab5e9a158f2d467a10ae71ec0aa3f4aae603be1453798.json delete mode 100644 backend/.sqlx/query-ab4f1762b923774f43d5ed088d43828956701d9f9a08f8c202446ea06f292d46.json delete mode 100644 backend/.sqlx/query-af8981997f299b5c943bbdffb4d52dbff1d6ef4c7c0118574b98e80a7b179e94.json delete mode 100644 backend/.sqlx/query-c902cf38cea14284032312f833ed84637926113f1f6aef6b58e24c88930f1432.json create mode 100644 backend/tests/drafts_move_mirror.rs create mode 100644 backend/tests/fixtures/drafts_move_mirror.sql diff --git a/backend/.sqlx/query-10f8e68e31df7b6e0bcd10091a46e8f2a00674eb87a5d025484751674656ca25.json b/backend/.sqlx/query-10f8e68e31df7b6e0bcd10091a46e8f2a00674eb87a5d025484751674656ca25.json deleted file mode 100644 index 28cb1e2ee5..0000000000 --- a/backend/.sqlx/query-10f8e68e31df7b6e0bcd10091a46e8f2a00674eb87a5d025484751674656ca25.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email = $4", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "?column?", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - { - "Custom": { - "name": "draft_kind", - "kind": { - "Enum": [ - "script", - "flow", - "app", - "raw_app", - "resource", - "variable", - "trigger_schedule", - "trigger_webhook", - "trigger_default_email", - "trigger_email", - "trigger_http", - "trigger_websocket", - "trigger_postgres", - "trigger_kafka", - "trigger_nats", - "trigger_mqtt", - "trigger_sqs", - "trigger_gcp", - "trigger_azure", - "trigger_poll", - "trigger_cli", - "trigger_nextcloud", - "trigger_google", - "trigger_github", - "data_pipeline", - "trigger_amqp" - ] - } - } - }, - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "10f8e68e31df7b6e0bcd10091a46e8f2a00674eb87a5d025484751674656ca25" -} diff --git a/backend/.sqlx/query-30958446d29c3fa521bda176c23994caad0c7a1c05374f872f87ea838a412801.json b/backend/.sqlx/query-30958446d29c3fa521bda176c23994caad0c7a1c05374f872f87ea838a412801.json deleted file mode 100644 index 76098d2efd..0000000000 --- a/backend/.sqlx/query-30958446d29c3fa521bda176c23994caad0c7a1c05374f872f87ea838a412801.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE draft AS d\n SET path = $3,\n value = to_json(\n jsonb_set(to_jsonb(d.value), ARRAY[$4::text], to_jsonb($3::text), false)\n )\n WHERE d.workspace_id = $1\n AND d.path = $2\n AND d.typ::text = ANY($5::text[])\n AND NOT EXISTS (\n SELECT 1 FROM draft o\n WHERE o.workspace_id = d.workspace_id\n AND o.path = $3\n AND o.typ = d.typ\n AND o.email IS NOT DISTINCT FROM d.email\n )\n RETURNING d.id", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - "Text", - "Text", - "TextArray" - ] - }, - "nullable": [ - false - ] - }, - "hash": "30958446d29c3fa521bda176c23994caad0c7a1c05374f872f87ea838a412801" -} diff --git a/backend/.sqlx/query-381d6ead880f0f0c32e6d87d053bf16fad254b0b070317d9bed4fd97541a0272.json b/backend/.sqlx/query-381d6ead880f0f0c32e6d87d053bf16fad254b0b070317d9bed4fd97541a0272.json deleted file mode 100644 index d5781ac152..0000000000 --- a/backend/.sqlx/query-381d6ead880f0f0c32e6d87d053bf16fad254b0b070317d9bed4fd97541a0272.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE draft AS d\n SET path = $3,\n value = to_json(\n CASE WHEN to_jsonb(d.value) -> $4::text = to_jsonb($2::text)\n THEN jsonb_set(to_jsonb(d.value), ARRAY[$4::text], to_jsonb($3::text), false)\n ELSE to_jsonb(d.value)\n END\n )\n WHERE d.workspace_id = $1\n AND d.path = $2\n AND d.typ::text = ANY($5::text[])\n AND NOT EXISTS (\n SELECT 1 FROM draft o\n WHERE o.workspace_id = d.workspace_id\n AND o.path = $3\n AND o.typ = d.typ\n AND o.email IS NOT DISTINCT FROM d.email\n )\n RETURNING d.id", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - "Text", - "Text", - "TextArray" - ] - }, - "nullable": [ - false - ] - }, - "hash": "381d6ead880f0f0c32e6d87d053bf16fad254b0b070317d9bed4fd97541a0272" -} diff --git a/backend/.sqlx/query-56c66db90fc43db69357c41d55d685fc9f4c2947ab6aef60b36d09c0edd1ce89.json b/backend/.sqlx/query-56c66db90fc43db69357c41d55d685fc9f4c2947ab6aef60b36d09c0edd1ce89.json deleted file mode 100644 index 95051826b6..0000000000 --- a/backend/.sqlx/query-56c66db90fc43db69357c41d55d685fc9f4c2947ab6aef60b36d09c0edd1ce89.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT\n EXISTS(SELECT 1 FROM flow WHERE workspace_id = $1 AND path = $2) as \"still_here!\",\n (SELECT fv.path FROM flow_version fv\n JOIN flow f ON f.workspace_id = fv.workspace_id AND f.path = fv.path\n WHERE fv.id = $3 AND fv.workspace_id = $1) as new_path,\n (SELECT f.edited_by FROM flow_version fv\n JOIN flow f ON f.workspace_id = fv.workspace_id AND f.path = fv.path\n WHERE fv.id = $3 AND fv.workspace_id = $1) as new_by", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "still_here!", - "type_info": "Bool" - }, - { - "ordinal": 1, - "name": "new_path", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "new_by", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - "Int8" - ] - }, - "nullable": [ - null, - null, - null - ] - }, - "hash": "56c66db90fc43db69357c41d55d685fc9f4c2947ab6aef60b36d09c0edd1ce89" -} diff --git a/backend/.sqlx/query-5980a2c9348982eab53e092496dc2d2e2319d9e87a9deb05238f311928201695.json b/backend/.sqlx/query-5980a2c9348982eab53e092496dc2d2e2319d9e87a9deb05238f311928201695.json deleted file mode 100644 index 82cbd1b980..0000000000 --- a/backend/.sqlx/query-5980a2c9348982eab53e092496dc2d2e2319d9e87a9deb05238f311928201695.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE draft\n SET path = $3,\n value = to_json(\n jsonb_set(\n CASE WHEN $7::text IS NULL THEN to_jsonb(value)\n ELSE jsonb_set(to_jsonb(value), ARRAY['summary'], to_jsonb($7::text))\n END,\n ARRAY[$5::text], to_jsonb($3::text), false\n )\n )\n WHERE workspace_id = $1\n AND path = $2\n AND typ = $4\n AND email = $6\n AND NOT EXISTS (\n SELECT 1 FROM draft o\n WHERE o.workspace_id = $1 AND o.path = $3 AND o.typ = $4 AND o.email = $6\n )\n RETURNING id", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - "Text", - { - "Custom": { - "name": "draft_kind", - "kind": { - "Enum": [ - "script", - "flow", - "app", - "raw_app", - "resource", - "variable", - "trigger_schedule", - "trigger_webhook", - "trigger_default_email", - "trigger_email", - "trigger_http", - "trigger_websocket", - "trigger_postgres", - "trigger_kafka", - "trigger_nats", - "trigger_mqtt", - "trigger_sqs", - "trigger_gcp", - "trigger_azure", - "trigger_poll", - "trigger_cli", - "trigger_nextcloud", - "trigger_google", - "trigger_github", - "data_pipeline", - "trigger_amqp" - ] - } - } - }, - "Text", - "Text", - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "5980a2c9348982eab53e092496dc2d2e2319d9e87a9deb05238f311928201695" -} diff --git a/backend/.sqlx/query-5aafeaefd99e25c66c705e1cbc8681ccec81817226a5d31927b10bf97152c8d9.json b/backend/.sqlx/query-5aafeaefd99e25c66c705e1cbc8681ccec81817226a5d31927b10bf97152c8d9.json deleted file mode 100644 index 827cb187ab..0000000000 --- a/backend/.sqlx/query-5aafeaefd99e25c66c705e1cbc8681ccec81817226a5d31927b10bf97152c8d9.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT\n EXISTS(SELECT 1 FROM app WHERE workspace_id = $1 AND path = $2) as \"still_here!\",\n (SELECT a.path FROM app_version av JOIN app a ON a.id = av.app_id\n WHERE av.id = $3 AND a.workspace_id = $1) as new_path,\n (SELECT av.created_by FROM app_version av JOIN app a ON a.id = av.app_id\n WHERE av.id = $3 AND a.workspace_id = $1) as new_by", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "still_here!", - "type_info": "Bool" - }, - { - "ordinal": 1, - "name": "new_path", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "new_by", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - "Int8" - ] - }, - "nullable": [ - null, - null, - null - ] - }, - "hash": "5aafeaefd99e25c66c705e1cbc8681ccec81817226a5d31927b10bf97152c8d9" -} diff --git a/backend/.sqlx/query-666eea2c3d584885923d86b296e197c9249a79402742e0345a7fdf5f43843917.json b/backend/.sqlx/query-666eea2c3d584885923d86b296e197c9249a79402742e0345a7fdf5f43843917.json deleted file mode 100644 index e392c6e895..0000000000 --- a/backend/.sqlx/query-666eea2c3d584885923d86b296e197c9249a79402742e0345a7fdf5f43843917.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE draft AS d\n SET path = $3,\n value = CASE\n WHEN position(chr(92) || 'u0000' in d.value::text) > 0 THEN d.value\n WHEN to_jsonb(d.value) -> $4::text = to_jsonb($2::text)\n THEN to_json(jsonb_set(to_jsonb(d.value), ARRAY[$4::text], to_jsonb($3::text), false))\n ELSE d.value\n END\n WHERE d.workspace_id = $1\n AND d.path = $2\n AND d.typ::text = ANY($5::text[])\n AND NOT EXISTS (\n SELECT 1 FROM draft o\n WHERE o.workspace_id = d.workspace_id\n AND o.path = $3\n AND o.typ = d.typ\n AND o.email IS NOT DISTINCT FROM d.email\n )\n RETURNING d.id", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - "Text", - "Text", - "TextArray" - ] - }, - "nullable": [ - false - ] - }, - "hash": "666eea2c3d584885923d86b296e197c9249a79402742e0345a7fdf5f43843917" -} diff --git a/backend/.sqlx/query-6e4f241221ff60557d989890b06c9ef7af9869ceeee8030b6bd17d9103931e2f.json b/backend/.sqlx/query-6e4f241221ff60557d989890b06c9ef7af9869ceeee8030b6bd17d9103931e2f.json deleted file mode 100644 index 85398f089f..0000000000 --- a/backend/.sqlx/query-6e4f241221ff60557d989890b06c9ef7af9869ceeee8030b6bd17d9103931e2f.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE draft\n SET value = to_json(\n CASE WHEN to_jsonb(value) -> 'path' = to_jsonb($2::text)\n THEN jsonb_set(to_jsonb(value), ARRAY['path'], to_jsonb($1::text), false)\n ELSE to_jsonb(value)\n END\n )\n WHERE id = ANY($3)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text", - "Int8Array" - ] - }, - "nullable": [] - }, - "hash": "6e4f241221ff60557d989890b06c9ef7af9869ceeee8030b6bd17d9103931e2f" -} diff --git a/backend/.sqlx/query-9031e85807fb009d212ab5e9a158f2d467a10ae71ec0aa3f4aae603be1453798.json b/backend/.sqlx/query-9031e85807fb009d212ab5e9a158f2d467a10ae71ec0aa3f4aae603be1453798.json deleted file mode 100644 index 57a212c68d..0000000000 --- a/backend/.sqlx/query-9031e85807fb009d212ab5e9a158f2d467a10ae71ec0aa3f4aae603be1453798.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT\n EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2\n AND NOT archived AND NOT deleted) as \"still_here!\",\n (SELECT path FROM script WHERE workspace_id = $1 AND $3 = ANY(parent_hashes)\n AND NOT archived AND NOT deleted ORDER BY created_at DESC LIMIT 1) as new_path,\n (SELECT created_by FROM script WHERE workspace_id = $1 AND $3 = ANY(parent_hashes)\n AND NOT archived AND NOT deleted ORDER BY created_at DESC LIMIT 1) as new_by", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "still_here!", - "type_info": "Bool" - }, - { - "ordinal": 1, - "name": "new_path", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "new_by", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - "Int8" - ] - }, - "nullable": [ - null, - null, - null - ] - }, - "hash": "9031e85807fb009d212ab5e9a158f2d467a10ae71ec0aa3f4aae603be1453798" -} diff --git a/backend/.sqlx/query-ab4f1762b923774f43d5ed088d43828956701d9f9a08f8c202446ea06f292d46.json b/backend/.sqlx/query-ab4f1762b923774f43d5ed088d43828956701d9f9a08f8c202446ea06f292d46.json deleted file mode 100644 index 43e11ac221..0000000000 --- a/backend/.sqlx/query-ab4f1762b923774f43d5ed088d43828956701d9f9a08f8c202446ea06f292d46.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT a.path, av.created_by,\n (SELECT id FROM app_version WHERE app_id = a.id\n ORDER BY created_at DESC LIMIT 1) as \"head!\"\n FROM app_version av JOIN app a ON a.id = av.app_id\n WHERE av.id = $2 AND a.workspace_id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "path", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "created_by", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "head!", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text", - "Int8" - ] - }, - "nullable": [ - false, - false, - null - ] - }, - "hash": "ab4f1762b923774f43d5ed088d43828956701d9f9a08f8c202446ea06f292d46" -} diff --git a/backend/.sqlx/query-af8981997f299b5c943bbdffb4d52dbff1d6ef4c7c0118574b98e80a7b179e94.json b/backend/.sqlx/query-af8981997f299b5c943bbdffb4d52dbff1d6ef4c7c0118574b98e80a7b179e94.json deleted file mode 100644 index ec22cd5c04..0000000000 --- a/backend/.sqlx/query-af8981997f299b5c943bbdffb4d52dbff1d6ef4c7c0118574b98e80a7b179e94.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE draft\n SET path = $3,\n value = to_json(\n jsonb_set(\n CASE WHEN $7::text IS NULL THEN to_jsonb(value)\n ELSE jsonb_set(to_jsonb(value), ARRAY['summary'], to_jsonb($7::text))\n END,\n ARRAY[$5::text], to_jsonb($3::text), false\n )\n )\n WHERE workspace_id = $1\n AND path = $2\n AND typ = $4\n AND email = $6\n -- Skipped on a summary-only edit, where the \"target\" row is this\n -- row and the guard would refuse the update against itself.\n AND ($2 = $3 OR NOT EXISTS (\n SELECT 1 FROM draft o\n WHERE o.workspace_id = $1 AND o.path = $3 AND o.typ = $4 AND o.email = $6\n ))\n RETURNING id", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - "Text", - { - "Custom": { - "name": "draft_kind", - "kind": { - "Enum": [ - "script", - "flow", - "app", - "raw_app", - "resource", - "variable", - "trigger_schedule", - "trigger_webhook", - "trigger_default_email", - "trigger_email", - "trigger_http", - "trigger_websocket", - "trigger_postgres", - "trigger_kafka", - "trigger_nats", - "trigger_mqtt", - "trigger_sqs", - "trigger_gcp", - "trigger_azure", - "trigger_poll", - "trigger_cli", - "trigger_nextcloud", - "trigger_google", - "trigger_github", - "data_pipeline", - "trigger_amqp" - ] - } - } - }, - "Text", - "Text", - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "af8981997f299b5c943bbdffb4d52dbff1d6ef4c7c0118574b98e80a7b179e94" -} diff --git a/backend/.sqlx/query-c902cf38cea14284032312f833ed84637926113f1f6aef6b58e24c88930f1432.json b/backend/.sqlx/query-c902cf38cea14284032312f833ed84637926113f1f6aef6b58e24c88930f1432.json deleted file mode 100644 index 74fec3d7d8..0000000000 --- a/backend/.sqlx/query-c902cf38cea14284032312f833ed84637926113f1f6aef6b58e24c88930f1432.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE draft\n SET path = $3,\n value = to_json(\n jsonb_set(\n CASE WHEN $7::text IS NULL THEN to_jsonb(value)\n ELSE jsonb_set(to_jsonb(value), ARRAY['summary'], to_jsonb($7::text))\n END,\n ARRAY[$5::text], to_jsonb($3::text), false\n )\n )\n WHERE workspace_id = $1\n AND path = $2\n AND typ = $4\n AND email = $6\n -- A pre-sanitizer NUL escape makes `to_jsonb` raise 22P05. Excluded\n -- here so the statement can't 500; reported below instead. Unlike the\n -- passive carry, rewriting the value IS this operation, so skipping it\n -- silently would move the row and leave its typed path stale.\n AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) = 0\n -- Skipped on a summary-only edit, where the \"target\" row is this\n -- row and the guard would refuse the update against itself.\n AND ($2 = $3 OR NOT EXISTS (\n SELECT 1 FROM draft o\n WHERE o.workspace_id = $1 AND o.path = $3 AND o.typ = $4 AND o.email = $6\n ))\n RETURNING id", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - "Text", - { - "Custom": { - "name": "draft_kind", - "kind": { - "Enum": [ - "script", - "flow", - "app", - "raw_app", - "resource", - "variable", - "trigger_schedule", - "trigger_webhook", - "trigger_default_email", - "trigger_email", - "trigger_http", - "trigger_websocket", - "trigger_postgres", - "trigger_kafka", - "trigger_nats", - "trigger_mqtt", - "trigger_sqs", - "trigger_gcp", - "trigger_azure", - "trigger_poll", - "trigger_cli", - "trigger_nextcloud", - "trigger_google", - "trigger_github", - "data_pipeline", - "trigger_amqp" - ] - } - } - }, - "Text", - "Text", - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "c902cf38cea14284032312f833ed84637926113f1f6aef6b58e24c88930f1432" -} diff --git a/backend/migrations/20260909110835_grant_draft_id_seq.down.sql b/backend/migrations/20260909110835_grant_draft_id_seq.down.sql index 37f0ebada6..14fa7ae54d 100644 --- a/backend/migrations/20260909110835_grant_draft_id_seq.down.sql +++ b/backend/migrations/20260909110835_grant_draft_id_seq.down.sql @@ -1,2 +1,5 @@ -REVOKE ALL ON SEQUENCE draft_id_seq FROM windmill_user; -REVOKE ALL ON SEQUENCE draft_id_seq FROM windmill_admin; +-- Deliberately empty. On an instance where 20250205131523 succeeded, this grant +-- came from its ALTER DEFAULT PRIVILEGES when the sequence was created, and the +-- catalog keeps no record distinguishing that from the one above — so a REVOKE +-- here would strip a privilege the instance had before this migration ran, and +-- leave every autosave failing with `permission denied for sequence`. diff --git a/backend/tests/drafts_move_mirror.rs b/backend/tests/drafts_move_mirror.rs new file mode 100644 index 0000000000..05f881e2b9 --- /dev/null +++ b/backend/tests/drafts_move_mirror.rs @@ -0,0 +1,70 @@ +//! Moving a draft must carry both of its path keys. +//! +//! A draft value holds a typed path and a mirror the editors keep beside it while +//! it differs from the row's path (`path`/`draft_path`; which is which depends on +//! the kind). The loaders prefer the mirror, so a move that rewrote only the typed +//! key left the mirror naming the old location: reopening the item restored the +//! old path, and the next autosave wrote it back — undoing the move silently. +//! +//! The rule is spread over three sites that have to agree (`move_draft`, the +//! passive carry in `move_drafts_for_path`, and `resolve_moved_to_in`'s patch); +//! this pins the endpoint, including that a draft with no mirror never gains one. + +use serde_json::Value; +use sqlx::{Pool, Postgres}; + +use windmill_test_utils::*; + +async fn move_to(port: u16, from: &str, to: &str) -> anyhow::Result { + Ok(reqwest::Client::new() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/drafts/move/script/{from}" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&serde_json::json!({ "new_path": to })) + .send() + .await? + .text() + .await?) +} + +/// The stored draft value at `path`, read back through the API so this test needs +/// no `sqlx::query!` (which would want an offline cache entry of its own). +async fn value_at(port: u16, path: &str) -> anyhow::Result { + let body: Value = reqwest::Client::new() + .get(format!( + "http://localhost:{port}/api/w/test-workspace/drafts/get_own/script/{path}" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .send() + .await? + .json() + .await?; + Ok(body + .get("value") + .cloned() + .unwrap_or_else(|| panic!("no draft at {path}: {body}"))) +} + +#[sqlx::test(fixtures("base", "drafts_move_mirror"))] +async fn test_move_carries_both_path_keys(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + move_to(port, "u/test-user/draft_mirror", "u/test-user/renamed").await?; + move_to(port, "u/test-user/draft_plain", "u/test-user/plain2").await?; + + // The mirror follows: left at `u/test-user/friendly` it would win at load and + // walk the item back there. + let moved = value_at(port, "u/test-user/renamed").await?; + assert_eq!(moved["path"], "u/test-user/renamed"); + assert_eq!(moved["draft_path"], "u/test-user/renamed"); + + // A draft that never had a mirror must not be given one. + let plain = value_at(port, "u/test-user/plain2").await?; + assert_eq!(plain["path"], "u/test-user/plain2"); + assert_eq!(plain.get("draft_path"), None, "mirror injected: {plain}"); + + Ok(()) +} diff --git a/backend/tests/fixtures/drafts_move_mirror.sql b/backend/tests/fixtures/drafts_move_mirror.sql new file mode 100644 index 0000000000..e96d985eae --- /dev/null +++ b/backend/tests/fixtures/drafts_move_mirror.sql @@ -0,0 +1,16 @@ +-- Fixture for the two-path-key move test. +-- +-- Two draft-only scripts owned by test-user, both parked at a generated storage +-- key the way a session-created draft is. One carries the `draft_path` mirror the +-- session editor writes while the typed path differs from that key; the other +-- carries no mirror at all, which is what a plain draft looks like. + +INSERT INTO draft (workspace_id, path, typ, value, email) VALUES + ('test-workspace', 'u/test-user/draft_mirror', 'script', + '{"path": "u/test-user/friendly", "draft_path": "u/test-user/friendly", + "content": "x", "language": "bun", "summary": "S"}', + 'test@windmill.dev'), + ('test-workspace', 'u/test-user/draft_plain', 'script', + '{"path": "u/test-user/draft_plain", + "content": "x", "language": "bun", "summary": "S"}', + 'test@windmill.dev'); diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index 9e559f5011..34eb94cbd9 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -1393,7 +1393,7 @@ async fn update_flow( flow_path, &nf.path, UserDraftItemKind::Flow.typed_path_field(), - UserDraftItemKind::Flow.mirror_path_field(), + UserDraftItemKind::Flow.mirror_path_field(), UserDraftItemKind::Flow .base_version_field() .map(|f| (f, version.to_string())), diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index b8954251b0..33f08fedf8 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -9457,7 +9457,7 @@ paths: /w/{workspace}/drafts/move/{kind}/{path}: post: summary: move the current user's draft-only item to another path - description: Relocates the authed user's own draft row (and the typed path inside its value). Only for draft-only items — a deployed item must be moved through its own deploy endpoint, which carries every draft with it. Restricted to script, flow, app and raw_app; any other kind is rejected with 400, because only these keep their deploy target where this endpoint rewrites it. + description: Relocates the authed user's own draft row, along with both path keys inside its value (the typed path and the mirror the editors keep beside it). Only for draft-only items — a deployed item must be moved through its own deploy endpoint, which carries every draft with it. Restricted to script, flow, app and raw_app; any other kind is rejected with 400, because only these keep their deploy target where this endpoint rewrites it. operationId: moveDraft tags: - draft diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index ca04403cd2..f7df4eff08 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -3458,7 +3458,7 @@ async fn update_app_internal<'a>( path, &npath, UserDraftItemKind::App.typed_path_field(), - UserDraftItemKind::App.mirror_path_field(), + UserDraftItemKind::App.mirror_path_field(), UserDraftItemKind::App .base_version_field() .map(|f| (f, v_id.to_string())), diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index 966d111b92..110420ce0e 100644 --- a/backend/windmill-api/src/drafts.rs +++ b/backend/windmill-api/src/drafts.rs @@ -909,8 +909,8 @@ pub struct MoveDraftRequest { } /// Relocate the authed user's own DRAFT-ONLY item. Such an item is nothing but -/// its draft row, so moving it is a rewrite of that row's path plus the typed -/// path inside its value — there is no deployed row, schedule or trigger to +/// its draft row, so moving it is a rewrite of that row's path plus both path +/// keys inside its value — there is no deployed row, schedule or trigger to /// cascade to, and no second party to notify (a draft is private to its owner). /// /// Scoped to the caller's own row on purpose: two users can each have a draft