test: pin the two-key move, and stop the down migration breaking instances

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) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-09 14:20:37 +02:00
co-authored by Claude Opus 5
parent 0406c3625d
commit ad97bcf110
19 changed files with 96 additions and 489 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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`.
+70
View File
@@ -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<String> {
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<Value> {
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<Postgres>) -> 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(())
}
+16
View File
@@ -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');
+1 -1
View File
@@ -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())),
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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())),
+2 -2
View File
@@ -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