mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
fix: write a followed draft by id against the row's own path keys; keep base on assign and clone
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ae6d37fdf8
commit
7ac3aed2ef
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH legacy AS (\n DELETE FROM draft\n WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email IS NULL\n RETURNING value\n )\n INSERT INTO draft (workspace_id, email, path, typ, value, created_at)\n SELECT $1, $4, $2, $3, value, now() FROM legacy\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = EXCLUDED.value, created_at = now()\n RETURNING 1 as \"one!\"",
|
||||
"query": "WITH legacy AS (\n DELETE FROM draft\n WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email IS NULL\n RETURNING value, base\n )\n INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)\n SELECT $1, $4, $2, $3, value, now(), base FROM legacy\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = EXCLUDED.value, created_at = now(), base = EXCLUDED.base\n RETURNING 1 as \"one!\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -55,5 +55,5 @@
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "46f00a75b2e7e4ac70758a9687070f68bc0421f1aa228f80157adda63191d33b"
|
||||
"hash": "1c6859291963a9ca38d73f500213a575bfcd4469524f0dc8812470737759f02b"
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE draft AS d\n SET value = CASE\n WHEN d.path = $4 THEN $5::text::json\n ELSE (\n SELECT to_json(\n s.new\n || CASE WHEN NOT s.new ? 'path' THEN '{}'::jsonb\n WHEN s.old ? 'path' THEN jsonb_build_object('path', s.old -> 'path')\n WHEN s.new -> 'path' = to_jsonb($4::text) THEN jsonb_build_object('path', d.path)\n ELSE '{}'::jsonb END\n || CASE WHEN NOT s.new ? 'draft_path' THEN '{}'::jsonb\n WHEN s.old ? 'draft_path' THEN jsonb_build_object('draft_path', s.old -> 'draft_path')\n WHEN s.new -> 'draft_path' = to_jsonb($4::text) THEN jsonb_build_object('draft_path', d.path)\n ELSE '{}'::jsonb END\n )\n FROM (SELECT $5::text::jsonb AS new,\n CASE WHEN position(chr(92) || 'u0000' in replace(d.value::text, chr(92) || chr(92), '')) > 0\n THEN '{}'::jsonb\n ELSE to_jsonb(d.value) END AS old) s\n )\n END,\n created_at = COALESCE($8::timestamptz, now()),\n base = $9\n WHERE d.id = $3 AND d.workspace_id = $1 AND d.typ = $10 AND d.email = $2\n AND ($7::bool = true\n OR $6::timestamptz IS NULL\n OR d.created_at <= $6::timestamptz)\n RETURNING d.id, d.path, d.created_at",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Int8",
|
||||
"Text",
|
||||
"Text",
|
||||
"Timestamptz",
|
||||
"Bool",
|
||||
"Timestamptz",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "3b3502ee12e6bd7bb693ed745ac0298f92c3e8846341c47fb7c58cff723ce4d7"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)\n VALUES ($1, $2, $3, $4, $5::text::json, COALESCE($8::timestamptz, now()), $9)\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = EXCLUDED.value, created_at = EXCLUDED.created_at,\n base = EXCLUDED.base\n WHERE $7::bool = true\n OR $6::timestamptz IS NULL\n OR draft.created_at <= $6::timestamptz\n RETURNING id, path, created_at",
|
||||
"query": "INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)\n VALUES ($1, $2, $3, $4, $5::text::json, COALESCE($8::timestamptz, now()), $9)\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = EXCLUDED.value, created_at = EXCLUDED.created_at,\n base = EXCLUDED.base\n WHERE $7::bool = true\n OR $6::timestamptz IS NULL\n OR draft.created_at <= $6::timestamptz\n RETURNING id, path, created_at",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -72,5 +72,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0a8e315e6a808c9a8704d5cbdc607a011dba4472741df120c2a556c87fe2825f"
|
||||
"hash": "49775ee6ef7a059fb8c6eaa4cb9a1bf45481295367f0a5dfa21b380ec271ece3"
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT created_at FROM draft WHERE id = $1 AND workspace_id = $2 AND typ = $3 AND email = $4",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"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": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "52d6106995c012e69302bf3e9e66c5c0db0244e391632e933b55491f9fde30cd"
|
||||
}
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT created_at FROM draft\n WHERE workspace_id = $1\n AND email IS NOT DISTINCT FROM (CASE WHEN $5::bool THEN NULL::text ELSE $2 END)\n AND path = $3 AND typ = $4",
|
||||
"query": "SELECT created_at FROM draft\n WHERE workspace_id = $1\n AND email IS NOT DISTINCT FROM (CASE WHEN $5::bool THEN NULL::text ELSE $2 END)\n AND (CASE WHEN $6::bigint IS NULL THEN path = $3 ELSE id = $6 END)\n AND typ = $4",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -49,12 +49,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Bool"
|
||||
"Bool",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "560539adbad0ecfa57fa477c3b82d82c350857166fc27fe9eecc88bcc4b229bc"
|
||||
"hash": "58d83dadb8699ed39e0949ed630574ad6fb07c273ce906c21b90a9dc6bed925f"
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE draft\n SET path = $3::text,\n value = CASE\n WHEN position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) > 0\n THEN value\n WHEN to_jsonb(value) -> 'path' = to_jsonb($2::text)\n OR to_jsonb(value) -> 'draft_path' = to_jsonb($2::text)\n THEN to_json(\n to_jsonb(value)\n || CASE WHEN to_jsonb(value) -> 'path' = to_jsonb($2::text)\n THEN jsonb_build_object('path', $3::text)\n ELSE '{}'::jsonb END\n || CASE WHEN to_jsonb(value) -> 'draft_path' = to_jsonb($2::text)\n THEN jsonb_build_object('draft_path', $3::text)\n ELSE '{}'::jsonb END\n )\n ELSE value\n END\n WHERE workspace_id = $1\n AND path = $2::text\n AND typ::text = ANY($4::text[])",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "5e5547ba06a57bb5c46300bd3af450a6444770629bb7bcebe46a33a574bb4931"
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO draft (workspace_id, path, typ, value, created_at, email)\n SELECT $2, path, typ,\n CASE WHEN typ IN ('script', 'flow')\n THEN to_json(to_jsonb(value) - 'on_behalf_of')\n ELSE value END,\n created_at, email\n FROM draft\n WHERE workspace_id = $1 AND (email = $3 OR email IS NULL)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "87afd5289e54b6accda8a399ec3c66df3d19217059f62903be3c74eadb9c15d5"
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE draft\n SET path = $3::text,\n value = CASE\n WHEN position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) > 0\n THEN value\n WHEN to_jsonb(value) -> $5::text = to_jsonb($2::text)\n OR to_jsonb(value) -> $6::text = to_jsonb($2::text)\n THEN to_json(\n to_jsonb(value)\n || CASE WHEN to_jsonb(value) -> $5::text = to_jsonb($2::text)\n THEN jsonb_build_object($5::text, $3::text)\n ELSE '{}'::jsonb END\n || CASE WHEN to_jsonb(value) -> $6::text = to_jsonb($2::text)\n THEN jsonb_build_object($6::text, $3::text)\n ELSE '{}'::jsonb END\n )\n ELSE value\n END\n WHERE workspace_id = $1\n AND path = $2::text\n AND typ::text = ANY($4::text[])",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"TextArray",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b1292f83c52ab60599c487cc4e89375d9f391cc732aa8ca79717df790e9111d6"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO draft (workspace_id, path, typ, value, created_at, email, base)\n SELECT $2, path, typ,\n CASE WHEN typ IN ('script', 'flow')\n THEN to_json(to_jsonb(value) - 'on_behalf_of')\n ELSE value END,\n created_at, email, base\n FROM draft\n WHERE workspace_id = $1 AND (email = $3 OR email IS NULL)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c55d8d369fd8e37ff85fcc9dea1ec641ea507205fdc97485c673b8f20fab7f64"
|
||||
}
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM draft\n WHERE workspace_id = $1\n AND email IS NOT DISTINCT FROM (CASE WHEN $7::bool THEN NULL::text ELSE $2 END)\n AND path = $3\n AND typ = $4\n AND ($6::bool = true\n OR $5::timestamptz IS NULL\n OR created_at <= $5::timestamptz)\n RETURNING now() as \"now!\"",
|
||||
"query": "DELETE FROM draft\n WHERE workspace_id = $1\n AND email IS NOT DISTINCT FROM (CASE WHEN $7::bool THEN NULL::text ELSE $2 END)\n AND (CASE WHEN $8::bigint IS NULL THEN path = $3 ELSE id = $8 END)\n AND typ = $4\n AND ($6::bool = true\n OR $5::timestamptz IS NULL\n OR created_at <= $5::timestamptz)\n RETURNING now() as \"now!\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -51,12 +51,13 @@
|
||||
},
|
||||
"Timestamptz",
|
||||
"Bool",
|
||||
"Bool"
|
||||
"Bool",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "2bed492ef32edf36e60e8a03268fa25bfb67dd641153d1ca23f7d0d2ae73624e"
|
||||
"hash": "f0c71f89c8848334163394a058ca670eb2a0aef389856c23ce6ae3b109f1a441"
|
||||
}
|
||||
@@ -113,3 +113,50 @@ async fn test_save_by_id_follows_a_rename(db: Pool<Postgres>) -> anyhow::Result<
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A draft-only move rewrites both path keys. The open editor's next save still
|
||||
/// carries the typed path it had, which names neither the row's old nor new path;
|
||||
/// the row's own keys have to win, or the save walks the item back.
|
||||
#[sqlx::test(fixtures("base", "drafts_save_by_id"))]
|
||||
async fn test_save_by_id_keeps_a_draft_only_move(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/draft_store"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({ "new_path": "u/test-user/moved" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert!(resp.status().is_success(), "move failed: {}", resp.text().await?);
|
||||
|
||||
let saved: Value = client
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/update/script/u/test-user/draft_store"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({
|
||||
"id": 9002,
|
||||
"value": {
|
||||
"path": "u/test-user/friendly",
|
||||
"draft_path": "u/test-user/friendly",
|
||||
"summary": "D",
|
||||
"content": "edited after the move"
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(saved["path"], "u/test-user/moved", "{saved}");
|
||||
|
||||
let draft = own_draft_value(port, "u/test-user/moved").await?;
|
||||
assert_eq!(draft["content"], "edited after the move", "{draft}");
|
||||
assert_eq!(draft["path"], "u/test-user/moved", "{draft}");
|
||||
assert_eq!(draft["draft_path"], "u/test-user/moved", "{draft}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -14,3 +14,9 @@ INSERT INTO draft (id, workspace_id, path, typ, value, email, base)
|
||||
VALUES (9001, 'test-workspace', 'u/test-user/byid_a', 'script',
|
||||
'{"path": "u/test-user/byid_a", "parent_hash": "0000000000001b76", "summary": "A", "content": "draft"}',
|
||||
'test@windmill.dev', '0000000000001b76');
|
||||
|
||||
-- A draft-only script parked at a generated storage key, its typed path elsewhere.
|
||||
INSERT INTO draft (id, workspace_id, path, typ, value, email)
|
||||
VALUES (9002, 'test-workspace', 'u/test-user/draft_store', 'script',
|
||||
'{"path": "u/test-user/friendly", "draft_path": "u/test-user/friendly", "summary": "D", "content": "draft"}',
|
||||
'test@windmill.dev');
|
||||
|
||||
@@ -7315,12 +7315,12 @@ async fn clone_drafts(
|
||||
// filtered like `clone_scripts`: the address the draft still carries re-derives the
|
||||
// clone's own principal at deploy time, which is the more accurate answer of the two.
|
||||
sqlx::query!(
|
||||
"INSERT INTO draft (workspace_id, path, typ, value, created_at, email)
|
||||
"INSERT INTO draft (workspace_id, path, typ, value, created_at, email, base)
|
||||
SELECT $2, path, typ,
|
||||
CASE WHEN typ IN ('script', 'flow')
|
||||
THEN to_json(to_jsonb(value) - 'on_behalf_of')
|
||||
ELSE value END,
|
||||
created_at, email
|
||||
created_at, email, base
|
||||
FROM draft
|
||||
WHERE workspace_id = $1 AND (email = $3 OR email IS NULL)",
|
||||
source_workspace_id,
|
||||
|
||||
@@ -17,9 +17,7 @@ use serde::{Deserialize, Serialize};
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
error::{Error, Result},
|
||||
user_drafts::{
|
||||
repoint_draft_path_keys, DraftUserRef, UserDraftItemKind, ENCRYPTED_DRAFT_PREFIX,
|
||||
},
|
||||
user_drafts::{DraftUserRef, UserDraftItemKind, ENCRYPTED_DRAFT_PREFIX},
|
||||
users::resolve_username_to_email,
|
||||
utils::{check_proper_path, strip_json_nul},
|
||||
variables::{build_crypt, encrypt},
|
||||
@@ -472,53 +470,123 @@ async fn update_draft(
|
||||
// escape and later make any `->>`/`to_jsonb` extraction raise `22P05`.
|
||||
// Strip it here so a NUL never reaches the column.
|
||||
let serialized = strip_json_nul(&serialized);
|
||||
// An editor still open on the path the row moved away from writes that path
|
||||
// into the value; kept, it would deploy the item back there.
|
||||
let serialized = if followed && path != url_path {
|
||||
repoint_draft_path_keys(&serialized, url_path, path)
|
||||
.map_or(serialized, std::borrow::Cow::Owned)
|
||||
} else {
|
||||
serialized
|
||||
};
|
||||
// `base` is derived here from the value's per-kind field rather than sent
|
||||
// by the client, so every writer (editors, chat, CLI) fills it the same way.
|
||||
let base = draft_lineage(kind, value.0.get()).and_then(|l| l.as_text(kind));
|
||||
// Upsert. The conflict check rides on the DO UPDATE WHERE clause —
|
||||
// when the row is newer than `last_sync`, RETURNING yields nothing.
|
||||
// `created_at` defaults to `now()` but the migration overrides it ($8)
|
||||
// so a migrated draft keeps its original age instead of jumping to top.
|
||||
sqlx::query!(
|
||||
r#"INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)
|
||||
VALUES ($1, $2, $3, $4, $5::text::json, COALESCE($8::timestamptz, now()), $9)
|
||||
ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL
|
||||
DO UPDATE SET value = EXCLUDED.value, created_at = EXCLUDED.created_at,
|
||||
base = EXCLUDED.base
|
||||
WHERE $7::bool = true
|
||||
OR $6::timestamptz IS NULL
|
||||
OR draft.created_at <= $6::timestamptz
|
||||
RETURNING id, path, created_at"#,
|
||||
&w_id,
|
||||
email,
|
||||
path,
|
||||
kind as UserDraftItemKind,
|
||||
serialized.as_ref(),
|
||||
req.last_sync,
|
||||
req.force,
|
||||
req.created_at,
|
||||
base.as_deref(),
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.map(|r| (r.created_at, Some(r.id), Some(r.path)))
|
||||
|
||||
let mut written = None;
|
||||
if let Some(id) = req.id.filter(|_| followed) {
|
||||
// By id, in one statement, so a move committing after the lookup above
|
||||
// still takes this write along; a path-keyed upsert would plant a new row
|
||||
// at the path the row left. Where the row sits elsewhere than the URL,
|
||||
// the editor's path keys predate the move: the row's own keys win, and
|
||||
// one naming the URL path follows the row (see `move_drafts_for_path`).
|
||||
// A pre-sanitizer NUL escape in the stored value makes `to_jsonb` raise,
|
||||
// so such a row contributes no keys.
|
||||
written = sqlx::query!(
|
||||
r#"UPDATE draft AS d
|
||||
SET value = CASE
|
||||
WHEN d.path = $4 THEN $5::text::json
|
||||
ELSE (
|
||||
SELECT to_json(
|
||||
s.new
|
||||
|| CASE WHEN NOT s.new ? 'path' THEN '{}'::jsonb
|
||||
WHEN s.old ? 'path' THEN jsonb_build_object('path', s.old -> 'path')
|
||||
WHEN s.new -> 'path' = to_jsonb($4::text) THEN jsonb_build_object('path', d.path)
|
||||
ELSE '{}'::jsonb END
|
||||
|| CASE WHEN NOT s.new ? 'draft_path' THEN '{}'::jsonb
|
||||
WHEN s.old ? 'draft_path' THEN jsonb_build_object('draft_path', s.old -> 'draft_path')
|
||||
WHEN s.new -> 'draft_path' = to_jsonb($4::text) THEN jsonb_build_object('draft_path', d.path)
|
||||
ELSE '{}'::jsonb END
|
||||
)
|
||||
FROM (SELECT $5::text::jsonb AS new,
|
||||
CASE WHEN position(chr(92) || 'u0000' in replace(d.value::text, chr(92) || chr(92), '')) > 0
|
||||
THEN '{}'::jsonb
|
||||
ELSE to_jsonb(d.value) END AS old) s
|
||||
)
|
||||
END,
|
||||
created_at = COALESCE($8::timestamptz, now()),
|
||||
base = $9
|
||||
WHERE d.id = $3 AND d.workspace_id = $1 AND d.typ = $10 AND d.email = $2
|
||||
AND ($7::bool = true
|
||||
OR $6::timestamptz IS NULL
|
||||
OR d.created_at <= $6::timestamptz)
|
||||
RETURNING d.id, d.path, d.created_at"#,
|
||||
&w_id,
|
||||
email,
|
||||
id,
|
||||
url_path,
|
||||
serialized.as_ref(),
|
||||
req.last_sync,
|
||||
req.force,
|
||||
req.created_at,
|
||||
base.as_deref(),
|
||||
kind as UserDraftItemKind,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.map(|r| (r.created_at, Some(r.id), Some(r.path)));
|
||||
if written.is_none() {
|
||||
let existing = sqlx::query_scalar!(
|
||||
"SELECT created_at FROM draft WHERE id = $1 AND workspace_id = $2 AND typ = $3 AND email = $4",
|
||||
id,
|
||||
&w_id,
|
||||
kind as UserDraftItemKind,
|
||||
email,
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
if let Some(ts) = existing {
|
||||
return Ok(Json(SaveDraftResponse {
|
||||
status: SaveDraftStatus::Conflict,
|
||||
current_timestamp: ts,
|
||||
id: None,
|
||||
path: None,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
if written.is_none() {
|
||||
// Upsert: no id, or its row is gone since the lookup, which is then a
|
||||
// first save again. The conflict check rides on the DO UPDATE WHERE
|
||||
// clause — when the row is newer than `last_sync`, RETURNING yields
|
||||
// nothing. `created_at` defaults to `now()` but the migration overrides
|
||||
// it ($8) so a migrated draft keeps its original age.
|
||||
written = sqlx::query!(
|
||||
r#"INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)
|
||||
VALUES ($1, $2, $3, $4, $5::text::json, COALESCE($8::timestamptz, now()), $9)
|
||||
ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL
|
||||
DO UPDATE SET value = EXCLUDED.value, created_at = EXCLUDED.created_at,
|
||||
base = EXCLUDED.base
|
||||
WHERE $7::bool = true
|
||||
OR $6::timestamptz IS NULL
|
||||
OR draft.created_at <= $6::timestamptz
|
||||
RETURNING id, path, created_at"#,
|
||||
&w_id,
|
||||
email,
|
||||
path,
|
||||
kind as UserDraftItemKind,
|
||||
serialized.as_ref(),
|
||||
req.last_sync,
|
||||
req.force,
|
||||
req.created_at,
|
||||
base.as_deref(),
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.map(|r| (r.created_at, Some(r.id), Some(r.path)));
|
||||
}
|
||||
written
|
||||
} else {
|
||||
// Delete, same conflict rule in the WHERE clause. Returns NULL when
|
||||
// the row was too new (conflict) OR already absent (idempotent) —
|
||||
// disambiguated below. `legacy` ($7) retargets to the NULL-email row.
|
||||
// A followed row is deleted by id ($8), for the same reason it is written by id.
|
||||
sqlx::query_scalar!(
|
||||
r#"DELETE FROM draft
|
||||
WHERE workspace_id = $1
|
||||
AND email IS NOT DISTINCT FROM (CASE WHEN $7::bool THEN NULL::text ELSE $2 END)
|
||||
AND path = $3
|
||||
AND (CASE WHEN $8::bigint IS NULL THEN path = $3 ELSE id = $8 END)
|
||||
AND typ = $4
|
||||
AND ($6::bool = true
|
||||
OR $5::timestamptz IS NULL
|
||||
@@ -531,6 +599,7 @@ async fn update_draft(
|
||||
req.last_sync,
|
||||
req.force,
|
||||
req.legacy,
|
||||
req.id.filter(|_| followed),
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
@@ -553,12 +622,14 @@ async fn update_draft(
|
||||
r#"SELECT created_at FROM draft
|
||||
WHERE workspace_id = $1
|
||||
AND email IS NOT DISTINCT FROM (CASE WHEN $5::bool THEN NULL::text ELSE $2 END)
|
||||
AND path = $3 AND typ = $4"#,
|
||||
AND (CASE WHEN $6::bigint IS NULL THEN path = $3 ELSE id = $6 END)
|
||||
AND typ = $4"#,
|
||||
&w_id,
|
||||
email,
|
||||
path,
|
||||
kind as UserDraftItemKind,
|
||||
req.legacy,
|
||||
req.id.filter(|_| followed && req.value.is_none()),
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
@@ -822,12 +893,12 @@ async fn migrate_legacy_draft(
|
||||
r#"WITH legacy AS (
|
||||
DELETE FROM draft
|
||||
WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email IS NULL
|
||||
RETURNING value
|
||||
RETURNING value, base
|
||||
)
|
||||
INSERT INTO draft (workspace_id, email, path, typ, value, created_at)
|
||||
SELECT $1, $4, $2, $3, value, now() FROM legacy
|
||||
INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)
|
||||
SELECT $1, $4, $2, $3, value, now(), base FROM legacy
|
||||
ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL
|
||||
DO UPDATE SET value = EXCLUDED.value, created_at = now()
|
||||
DO UPDATE SET value = EXCLUDED.value, created_at = now(), base = EXCLUDED.base
|
||||
RETURNING 1 as "one!""#,
|
||||
&w_id,
|
||||
path,
|
||||
|
||||
@@ -640,8 +640,11 @@ pub async fn delete_own_draft_for_path(
|
||||
///
|
||||
/// The value keeps its base version: a move is a deploy like any other, so every
|
||||
/// draft on the item is now behind the head, which the editor reports through
|
||||
/// the ordinary stale-draft prompt. Its path keys follow only where they still
|
||||
/// name `old_path` (see [`DRAFT_PATH_KEYS`]).
|
||||
/// the ordinary stale-draft prompt. Of its two path keys (`path`, `draft_path`),
|
||||
/// one still naming `old_path` is not a rename the user staged — the editors write
|
||||
/// the item's own path there on every save — so it follows the row, or deploying
|
||||
/// the draft would send the item back where it came from. Any other value is a
|
||||
/// staged rename, and is kept.
|
||||
///
|
||||
/// A draft already at `new_path` (a never-deployed item, or one left on an
|
||||
/// archived script there) occupies that path the way a deployed item does, and
|
||||
@@ -694,15 +697,15 @@ pub async fn move_drafts_for_path(
|
||||
value = CASE
|
||||
WHEN position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) > 0
|
||||
THEN value
|
||||
WHEN to_jsonb(value) -> $5::text = to_jsonb($2::text)
|
||||
OR to_jsonb(value) -> $6::text = to_jsonb($2::text)
|
||||
WHEN to_jsonb(value) -> 'path' = to_jsonb($2::text)
|
||||
OR to_jsonb(value) -> 'draft_path' = to_jsonb($2::text)
|
||||
THEN to_json(
|
||||
to_jsonb(value)
|
||||
|| CASE WHEN to_jsonb(value) -> $5::text = to_jsonb($2::text)
|
||||
THEN jsonb_build_object($5::text, $3::text)
|
||||
|| CASE WHEN to_jsonb(value) -> 'path' = to_jsonb($2::text)
|
||||
THEN jsonb_build_object('path', $3::text)
|
||||
ELSE '{}'::jsonb END
|
||||
|| CASE WHEN to_jsonb(value) -> $6::text = to_jsonb($2::text)
|
||||
THEN jsonb_build_object($6::text, $3::text)
|
||||
|| CASE WHEN to_jsonb(value) -> 'draft_path' = to_jsonb($2::text)
|
||||
THEN jsonb_build_object('draft_path', $3::text)
|
||||
ELSE '{}'::jsonb END
|
||||
)
|
||||
ELSE value
|
||||
@@ -714,38 +717,12 @@ pub async fn move_drafts_for_path(
|
||||
old_path,
|
||||
new_path,
|
||||
&typs as &[&str],
|
||||
DRAFT_PATH_KEYS[0],
|
||||
DRAFT_PATH_KEYS[1],
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The two keys a full-page editor's draft value can hold a path in: `path` and
|
||||
/// `draft_path` (which one is the deploy target depends on the kind, see
|
||||
/// `typed_path_field`). One still equal to the path the row sits at is not a
|
||||
/// rename the user staged — the editors write the item's own path there on every
|
||||
/// save — so when the row moves it has to move too, or deploying the draft sends
|
||||
/// the item back where it came from. Any other value is a staged rename, kept.
|
||||
const DRAFT_PATH_KEYS: [&str; 2] = ["path", "draft_path"];
|
||||
|
||||
/// `value` with each of [`DRAFT_PATH_KEYS`] that equals `from` re-pointed at `to`,
|
||||
/// or `None` when none does (the value is then already right as it is).
|
||||
pub fn repoint_draft_path_keys(value: &str, from: &str, to: &str) -> Option<String> {
|
||||
let mut obj = serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(value).ok()?;
|
||||
let mut changed = false;
|
||||
for key in DRAFT_PATH_KEYS {
|
||||
if let Some(v) = obj.get_mut(key) {
|
||||
if v.as_str() == Some(from) {
|
||||
*v = serde_json::Value::String(to.to_string());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
changed.then(|| serde_json::to_string(&obj).ok()).flatten()
|
||||
}
|
||||
|
||||
/// Fetch the authed user's draft as a standalone payload, for "get by path"
|
||||
/// routes when no deployed row exists but a draft might. Returns it as a
|
||||
/// `WithDraftOverlay` with `inner` and `draft` both set to the same JSON and
|
||||
@@ -824,23 +801,3 @@ pub async fn decrypt_draft_secret_value(db: &DB, w_id: &str, value: &str) -> Res
|
||||
let mc = crate::variables::build_crypt(db, w_id).await?;
|
||||
crate::variables::decrypt(&mc, encrypted.to_string()).map_err(|_| draft_decrypt_error())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::repoint_draft_path_keys;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[test]
|
||||
fn repoints_only_keys_still_naming_the_old_path() {
|
||||
let flow = r#"{"path":"f/a","draft_path":"f/staged","summary":"s"}"#;
|
||||
let out: Value =
|
||||
serde_json::from_str(&repoint_draft_path_keys(flow, "f/a", "f/b").unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
json!({"path": "f/b", "draft_path": "f/staged", "summary": "s"})
|
||||
);
|
||||
|
||||
let staged = r#"{"path":"f/staged"}"#;
|
||||
assert_eq!(repoint_draft_path_keys(staged, "f/a", "f/b"), None);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user