mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
feat: save drafts by row id, so an open editor follows its draft through a move
A rename carries every draft on the item to the new path. An editor left open across it was still saving by the path it opened on, which the server had to refuse and answer with where the item went (the "moved" handshake and its modal). The draft row has an id: the get-by-path overlay now returns it as draft_id, every later save sends it, and the server writes the row wherever it is and answers with that path. The editor then follows: it flushes what it holds, tells the user, and navigates to the item's new path, where the stale prompt says what changed. The lineage-based move resolvers, the moved status and the moved modal are gone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
793e4dba6b
commit
ab296e59c1
+14
-2
@@ -1,10 +1,20 @@
|
||||
{
|
||||
"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 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": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
@@ -57,8 +67,10 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "d3686c73788b866090f25383da507e9a7d9f90d5f86fa4a1b89026cb088d0a58"
|
||||
"hash": "0a8e315e6a808c9a8704d5cbdc607a011dba4472741df120c2a556c87fe2825f"
|
||||
}
|
||||
+10
-4
@@ -1,20 +1,25 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT value as \"value!: sqlx::types::Json<Box<serde_json::value::RawValue>>\",\n created_at, base\n FROM draft\n WHERE workspace_id = $1\n AND (email = $2 OR email IS NULL)\n AND path = $3\n AND typ = $4\n ORDER BY email NULLS LAST\n LIMIT 1",
|
||||
"query": "SELECT id, value as \"value!: sqlx::types::Json<Box<serde_json::value::RawValue>>\",\n created_at, base\n FROM draft\n WHERE workspace_id = $1\n AND (email = $2 OR email IS NULL)\n AND path = $3\n AND typ = $4\n ORDER BY email NULLS LAST\n LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
|
||||
"type_info": "Json"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"ordinal": 2,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"ordinal": 3,
|
||||
"name": "base",
|
||||
"type_info": "Text"
|
||||
}
|
||||
@@ -62,10 +67,11 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "0cc6770a81ecaecafe0d9b7100f94b329c4ec7978b016af4c964415e0c38396c"
|
||||
"hash": "10e85bd008777fe980bba2515ce7d927f3832b0de8b7a1b45223d09406c39a16"
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT path FROM draft WHERE id = $1 AND workspace_id = $2 AND typ = $3 AND email = $4",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"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": "14a475e8d090d3caca0b76ab3b2e7b48417a376412d7ab7ecd2a324a307897b7"
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT path, created_by, hash FROM script\n WHERE workspace_id = $1 AND $2 = ANY(parent_hashes)\n AND NOT archived AND NOT deleted\n ORDER BY created_at DESC LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "hash",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "189a8c5a3ac2d9ece750aa1e01c08120b04670bf7261b2f5f5b61b1bb7fde5c8"
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT a.path, head.id as \"head!\", head.created_by as \"head_by!\"\n FROM app_version av\n JOIN app a ON a.id = av.app_id\n JOIN LATERAL (\n SELECT id, created_by FROM app_version\n WHERE app_id = a.id ORDER BY created_at DESC LIMIT 1\n ) head ON true\n WHERE av.id = $2 AND a.workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "head!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "head_by!",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "323f975428149815f3a9bc5c47396c1ad11eab84d03dd8c435b824e2e1b03ff1"
|
||||
}
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT fv.path, f.edited_by, f.versions[array_upper(f.versions, 1)] as \"head!\"\n FROM flow_version fv\n JOIN flow f ON f.workspace_id = fv.workspace_id AND f.path = fv.path\n WHERE fv.id = $2 AND fv.workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "edited_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "head!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "394fea4bb407ef3f800df87032aaf5d00d2fbcc50cb3615dacc85831a6fdf813"
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM app WHERE workspace_id = $1 AND path = $2) as \"e!\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "e!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "9ab0ebbe787d71d8a36618e8fbcab5de58f58d3d7bb9f4cc94a98fd138c28114"
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM flow WHERE workspace_id = $1 AND path = $2) as \"e!\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "e!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "c451a6d05b4aeb21ecbe29a355b8943c2b738ead4b870ef75c0d64a20bd83ecc"
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2\n AND NOT archived AND NOT deleted) as \"e!\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "e!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "d5f06d5bca13ae5a22ef1290de4ebef6725238cea5ffbdc7a21b157b27d4c4dd"
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
//! `POST /drafts/update` must never name a destination the caller cannot see.
|
||||
//!
|
||||
//! When an item moves, the saver's editor is still bound to the old path and its
|
||||
//! next save is answered with `status: "moved"` plus the new path and the mover's
|
||||
//! username. That answer is resolved twice — once before the write, once after it
|
||||
//! on the write's own connection — and both must read under RLS. A post-write
|
||||
//! re-assert on a raw pool connection reads rows the pre-check cannot see, so it
|
||||
//! discloses the destination and permanently refuses a save that should land.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
use windmill_test_utils::*;
|
||||
|
||||
/// Hex form of script hash 7001, the way a script draft stores `parent_hash`.
|
||||
const OLD_HASH: &str = "0000000000001b59";
|
||||
|
||||
async fn save_draft(port: u16, token: &str) -> anyhow::Result<Value> {
|
||||
Ok(reqwest::Client::new()
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/update/script/f/mvrls_visible/s1"
|
||||
))
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.json(&json!({
|
||||
"value": {
|
||||
"path": "f/mvrls_visible/s1",
|
||||
"parent_hash": OLD_HASH,
|
||||
"content": "export function main() { return 2 }",
|
||||
"language": "deno",
|
||||
"summary": "S1",
|
||||
"description": "",
|
||||
"schema": {}
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base", "drafts_moved_rls"))]
|
||||
async fn test_moved_answer_is_rls_scoped(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
// test-user-2 writes in `mvrls_visible` but has nothing on `mvrls_secret`.
|
||||
// They must be told the save landed, not where the item went.
|
||||
let res = save_draft(port, "SECRET_TOKEN_2").await?;
|
||||
assert_eq!(res["status"], "saved", "non-admin save was refused: {res}");
|
||||
assert_eq!(res["moved_to"], Value::Null, "destination disclosed: {res}");
|
||||
assert_eq!(res["moved_by"], Value::Null, "mover disclosed: {res}");
|
||||
|
||||
// The admin sees the destination, so they get the real answer.
|
||||
let res = save_draft(port, "SECRET_TOKEN").await?;
|
||||
assert_eq!(res["status"], "moved", "admin was not told it moved: {res}");
|
||||
assert_eq!(res["moved_to"], "f/mvrls_secret/s1");
|
||||
assert_eq!(res["moved_by"], "test-user");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! A draft saved by row id lands where the row is, not where the editor was.
|
||||
//!
|
||||
//! A rename carries every draft on the item to the new path. An editor left open
|
||||
//! across it is still bound to the old path; saving by the row's id writes at the
|
||||
//! item's current path and the response names it, so the editor can follow. A
|
||||
//! save by path would instead have planted a phantom draft at the old location.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
use windmill_test_utils::*;
|
||||
|
||||
/// Hex form of script hash 7030, the way the API takes a parent hash.
|
||||
const HEAD_HASH: &str = "0000000000001b76";
|
||||
const DRAFT_ID: i64 = 9001;
|
||||
|
||||
async fn own_draft_paths(port: u16) -> anyhow::Result<Vec<String>> {
|
||||
let list: Vec<Value> = reqwest::Client::new()
|
||||
.get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/list"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
Ok(list
|
||||
.iter()
|
||||
.filter(|d| d["kind"] == "script")
|
||||
.filter_map(|d| d["path"].as_str().map(String::from))
|
||||
.filter(|p| p.starts_with("u/test-user/byid_"))
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base", "drafts_save_by_id"))]
|
||||
async fn test_save_by_id_follows_a_rename(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();
|
||||
|
||||
// Rename from Home: a redeploy of the deployed content at the new path that
|
||||
// keeps the deployer's own draft, so the draft is carried rather than consumed.
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/scripts/create"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({
|
||||
"path": "u/test-user/byid_b",
|
||||
"parent_hash": HEAD_HASH,
|
||||
"summary": "A",
|
||||
"description": "",
|
||||
"content": "export function main() { return 1 }",
|
||||
"language": "deno",
|
||||
"schema": {},
|
||||
"skip_draft_deletion": true
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 201, "rename failed: {}", resp.text().await?);
|
||||
assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/byid_b"]);
|
||||
|
||||
// The editor is still on the old path but saves by id.
|
||||
let saved: Value = client
|
||||
.post(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/update/script/u/test-user/byid_a"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.json(&json!({
|
||||
"id": DRAFT_ID,
|
||||
"value": {
|
||||
"path": "u/test-user/byid_a",
|
||||
"parent_hash": HEAD_HASH,
|
||||
"summary": "A",
|
||||
"content": "edited after the move"
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(saved["status"], "saved", "save refused: {saved}");
|
||||
assert_eq!(saved["id"], DRAFT_ID);
|
||||
assert_eq!(
|
||||
saved["path"], "u/test-user/byid_b",
|
||||
"save did not follow the row: {saved}"
|
||||
);
|
||||
|
||||
// The write landed on the carried row; nothing reappeared at the old path.
|
||||
assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/byid_b"]);
|
||||
let draft: Value = client
|
||||
.get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/drafts/get_own/script/u/test-user/byid_b"
|
||||
))
|
||||
.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
draft["value"]["content"], "edited after the move",
|
||||
"{draft}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
-- Fixture for the "moved" draft answer under RLS.
|
||||
--
|
||||
-- Models a script that has already been moved out of a folder the saver can
|
||||
-- reach (`mvrls_visible`, where test-user-2 is a writer) and into one they have
|
||||
-- no permission on (`mvrls_secret`). A script move goes through `create_script`,
|
||||
-- which archives the row in place and inserts a successor carrying the old hash
|
||||
-- in `parent_hashes` — the state reproduced here.
|
||||
|
||||
INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, created_by)
|
||||
VALUES ('test-workspace', 'mvrls_visible', 'Visible', '{"u/test-user"}',
|
||||
'{"u/test-user": true, "u/test-user-2": true}', 'test-user');
|
||||
|
||||
-- No entry for test-user-2: the destination is invisible to them.
|
||||
INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, created_by)
|
||||
VALUES ('test-workspace', 'mvrls_secret', 'Secret', '{"u/test-user"}',
|
||||
'{"u/test-user": true}', 'test-user');
|
||||
|
||||
-- The pre-move row, archived in place at the old path. Hash 7001 = 0x1b59.
|
||||
INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by,
|
||||
schema, summary, description, lock, extra_perms, archived)
|
||||
VALUES ('test-workspace', 7001, 'f/mvrls_visible/s1',
|
||||
'export function main() { return 1 }',
|
||||
'deno', 'script', 'test-user', '{}', 'S1', '', '', '{}', true);
|
||||
|
||||
-- The post-move row, at the destination, pointing back at 7001.
|
||||
INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by,
|
||||
schema, summary, description, lock, extra_perms, parent_hashes)
|
||||
VALUES ('test-workspace', 7002, 'f/mvrls_secret/s1',
|
||||
'export function main() { return 1 }',
|
||||
'deno', 'script', 'test-user', '{}', 'S1', '', '', '{}', '{7001}');
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
-- Fixture for saving a draft by row id across a rename.
|
||||
--
|
||||
-- A deployed script at `u/test-user/byid_a` (hash 7030 = 0x1b76) with the
|
||||
-- deployer's own draft on it. The test renames the script and then saves the
|
||||
-- draft by id from an editor still bound to the old path.
|
||||
|
||||
INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by,
|
||||
schema, summary, description, lock, extra_perms)
|
||||
VALUES ('test-workspace', 7030, 'u/test-user/byid_a',
|
||||
'export function main() { return 1 }',
|
||||
'deno', 'script', 'test-user', '{}', 'A', '', '', '{}');
|
||||
|
||||
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');
|
||||
@@ -9430,6 +9430,10 @@ paths:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Upsert-only override for the stored creation timestamp. Normal saves omit it (stamped server-side); the localStorage→DB migration passes the draft's original write time so migrated drafts keep their age.
|
||||
id:
|
||||
type: integer
|
||||
format: int64
|
||||
description: The draft row to write, from a previous save's `id` or the get-by-path `draft_id`. Addresses the row wherever a move took it; the URL path only applies when the id names no row of the caller's any more.
|
||||
responses:
|
||||
"200":
|
||||
description: save result
|
||||
@@ -9440,21 +9444,17 @@ paths:
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [saved, conflict, moved]
|
||||
description: "`moved`: the item left this path, so nothing was written — see `moved_to`."
|
||||
enum: [saved, conflict]
|
||||
current_timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
moved_to:
|
||||
id:
|
||||
type: integer
|
||||
format: int64
|
||||
description: "`saved` upserts only: the row's id, to save by from now on."
|
||||
path:
|
||||
type: string
|
||||
description: "`moved` only: the path the item lives at now."
|
||||
moved_by:
|
||||
type: string
|
||||
description: "`moved` only: who last deployed it there. Best-effort."
|
||||
moved_patch:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: "`moved` only: fields to merge into the refused draft before re-saving it at `moved_to` — the typed target path and the version the item now sits at. Sent as a patch so the client does not reproduce the per-kind key names."
|
||||
description: "`saved` upserts only: where the row is. Differs from the URL path once a move has carried the row elsewhere; the editor follows it there."
|
||||
required: [status, current_timestamp]
|
||||
|
||||
/w/{workspace}/drafts/move/{kind}/{path}:
|
||||
@@ -26105,6 +26105,10 @@ components:
|
||||
draft_saved_at:
|
||||
type: string
|
||||
format: date-time
|
||||
draft_id:
|
||||
type: integer
|
||||
format: int64
|
||||
description: The draft row's id. Pass it as `id` on updateDraft so saves follow the row through a move.
|
||||
draft_base:
|
||||
type: string
|
||||
description: |
|
||||
|
||||
@@ -14,11 +14,9 @@ use axum::{
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
error::{Error, Result},
|
||||
scripts::ScriptHash,
|
||||
user_drafts::{DraftUserRef, UserDraftItemKind, ENCRYPTED_DRAFT_PREFIX},
|
||||
users::resolve_username_to_email,
|
||||
utils::{check_proper_path, strip_json_nul},
|
||||
@@ -314,6 +312,11 @@ pub struct SaveDraftRequest {
|
||||
/// keep their age instead of all resurfacing to the top as freshly created.
|
||||
#[serde(default)]
|
||||
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
/// The draft row to write, when the client has saved or loaded it before.
|
||||
/// Addresses the row wherever a move took it; the URL path is only the
|
||||
/// fallback when the id names no row of the caller's any more.
|
||||
#[serde(default)]
|
||||
pub id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
@@ -321,10 +324,6 @@ pub struct SaveDraftRequest {
|
||||
pub enum SaveDraftStatus {
|
||||
Saved,
|
||||
Conflict,
|
||||
/// The item this draft belongs to was moved away from this path. Nothing
|
||||
/// was written — writing would plant a phantom draft-only item at a path
|
||||
/// the item has left.
|
||||
Moved,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
@@ -332,20 +331,14 @@ pub struct SaveDraftResponse {
|
||||
pub status: SaveDraftStatus,
|
||||
/// On `saved`: when the change was applied (client remembers it as the
|
||||
/// next `last_sync`). On `conflict`: the existing row's `created_at`.
|
||||
/// On `moved`: the server's now().
|
||||
pub current_timestamp: chrono::DateTime<chrono::Utc>,
|
||||
/// `moved` only: where the item lives now.
|
||||
/// `saved` upserts only: the row's id, to save by from now on.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub moved_to: Option<String>,
|
||||
/// `moved` only: who last deployed it at its new path. Best-effort.
|
||||
pub id: Option<i64>,
|
||||
/// `saved` upserts only: where the row is. Differs from the URL path once a
|
||||
/// move has carried the row elsewhere; the editor follows it there.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub moved_by: Option<String>,
|
||||
/// `moved` only: the path keys to merge into the refused draft before
|
||||
/// re-saving it at `moved_to`. Sent as a patch so the client never has to
|
||||
/// reproduce the per-kind key names (`UserDraftItemKind::typed_path_field`
|
||||
/// and `mirror_path_field`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub moved_patch: Option<serde_json::Value>,
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// The version a draft forked from, as the editors write it into `draft.value`.
|
||||
@@ -377,11 +370,9 @@ impl DraftBaseVersion {
|
||||
}
|
||||
}
|
||||
|
||||
/// The version this draft forked from, or `None` when it has no lineage to
|
||||
/// follow — a kind that keeps none, a malformed payload, or a draft that was
|
||||
/// never forked from a deploy. Pure: no queries. This is the escape hatch the
|
||||
/// autosave path is built around, so every caller that is about to spend a
|
||||
/// round-trip on move detection should consult it first.
|
||||
/// The version this draft forked from, or `None` when it has none — a kind
|
||||
/// that keeps no lineage, a malformed payload, or a draft that was never forked
|
||||
/// from a deploy. Pure: no queries.
|
||||
fn draft_lineage(kind: UserDraftItemKind, value: &str) -> Option<DraftBaseVersion> {
|
||||
use UserDraftItemKind::*;
|
||||
if !matches!(kind, Script | Flow | App | RawApp) {
|
||||
@@ -400,228 +391,15 @@ fn draft_lineage(kind: UserDraftItemKind, value: &str) -> Option<DraftBaseVersio
|
||||
has_base.then_some(base)
|
||||
}
|
||||
|
||||
/// Where the item that used to live at `path` went, for a draft still bound to
|
||||
/// the old path. Resolved from the version the draft already carries — every
|
||||
/// kind keeps a pointer that outlives a rename:
|
||||
/// - flows: `flow_version.path` is rewritten before the old row is deleted;
|
||||
/// - apps: `app_version` points at the app's surrogate id, which never moves;
|
||||
/// - scripts: the new version prepends the old hash onto `parent_hashes`.
|
||||
/// Apply the current user's draft: non-null `value` upserts, `null` (or
|
||||
/// omitted) deletes. Either way, when the existing row is newer than
|
||||
/// `last_sync` (and `force` is false) the op is skipped and the response is
|
||||
/// `status = conflict` + the server's current timestamp.
|
||||
///
|
||||
/// `None` (⇒ save normally) when the draft carries no base version (a genuine
|
||||
/// draft-only item), when the lineage is gone (item deleted, or recreated at
|
||||
/// the new path by a CLI/git-sync push that leaves no lineage), or when the
|
||||
/// caller can't see where it went.
|
||||
///
|
||||
/// Reads under RLS so the answer can never reveal an item the caller has no
|
||||
/// access to.
|
||||
async fn resolve_moved_to(
|
||||
authed: &ApiAuthed,
|
||||
user_db: &UserDB,
|
||||
w_id: &str,
|
||||
kind: UserDraftItemKind,
|
||||
path: &str,
|
||||
base: &DraftBaseVersion,
|
||||
) -> Result<Option<(String, Option<String>, serde_json::Value)>> {
|
||||
// `UserDB::begin` is not a bare BEGIN — it also issues `set_session_context`
|
||||
// and, under `PG_SCHEMA`, `SET LOCAL search_path`, so this wraps one indexed
|
||||
// existence check in 3-4 round-trips. Callers keep that off a draft with
|
||||
// nothing to follow by having no `DraftBaseVersion` to pass.
|
||||
let mut tx = user_db.clone().begin(authed).await?;
|
||||
let moved = resolve_moved_to_in(&mut tx, w_id, kind, path, base).await;
|
||||
tx.commit().await?;
|
||||
moved
|
||||
}
|
||||
|
||||
/// The body of `resolve_moved_to`, on a caller-supplied transaction. Split out
|
||||
/// so the post-write re-assert can reuse the connection it already holds rather
|
||||
/// than acquiring a second one from the same pool while holding an open
|
||||
/// transaction — that pattern stalls under pool pressure. The transaction it is
|
||||
/// handed must be RLS-scoped: it reports a path and a username the caller may
|
||||
/// have no access to, and passing the write gate at the old path says nothing
|
||||
/// about what the caller may see at the new one.
|
||||
async fn resolve_moved_to_in(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
w_id: &str,
|
||||
kind: UserDraftItemKind,
|
||||
path: &str,
|
||||
base: &DraftBaseVersion,
|
||||
) -> Result<Option<(String, Option<String>, serde_json::Value)>> {
|
||||
use UserDraftItemKind::*;
|
||||
let script_hash = base
|
||||
.parent_hash
|
||||
.as_deref()
|
||||
.and_then(|h| windmill_common::scripts::to_i64(h).ok());
|
||||
|
||||
let moved = match kind {
|
||||
Script => {
|
||||
// An archived row keeps sitting at the old path, so a plain
|
||||
// existence check would miss every script move.
|
||||
let still_here = sqlx::query_scalar!(
|
||||
r#"SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2
|
||||
AND NOT archived AND NOT deleted) as "e!""#,
|
||||
w_id,
|
||||
path,
|
||||
)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
if still_here {
|
||||
None
|
||||
} else {
|
||||
// Only reached once the item is gone. `parent_hashes` carries no
|
||||
// index, so this is a workspace-wide scan — keeping it behind the
|
||||
// cheap check above is what leaves the autosave hot path at one
|
||||
// indexed lookup.
|
||||
sqlx::query!(
|
||||
r#"SELECT path, created_by, hash FROM script
|
||||
WHERE workspace_id = $1 AND $2 = ANY(parent_hashes)
|
||||
AND NOT archived AND NOT deleted
|
||||
ORDER BY created_at DESC LIMIT 1"#,
|
||||
w_id,
|
||||
script_hash,
|
||||
)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
// Hex text, the way the API serializes a hash and the way a
|
||||
// script draft stores `parent_hash`.
|
||||
.map(|r| {
|
||||
(
|
||||
r.path,
|
||||
Some(r.created_by),
|
||||
json!(ScriptHash(r.hash).to_string()),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
Flow => {
|
||||
let still_here = sqlx::query_scalar!(
|
||||
r#"SELECT EXISTS(SELECT 1 FROM flow WHERE workspace_id = $1 AND path = $2) as "e!""#,
|
||||
w_id,
|
||||
path,
|
||||
)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
if still_here {
|
||||
None
|
||||
} else {
|
||||
sqlx::query!(
|
||||
r#"SELECT fv.path, f.edited_by, f.versions[array_upper(f.versions, 1)] as "head!"
|
||||
FROM flow_version fv
|
||||
JOIN flow f ON f.workspace_id = fv.workspace_id AND f.path = fv.path
|
||||
WHERE fv.id = $2 AND fv.workspace_id = $1"#,
|
||||
w_id,
|
||||
base.version_id,
|
||||
)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.map(|r| (r.path, Some(r.edited_by), json!(r.head)))
|
||||
}
|
||||
}
|
||||
App | RawApp => {
|
||||
let still_here = sqlx::query_scalar!(
|
||||
r#"SELECT EXISTS(SELECT 1 FROM app WHERE workspace_id = $1 AND path = $2) as "e!""#,
|
||||
w_id,
|
||||
path,
|
||||
)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
if still_here {
|
||||
None
|
||||
} else {
|
||||
// `created_by` must come from the HEAD row, not from `av` — `av`
|
||||
// is the version the draft forked from, whose author is usually
|
||||
// the person now reading this. Naming them would make
|
||||
// `moved_by_me` true for the wrong user and restamp a draft that
|
||||
// has never seen the head's content.
|
||||
sqlx::query!(
|
||||
r#"SELECT a.path, head.id as "head!", head.created_by as "head_by!"
|
||||
FROM app_version av
|
||||
JOIN app a ON a.id = av.app_id
|
||||
JOIN LATERAL (
|
||||
SELECT id, created_by FROM app_version
|
||||
WHERE app_id = a.id ORDER BY created_at DESC LIMIT 1
|
||||
) head ON true
|
||||
WHERE av.id = $2 AND a.workspace_id = $1"#,
|
||||
w_id,
|
||||
base.parent_version,
|
||||
)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.map(|r| (r.path, Some(r.head_by), json!(r.head)))
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Same path back ⇒ nothing moved (a stale read, or a path reused).
|
||||
let moved = moved.filter(|(new_path, _, _)| new_path != path);
|
||||
|
||||
// "Continue at the new path" is the user explicitly relocating their edits, so
|
||||
// both path keys are set to the destination — unlike the passive carry, which
|
||||
// leaves the value alone because it is not the user's action. The version is
|
||||
// NOT restamped: the draft really is behind the version the move created, and
|
||||
// the editor's stale prompt shows the diff that says whether that matters.
|
||||
// `create_missing` semantics are the client's: it merges this over the value,
|
||||
// so a key the draft never had is added, which is what relocating means here.
|
||||
Ok(moved.map(|(new_path, new_by, _head)| {
|
||||
let mut patch = serde_json::Map::new();
|
||||
for field in [kind.typed_path_field(), kind.mirror_path_field()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
patch.insert(field.to_string(), json!(&new_path));
|
||||
}
|
||||
(new_path, new_by, serde_json::Value::Object(patch))
|
||||
}))
|
||||
}
|
||||
|
||||
/// Is the item still deployed at `path`? One indexed existence check, used to
|
||||
/// NARROW — not close — the window between the `moved` pre-check and the write
|
||||
/// that follows it. A residual remains: the deploy that moves an item calls
|
||||
/// `move_drafts_for_path` inside its own transaction and then does more work
|
||||
/// before committing, so a first save with no prior row at the old path can read
|
||||
/// the pre-move snapshot under READ COMMITTED, take no lock, and commit a stray
|
||||
/// row. That row is bounded — the editor's next autosave hits the pre-check and
|
||||
/// is told the item moved. A save that DOES have a row there serialises behind
|
||||
/// the mover's own UPDATE on that tuple and detects the move correctly.
|
||||
/// Runs on the write's own RLS-scoped connection. An item hidden by RLS reads as
|
||||
/// `false` here, which asks `resolve_moved_to_in`, which answers `None`, so the
|
||||
/// save lands — the same outcome as the `true` this would return if the row were
|
||||
/// visible and unmoved.
|
||||
///
|
||||
/// `false` covers three different situations — moved, deleted, and a genuinely
|
||||
/// draft-only item that never had a deployed row — so it is only ever a cue to
|
||||
/// ask `resolve_moved_to`, never a verdict on its own.
|
||||
async fn deployed_still_at(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
w_id: &str,
|
||||
kind: UserDraftItemKind,
|
||||
path: &str,
|
||||
) -> Result<bool> {
|
||||
let Some(table) = kind.deployed_table() else {
|
||||
return Ok(false);
|
||||
};
|
||||
// A script move archives its parent in place, so the row stays at the old
|
||||
// path and a bare existence check would report "still here" for every script
|
||||
// move. `table` comes from the closed `deployed_table()` enum, never input.
|
||||
let archived_filter = if table == "script" {
|
||||
" AND NOT archived AND NOT deleted"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let q = format!(
|
||||
"SELECT EXISTS(SELECT 1 FROM {table} WHERE workspace_id = $1 AND path = $2{archived_filter})"
|
||||
);
|
||||
Ok(sqlx::query_scalar::<_, bool>(&q)
|
||||
.bind(w_id)
|
||||
.bind(path)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Apply the current user's draft at (workspace, kind, path): non-null `value`
|
||||
/// upserts, `null` (or omitted) deletes. Either way, when the existing row is
|
||||
/// newer than `last_sync` (and `force` is false) the op is skipped and the
|
||||
/// response is `status = conflict` + the server's current timestamp.
|
||||
/// The row is addressed by `id` when the client has one, and by the URL path
|
||||
/// otherwise. An id follows the row wherever a move took it, so an editor left
|
||||
/// open across a rename writes at the item's current path instead of planting
|
||||
/// a phantom draft at the one it left; the response names that path.
|
||||
async fn update_draft(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -630,7 +408,7 @@ async fn update_draft(
|
||||
Json(req): Json<SaveDraftRequest>,
|
||||
) -> Result<Json<SaveDraftResponse>> {
|
||||
let email = &authed.email;
|
||||
let path = path.to_path();
|
||||
let url_path = path.to_path();
|
||||
// Saving a draft requires write permission on the underlying path. Deleting
|
||||
// (discarding) one's OWN draft does not: the email-scoped row belongs to the
|
||||
// authed user, so they can always discard it even after losing write access
|
||||
@@ -640,57 +418,47 @@ async fn update_draft(
|
||||
// — they keep the write gate.
|
||||
let is_own_discard = req.value.is_none() && !req.legacy;
|
||||
|
||||
// Rejected before the moved branch below, not with the rest of the write gate
|
||||
// after it. An operator can never save a draft, so a `moved` answer is no use
|
||||
// to them — and letting them past would hand a read-only role the unindexed
|
||||
// `parent_hashes` lineage scan, repeatable with any fabricated base version.
|
||||
if !is_own_discard && authed.is_operator {
|
||||
return Err(Error::NotAuthorized(
|
||||
"operators cannot save drafts".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// An editor left open across someone else's move is still bound to the old
|
||||
// path and would re-plant its draft there. Refuse and answer with where the
|
||||
// item went; the carried draft is already waiting at the new path.
|
||||
//
|
||||
// Answered BEFORE the write gate, and deliberately: that gate resolves against
|
||||
// the OLD path, where a move has left no deployed row, so a collaborator whose
|
||||
// write access came from the item's own `extra_perms` would be told
|
||||
// "unauthorized" for an item they still have permission on — and never learn
|
||||
// where it went. Nothing is written on this branch, and `resolve_moved_to`
|
||||
// reads under RLS, so it can only name an item the caller can already see.
|
||||
//
|
||||
// Parsed once here and threaded to all three sites that need it: an app draft
|
||||
// runs to hundreds of KB, and serde tokenizes the whole document even to skip
|
||||
// the keys it does not want.
|
||||
let lineage = req
|
||||
.value
|
||||
.as_ref()
|
||||
.and_then(|value| draft_lineage(kind, value.0.get()));
|
||||
if let Some(base) = &lineage {
|
||||
if let Some((moved_to, moved_by, moved_patch)) =
|
||||
resolve_moved_to(&authed, &user_db, &w_id, kind, path, base).await?
|
||||
{
|
||||
let now = sqlx::query_scalar!(r#"SELECT now() as "now!""#)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
return Ok(Json(SaveDraftResponse {
|
||||
status: SaveDraftStatus::Moved,
|
||||
current_timestamp: now,
|
||||
moved_to: Some(moved_to),
|
||||
moved_by,
|
||||
moved_patch: Some(moved_patch),
|
||||
}));
|
||||
// Where the row is now. `None` when the id names no row of ours any more
|
||||
// (discarded elsewhere, or deleted with its item): the URL path then applies,
|
||||
// as it does for a first save.
|
||||
let row_path = match req.id {
|
||||
Some(id) => {
|
||||
sqlx::query_scalar!(
|
||||
"SELECT path 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?
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let followed = row_path.is_some();
|
||||
let path: &str = row_path.as_deref().unwrap_or(url_path);
|
||||
|
||||
// Everything past here writes, so the gate applies from here on. Answered
|
||||
// without the path when the row was followed: the move may have taken it
|
||||
// somewhere the caller cannot see.
|
||||
if !is_own_discard {
|
||||
match require_can_write_path(&authed, &db, &user_db, &w_id, kind, path).await {
|
||||
Err(Error::NotAuthorized(_)) if followed => {
|
||||
return Err(Error::NotAuthorized(
|
||||
"this draft's item was moved to a path you cannot write".to_string(),
|
||||
));
|
||||
}
|
||||
other => other?,
|
||||
}
|
||||
}
|
||||
|
||||
// Everything past here writes, so the gate applies from here on.
|
||||
if !is_own_discard {
|
||||
require_can_write_path(&authed, &db, &user_db, &w_id, kind, path).await?;
|
||||
}
|
||||
|
||||
let applied_at = if let Some(value) = &req.value {
|
||||
let applied = if let Some(value) = &req.value {
|
||||
// Secret variable values must never sit in `draft.value` in plaintext
|
||||
// (see `encrypt_secret_variable_value`).
|
||||
let serialized = if kind == UserDraftItemKind::Variable {
|
||||
@@ -702,33 +470,14 @@ 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);
|
||||
// `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.
|
||||
// The re-assert below needs a transaction to be able to undo the write,
|
||||
// but only a draft with lineage can ever be told it moved — so a draft
|
||||
// with none (a draft-only item, a variable, a trigger) keeps the plain
|
||||
// single-statement write instead of paying BEGIN/COMMIT for a check that
|
||||
// cannot fire.
|
||||
// RLS-scoped, because the re-assert below reads the item tables through
|
||||
// this same connection and must see exactly what the pre-check saw. `draft`
|
||||
// itself carries no policies and grants `windmill_user` full access, so the
|
||||
// upsert is unaffected by the role.
|
||||
let mut tx = match &lineage {
|
||||
Some(_) => Some(user_db.clone().begin(&authed).await?),
|
||||
None => None,
|
||||
};
|
||||
// One owned connection for the no-transaction case, so the executor below
|
||||
// borrows from a binding that outlives the call.
|
||||
let mut plain = match tx {
|
||||
Some(_) => None,
|
||||
None => Some(db.acquire().await?),
|
||||
};
|
||||
// `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 = lineage.as_ref().and_then(|l| l.as_text(kind));
|
||||
let applied = sqlx::query_scalar!(
|
||||
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
|
||||
@@ -737,7 +486,7 @@ async fn update_draft(
|
||||
WHERE $7::bool = true
|
||||
OR $6::timestamptz IS NULL
|
||||
OR draft.created_at <= $6::timestamptz
|
||||
RETURNING created_at"#,
|
||||
RETURNING id, path, created_at"#,
|
||||
&w_id,
|
||||
email,
|
||||
path,
|
||||
@@ -748,45 +497,9 @@ async fn update_draft(
|
||||
req.created_at,
|
||||
base.as_deref(),
|
||||
)
|
||||
.fetch_optional(match (tx.as_mut(), plain.as_mut()) {
|
||||
(Some(tx), _) => &mut **tx as &mut sqlx::PgConnection,
|
||||
(None, Some(conn)) => &mut **conn,
|
||||
(None, None) => unreachable!("exactly one of tx/plain is set"),
|
||||
})
|
||||
.await?;
|
||||
if let Some(mut tx) = tx {
|
||||
// Cheap on the path that matters: one indexed existence check when the
|
||||
// write landed. Only when it says the item is gone do we pay for the
|
||||
// lineage lookup — and that answer is what distinguishes a move (roll
|
||||
// back, report it) from a delete or a never-deployed draft-only item
|
||||
// (both legitimate saves, which resolve to `None`).
|
||||
//
|
||||
// Run on the connection we already hold: acquiring a second from the
|
||||
// same pool while this transaction is open is the two-connection stall.
|
||||
// `tx` exists only because `lineage` did, so this is the same draft the
|
||||
// pre-check consulted.
|
||||
if let Some(base) = lineage.as_ref().filter(|_| applied.is_some()) {
|
||||
if !deployed_still_at(&mut tx, &w_id, kind, path).await? {
|
||||
if let Some((moved_to, moved_by, moved_patch)) =
|
||||
resolve_moved_to_in(&mut tx, &w_id, kind, path, base).await?
|
||||
{
|
||||
tx.rollback().await?;
|
||||
let now = sqlx::query_scalar!(r#"SELECT now() as "now!""#)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
return Ok(Json(SaveDraftResponse {
|
||||
status: SaveDraftStatus::Moved,
|
||||
current_timestamp: now,
|
||||
moved_to: Some(moved_to),
|
||||
moved_by,
|
||||
moved_patch: Some(moved_patch),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
tx.commit().await?;
|
||||
}
|
||||
applied
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.map(|r| (r.created_at, Some(r.id), Some(r.path)))
|
||||
} else {
|
||||
// Delete, same conflict rule in the WHERE clause. Returns NULL when
|
||||
// the row was too new (conflict) OR already absent (idempotent) —
|
||||
@@ -811,15 +524,15 @@ async fn update_draft(
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.map(|ts| (ts, None, None))
|
||||
};
|
||||
|
||||
if let Some(ts) = applied_at {
|
||||
if let Some((ts, id, path)) = applied {
|
||||
return Ok(Json(SaveDraftResponse {
|
||||
status: SaveDraftStatus::Saved,
|
||||
current_timestamp: ts,
|
||||
moved_to: None,
|
||||
moved_by: None,
|
||||
moved_patch: None,
|
||||
id,
|
||||
path,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -844,9 +557,8 @@ async fn update_draft(
|
||||
Some(ts) => Ok(Json(SaveDraftResponse {
|
||||
status: SaveDraftStatus::Conflict,
|
||||
current_timestamp: ts,
|
||||
moved_to: None,
|
||||
moved_by: None,
|
||||
moved_patch: None,
|
||||
id: None,
|
||||
path: None,
|
||||
})),
|
||||
// Delete + nothing-was-there ⇒ report success with server's NOW().
|
||||
None => {
|
||||
@@ -856,9 +568,8 @@ async fn update_draft(
|
||||
Ok(Json(SaveDraftResponse {
|
||||
status: SaveDraftStatus::Saved,
|
||||
current_timestamp: now,
|
||||
moved_to: None,
|
||||
moved_by: None,
|
||||
moved_patch: None,
|
||||
id: None,
|
||||
path: None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -878,12 +589,8 @@ pub struct MoveDraftRequest {
|
||||
/// keys inside its value — there is no deployed row, schedule or trigger to
|
||||
/// cascade to.
|
||||
///
|
||||
/// The owner's OWN open editor is not notified, and cannot be: the moved-item
|
||||
/// handshake in `update_draft` resolves through a deployed version chain, and a
|
||||
/// draft-only item has none by definition. An editor still open on the old path
|
||||
/// re-plants a row there on its next autosave, leaving two items. Closing that
|
||||
/// needs a stable identity for a draft-only item — a tombstone or a surrogate id
|
||||
/// — which is a larger change than this endpoint.
|
||||
/// The owner's own open editor follows: it saves by the row's id, so its next
|
||||
/// autosave lands at the new path and it is told where that is.
|
||||
///
|
||||
/// Scoped to the caller's own row on purpose: two users can each have a draft
|
||||
/// at the same never-deployed path, and those are two separate items.
|
||||
|
||||
@@ -240,6 +240,10 @@ pub struct WithDraftOverlay {
|
||||
pub is_draft: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft_saved_at: Option<DateTime<Utc>>,
|
||||
/// The draft row's id. The editor saves by it from then on, so its writes
|
||||
/// follow the row through a move.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub draft_id: Option<i64>,
|
||||
/// The deployed version the draft forked from (`draft.base`), as text
|
||||
/// whatever the kind. The editor compares it to the head it loaded to tell
|
||||
/// a draft that is behind. Absent when there is no draft or it was never
|
||||
@@ -331,6 +335,7 @@ where
|
||||
inner: Box::new(deployed),
|
||||
is_draft: false,
|
||||
draft_saved_at: None,
|
||||
draft_id: None,
|
||||
draft_base: None,
|
||||
no_deployed: false,
|
||||
draft: None,
|
||||
@@ -351,7 +356,7 @@ where
|
||||
// NULL-email workspace draft. `NULLS LAST` + `LIMIT 1` drops the legacy
|
||||
// row when an owned one exists.
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
|
||||
r#"SELECT id, value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
|
||||
created_at, base
|
||||
FROM draft
|
||||
WHERE workspace_id = $1
|
||||
@@ -373,6 +378,7 @@ where
|
||||
inner: Box::new(deployed),
|
||||
is_draft: false,
|
||||
draft_saved_at: None,
|
||||
draft_id: None,
|
||||
draft_base: None,
|
||||
no_deployed: false,
|
||||
draft: None,
|
||||
@@ -386,6 +392,7 @@ where
|
||||
inner: Box::new(deployed),
|
||||
is_draft: true,
|
||||
draft_saved_at: Some(row.created_at),
|
||||
draft_id: Some(row.id),
|
||||
draft_base: row.base,
|
||||
no_deployed: false,
|
||||
draft: Some(draft_json),
|
||||
@@ -665,7 +672,7 @@ pub async fn fetch_draft_only(
|
||||
) -> Result<Option<WithDraftOverlay>> {
|
||||
// Own draft first, legacy NULL-email row as fallback (see `maybe_overlay_draft`).
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
|
||||
r#"SELECT id, value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
|
||||
created_at, base
|
||||
FROM draft
|
||||
WHERE workspace_id = $1
|
||||
@@ -697,6 +704,7 @@ pub async fn fetch_draft_only(
|
||||
inner: Box::new(draft_json.clone()),
|
||||
is_draft: true,
|
||||
draft_saved_at: Some(row.created_at),
|
||||
draft_id: Some(row.id),
|
||||
draft_base: row.base,
|
||||
no_deployed: true,
|
||||
draft: Some(draft_json),
|
||||
|
||||
@@ -786,7 +786,7 @@
|
||||
// + `tick()` first so the last keystrokes reach the bindable before the
|
||||
// syncer flushes. No toast — the AutosaveIndicator narrates the result, and
|
||||
// `flush` never rejects (postSave routes errors to the failures map).
|
||||
async function saveDraft(): Promise<void> {
|
||||
export async function saveDraft(): Promise<void> {
|
||||
if (!opWorkspace || !userDraftPath) return
|
||||
editor?.flushPendingChanges()
|
||||
await tick()
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
/**
|
||||
* The draft modals every editor route mounts at its trailer:
|
||||
* - DraftSyncConflictModal: surfaces a 409 from the autosave pipeline.
|
||||
* - DraftMovedModal: the item was moved away from this path mid-edit.
|
||||
* - OtherUsersDraftsModal: other users' drafts at this path, for forking.
|
||||
* - StaleDraftModal: prompts when the user's draft predates the latest
|
||||
* deploy; open-state is computed here from the route's timestamps.
|
||||
@@ -13,8 +12,11 @@
|
||||
*/
|
||||
import type { UserDraftItemKind } from '$lib/gen'
|
||||
import DraftSyncConflictModal from './DraftSyncConflictModal.svelte'
|
||||
import DraftMovedModal from './DraftMovedModal.svelte'
|
||||
import OtherUsersDraftsModal, { type OtherDraftUser } from './OtherUsersDraftsModal.svelte'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { base } from '$app/paths'
|
||||
import { goto } from '$app/navigation'
|
||||
import StaleDraftModal from './StaleDraftModal.svelte'
|
||||
import ConfirmationModal from './ConfirmationModal.svelte'
|
||||
import { OtherUserDraftLoad } from '$lib/components/otherUserDraftLoad.svelte'
|
||||
@@ -54,6 +56,11 @@
|
||||
* choice between keeping and discarding is informed. Omit where the editor
|
||||
* has no diff drawer; the action is then not rendered. */
|
||||
onViewDiff?: () => void | Promise<void>
|
||||
/** Runs before this editor follows its draft to the item's new path: the
|
||||
* editor's own draft save, which materializes text the code editor still
|
||||
* holds. Without it, keystrokes typed since the relocating save are lost
|
||||
* to the navigation. */
|
||||
onBeforeRelocate?: () => void | Promise<void>
|
||||
/** Defaults to true; set to false to suppress all modals. */
|
||||
enabled?: boolean
|
||||
}
|
||||
@@ -74,6 +81,7 @@
|
||||
deployedHeadVersion = undefined,
|
||||
onLoadLatestDeploy,
|
||||
onViewDiff,
|
||||
onBeforeRelocate,
|
||||
enabled = true
|
||||
}: Props = $props()
|
||||
|
||||
@@ -113,6 +121,30 @@
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const EDITOR_SEGMENT: Partial<Record<UserDraftItemKind, string>> = {
|
||||
script: 'scripts/edit',
|
||||
flow: 'flows/edit',
|
||||
app: 'apps/edit',
|
||||
raw_app: 'apps_raw/edit'
|
||||
}
|
||||
|
||||
// The item was moved while this editor was open: the draft row followed it
|
||||
// and the save just landed there. Follow it too — the route reloads the item
|
||||
// at its new path, and the stale prompt above then says what changed. Edits
|
||||
// typed since that save are flushed first, so leaving this path drops none.
|
||||
$effect(() => {
|
||||
if (!enabled || !workspace || !path) return
|
||||
const seg = EDITOR_SEGMENT[itemKind]
|
||||
if (!seg) return
|
||||
const query = { workspace, itemKind, path }
|
||||
return UserDraftDbSyncer.onRelocated(query, async (newPath) => {
|
||||
await onBeforeRelocate?.()
|
||||
await UserDraftDbSyncer.flush(query)
|
||||
sendUserToast(`This item was moved to ${newPath}. You are now editing it there.`)
|
||||
await goto(`${base}/${seg}/${newPath}`)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if enabled && workspace && path}
|
||||
@@ -121,7 +153,6 @@
|
||||
{onLoadFromServer}
|
||||
{getLocalDraft}
|
||||
/>
|
||||
<DraftMovedModal query={{ workspace, itemKind, path }} {getLocalDraft} />
|
||||
{#if otherDraftsUsers.length > 0}
|
||||
{#key path}
|
||||
<OtherUsersDraftsModal
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Surfaces the "moved" verdict left by `UserDraftDbSyncer.postSave`: someone
|
||||
* moved this item while the editor was open, so the server refused the
|
||||
* autosave rather than plant a phantom draft-only item at the path the item
|
||||
* has left.
|
||||
*
|
||||
* Continuing pushes the current in-memory draft to the new path (force, since
|
||||
* the draft carried over by the move is older) and follows it there, so edits
|
||||
* made after the move aren't lost to the relocation.
|
||||
*/
|
||||
import { base } from '$app/paths'
|
||||
import { goto } from '$app/navigation'
|
||||
import { UserDraftDbSyncer, type UserDraftLastSyncQuery } from '$lib/userDraftDbSyncer.svelte'
|
||||
import Modal2 from '$lib/components/common/modal/Modal2.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import Alert from '$lib/components/common/alert/Alert.svelte'
|
||||
import { FolderInput } from 'lucide-svelte'
|
||||
|
||||
type Props = {
|
||||
query: UserDraftLastSyncQuery
|
||||
/** Current local draft value, re-pointed at the new path before it is
|
||||
* pushed there. */
|
||||
getLocalDraft: () => unknown
|
||||
}
|
||||
|
||||
let { query, getLocalDraft }: Props = $props()
|
||||
|
||||
const moveHandle = $derived(UserDraftDbSyncer.getMove(query))
|
||||
let isOpen = $derived(moveHandle.move !== undefined)
|
||||
let busy = $state(false)
|
||||
|
||||
const EDITOR_SEGMENT: Partial<Record<string, string>> = {
|
||||
script: 'scripts/edit',
|
||||
flow: 'flows/edit',
|
||||
app: 'apps/edit',
|
||||
raw_app: 'apps_raw/edit'
|
||||
}
|
||||
|
||||
/** Applies the server's `moved_patch` — the typed target path plus the version
|
||||
* the item now sits at. Both come from the server precisely so this file
|
||||
* doesn't reproduce `typed_path_field` / `base_version_field`; re-pointing the
|
||||
* path without the version restamp would land the draft at the new path still
|
||||
* claiming the pre-move version, and greet the user with a stale-draft prompt
|
||||
* offering to discard the edits they just chose to carry. */
|
||||
function repointed(value: unknown, patch: Record<string, unknown> | undefined): unknown {
|
||||
if (value == undefined || typeof value !== 'object' || patch == undefined) return value
|
||||
return { ...(value as Record<string, unknown>), ...patch }
|
||||
}
|
||||
|
||||
// Tagged with the destination it was raised for, because this component is
|
||||
// mounted for the editor's lifetime rather than per prompt: an untagged error
|
||||
// would still be rendered when the next move verdict opens the modal. Tagging
|
||||
// also survives the A→B→C case, where we deliberately re-point the verdict and
|
||||
// then raise an error about the new destination.
|
||||
let carryError = $state<{ title: string; detail: string; forMovedTo: string } | undefined>(
|
||||
undefined
|
||||
)
|
||||
const shownError = $derived(
|
||||
carryError && carryError.forMovedTo === moveHandle.move?.movedTo ? carryError : undefined
|
||||
)
|
||||
|
||||
async function continueThere() {
|
||||
const move = moveHandle.move
|
||||
if (!move) return
|
||||
busy = true
|
||||
carryError = undefined
|
||||
try {
|
||||
const local = getLocalDraft()
|
||||
if (local != undefined) {
|
||||
const target = { workspace: query.workspace, itemKind: query.itemKind, path: move.movedTo }
|
||||
await UserDraftDbSyncer.overwrite({ ...target, value: repointed(local, move.patch) })
|
||||
// `overwrite` resolves whether or not the write landed — a network
|
||||
// failure and a second move both park state instead of throwing. Leaving
|
||||
// here regardless would drop the editor's edits on the floor and, for an
|
||||
// A→B→C move, land on a B that no longer holds the item. Stay put and say
|
||||
// so; the draft is still in this editor, so the user can retry.
|
||||
const failed = UserDraftDbSyncer.getState(target).failureMessage
|
||||
const movedAgain = UserDraftDbSyncer.getMove(target).move
|
||||
if (movedAgain) {
|
||||
// It moved again while we were carrying (A→B→C). Re-point this
|
||||
// editor's own move record at C so the modal now offers C and a
|
||||
// retry makes progress — without this the retry would keep
|
||||
// overwriting at B, be refused again, and loop with no way out.
|
||||
UserDraftDbSyncer.recordMove(query, movedAgain)
|
||||
carryError = {
|
||||
title: 'It moved again while saving',
|
||||
detail: `It is now at ${movedAgain.movedTo}. Your edits are still in this editor — continue to follow it there.`,
|
||||
forMovedTo: movedAgain.movedTo
|
||||
}
|
||||
return
|
||||
}
|
||||
if (failed) {
|
||||
carryError = {
|
||||
title: 'Could not save at the new path',
|
||||
detail: `${failed.replace(/\.?$/, '.')} Your edits are still in this editor.`,
|
||||
forMovedTo: move.movedTo
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
UserDraftDbSyncer.clearMove(query)
|
||||
const seg = EDITOR_SEGMENT[query.itemKind]
|
||||
if (seg) await goto(`${base}/${seg}/${move.movedTo}`)
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal2 bind:isOpen title="This item was moved" fixedWidth="sm" fixedHeight="adaptive">
|
||||
<div class="flex flex-col w-full gap-4">
|
||||
<div class="flex gap-3 items-start flex-1">
|
||||
<FolderInput size={20} class="text-blue-500 shrink-0 mt-0.5" />
|
||||
<div class="text-sm text-primary flex flex-col gap-1">
|
||||
<p>
|
||||
{#if moveHandle.move?.movedBy}
|
||||
<span class="font-semibold">{moveHandle.move.movedBy}</span> moved this to
|
||||
{:else}
|
||||
This was moved to
|
||||
{/if}
|
||||
<span class="font-mono text-xs">{moveHandle.move?.movedTo}</span>. Nothing was saved here
|
||||
— this path no longer holds the item.
|
||||
</p>
|
||||
<p class="text-xs text-secondary">
|
||||
Continuing takes your current edits to the new path, replacing any draft already there.
|
||||
Staying here leaves them unsaved.
|
||||
</p>
|
||||
{#if shownError}
|
||||
<Alert type="error" size="xs" title={shownError.title}>{shownError.detail}</Alert>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
disabled={busy}
|
||||
on:click={() => UserDraftDbSyncer.clearMove(query)}
|
||||
>
|
||||
Stay here
|
||||
</Button>
|
||||
<Button variant="accent" unifiedSize="sm" loading={busy} on:click={continueThere}>
|
||||
Continue at the new path
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal2>
|
||||
@@ -47,8 +47,10 @@ export interface PageDraftSync<V> {
|
||||
seedBaseline(value: V): void
|
||||
/** After a backend load, record the server's `draft_saved_at` so the
|
||||
* next autosave attaches a matching `last_sync` and the server can
|
||||
* reject stale writes. `undefined` clears it (no draft existed). */
|
||||
recordRemoteSync(draftSavedAt: string | undefined): void
|
||||
* reject stale writes. `undefined` clears it (no draft existed). The
|
||||
* row's `draft_id` makes later saves address the row, so they follow a
|
||||
* move. */
|
||||
recordRemoteSync(draftSavedAt: string | undefined, draftId?: number): void
|
||||
/** Drop the draft (server row + local cell) — restore-to-deployed and
|
||||
* post-deploy cleanup. */
|
||||
remove(): void
|
||||
@@ -99,14 +101,15 @@ export function usePageDraftSync<V = unknown>(opts: PageDraftSyncOptions<V>): Pa
|
||||
if (!ws || !p) return
|
||||
UserDraft.seed(opts.itemKind, p, value, { workspace: ws })
|
||||
},
|
||||
recordRemoteSync(draftSavedAt: string | undefined) {
|
||||
recordRemoteSync(draftSavedAt: string | undefined, draftId?: number) {
|
||||
const ws = opts.workspace()
|
||||
const p = opts.path()
|
||||
if (!ws || !p) return
|
||||
untrack(() =>
|
||||
UserDraftDbSyncer.recordRemoteSync(
|
||||
{ workspace: ws, itemKind: opts.itemKind, path: p },
|
||||
draftSavedAt
|
||||
draftSavedAt,
|
||||
draftId
|
||||
)
|
||||
)
|
||||
},
|
||||
|
||||
@@ -262,36 +262,6 @@ export async function migrateUserDraftsToDb(): Promise<void> {
|
||||
path,
|
||||
requestBody: { value, last_sync: writtenAt, created_at: writtenAt }
|
||||
})
|
||||
if (res.status === 'moved' && res.moved_to) {
|
||||
// Nothing was written: the item left this path. Retrying here would
|
||||
// never resolve — `resolve_moved_to` answers from the item's lineage,
|
||||
// so the old path reports `moved` for as long as the item exists — and
|
||||
// dropping the entry would destroy the only copy there is. Follow the
|
||||
// item instead, applying the patch the server just handed us so the
|
||||
// draft lands pointing at where it now lives.
|
||||
const moved = await DraftService.updateDraft({
|
||||
workspace: parsed.workspace,
|
||||
kind: parsed.itemKind,
|
||||
path: res.moved_to,
|
||||
requestBody: {
|
||||
value: { ...(value as object), ...((res.moved_patch as object) ?? {}) },
|
||||
last_sync: writtenAt,
|
||||
created_at: writtenAt
|
||||
}
|
||||
})
|
||||
if (moved.status !== 'saved' && moved.status !== 'conflict') {
|
||||
// Still not landed. Surface it rather than looping: the modal is the
|
||||
// user's only way to see or discard an un-migratable draft.
|
||||
reportDraftMigrationError({
|
||||
key,
|
||||
path,
|
||||
workspace: parsed.workspace,
|
||||
itemKind: parsed.itemKind,
|
||||
value
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (res.status === 'conflict') {
|
||||
console.info(
|
||||
`UserDraft LS→DB migration: server draft for ${path} is fresher, dropping LS copy`
|
||||
|
||||
@@ -129,17 +129,6 @@ export type DraftConflictInfo = {
|
||||
localLastSync: string | null
|
||||
}
|
||||
|
||||
/** Where an item went after someone moved it, as reported by a refused save. */
|
||||
export type DraftMovedInfo = {
|
||||
movedTo: string
|
||||
movedBy: string | undefined
|
||||
/** Fields the server says to merge into the draft before re-saving it at
|
||||
* `movedTo` — the typed target path and the version the item now sits at.
|
||||
* The server owns these key names (`UserDraftItemKind::typed_path_field` /
|
||||
* `base_version_field`), so nothing here reproduces them. */
|
||||
patch: Record<string, unknown> | undefined
|
||||
}
|
||||
|
||||
export type UserDraftLastSyncQuery = {
|
||||
workspace: string
|
||||
itemKind: UserDraftItemKind
|
||||
@@ -226,12 +215,18 @@ const syncLocked = new Map<string, (() => void) | undefined>()
|
||||
const conflicts = new SvelteMap<string, DraftConflictInfo>()
|
||||
|
||||
/**
|
||||
* Keys whose item was MOVED out from under an editor still bound to the old
|
||||
* path. The server refuses the write (saving would plant a phantom draft-only
|
||||
* item where the item no longer is) and answers with where it went; read via
|
||||
* `getMove(query)` to prompt the user over there.
|
||||
* The server row id per key, learned from the load (`draft_id`) or the first
|
||||
* save. Sent with every later save so the write addresses the row wherever a
|
||||
* move took it, rather than the path this editor was opened on.
|
||||
*/
|
||||
const moves = new SvelteMap<string, DraftMovedInfo>()
|
||||
const draftIds = new Map<string, number>()
|
||||
|
||||
/**
|
||||
* Per-key listeners fired when a save lands at a path other than the key's:
|
||||
* the item was moved while the editor was open, and the row followed it. The
|
||||
* editor follows too (see `onRelocated`).
|
||||
*/
|
||||
const relocationListeners = new Map<string, Set<(newPath: string) => void>>()
|
||||
|
||||
/**
|
||||
* Draft keys whose last save threw (network / 5xx) → extracted error
|
||||
@@ -310,21 +305,10 @@ async function postSave(opts: UserDraftDbSyncerSaveOpts): Promise<void> {
|
||||
// `last_sync` (first-ever save) hits the backend's "treat as
|
||||
// fresh" branch.
|
||||
last_sync: opts.force ? undefined : lastSync,
|
||||
force: opts.force ?? false
|
||||
force: opts.force ?? false,
|
||||
id: draftIds.get(key)
|
||||
}
|
||||
})
|
||||
if (resp.status === 'moved') {
|
||||
// Nothing was written. Like a conflict, `lastSync` stays put so the
|
||||
// state survives every retry until the user acts on it.
|
||||
if (resp.moved_to) {
|
||||
moves.set(key, {
|
||||
movedTo: resp.moved_to,
|
||||
movedBy: resp.moved_by,
|
||||
patch: resp.moved_patch as Record<string, unknown> | undefined
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
if (resp.status === 'conflict') {
|
||||
// Someone advanced the row past our `last_sync`. Park the
|
||||
// snapshot for the UI; do NOT touch `lastSync` — the next save
|
||||
@@ -339,8 +323,10 @@ async function postSave(opts: UserDraftDbSyncerSaveOpts): Promise<void> {
|
||||
// resp.status === 'saved' — advance lastSync (or drop on delete).
|
||||
if (opts.value === null) {
|
||||
clearLastSync(opts.workspace, opts.itemKind, opts.path)
|
||||
draftIds.delete(key)
|
||||
} else {
|
||||
setLastSync(opts.workspace, opts.itemKind, opts.path, resp.current_timestamp)
|
||||
if (resp.id != null) draftIds.set(key, resp.id)
|
||||
}
|
||||
// postSave is the only place a draft's server-side existence changes,
|
||||
// so it's the single source for the list pages' `*` hint
|
||||
@@ -348,7 +334,6 @@ async function postSave(opts: UserDraftDbSyncerSaveOpts): Promise<void> {
|
||||
// free instead of maintaining a separate source of truth.
|
||||
setLocalDraftHint(opts.workspace, opts.itemKind, opts.path, opts.value !== null)
|
||||
conflicts.delete(key)
|
||||
moves.delete(key)
|
||||
failures.delete(key)
|
||||
// Clear pending only if it's still the opts we just saved — a
|
||||
// newer `save()` that arrived during the POST replaces the entry
|
||||
@@ -365,6 +350,12 @@ async function postSave(opts: UserDraftDbSyncerSaveOpts): Promise<void> {
|
||||
// cached state the same way an upsert does. Listener errors must never
|
||||
// make a committed save read as failed.
|
||||
notifyAnySaved({ workspace: opts.workspace, itemKind: opts.itemKind, path: opts.path })
|
||||
// The row was carried elsewhere by a move and the save followed it. Last,
|
||||
// so the editor that reacts (by leaving this path) sees a settled key.
|
||||
if (opts.value !== null && resp.path && resp.path !== opts.path) {
|
||||
const listeners = relocationListeners.get(key)
|
||||
if (listeners) for (const l of [...listeners]) l(resp.path)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('UserDraftDbSyncer.save failed', e)
|
||||
// Leave pending opts in place so the next attempt retries the same
|
||||
@@ -558,18 +549,28 @@ export const UserDraftDbSyncer = {
|
||||
* Seed the per-tab `last_sync` after an editor reads a draft from the
|
||||
* server. Pass the response's `draft_saved_at` so the next save sends a
|
||||
* matching `last_sync`; pass `undefined` when no draft existed (next
|
||||
* save omits `last_sync`, the backend's first-push branch).
|
||||
* save omits `last_sync`, the backend's first-push branch). `draftId` is
|
||||
* the row's id from the same response; with it the next save addresses
|
||||
* the row rather than the path, so it follows a move.
|
||||
*/
|
||||
recordRemoteSync(query: UserDraftLastSyncQuery, draftSavedAt: string | undefined): void {
|
||||
recordRemoteSync(
|
||||
query: UserDraftLastSyncQuery,
|
||||
draftSavedAt: string | undefined,
|
||||
draftId?: number
|
||||
): void {
|
||||
const key = draftKey(query.workspace, query.itemKind, query.path)
|
||||
if (draftSavedAt) {
|
||||
setLastSync(query.workspace, query.itemKind, query.path, draftSavedAt)
|
||||
} else {
|
||||
clearLastSync(query.workspace, query.itemKind, query.path)
|
||||
}
|
||||
if (draftId != null) {
|
||||
draftIds.set(key, draftId)
|
||||
} else {
|
||||
draftIds.delete(key)
|
||||
}
|
||||
// Back in sync with the server: clear any conflict / failure.
|
||||
conflicts.delete(key)
|
||||
moves.delete(key)
|
||||
failures.delete(key)
|
||||
},
|
||||
|
||||
@@ -647,27 +648,26 @@ export const UserDraftDbSyncer = {
|
||||
conflicts.delete(draftKey(query.workspace, query.itemKind, query.path))
|
||||
},
|
||||
|
||||
/** Reactive "the item moved away from this path" snapshot, if any. */
|
||||
getMove(query: UserDraftLastSyncQuery): {
|
||||
readonly move: DraftMovedInfo | undefined
|
||||
} {
|
||||
/**
|
||||
* Subscribe to saves for a draft key that landed at another path: the item
|
||||
* was moved while this editor was open and the row followed it. Fired with
|
||||
* the row's current path, after the save is fully accounted for. Returns an
|
||||
* unsubscribe.
|
||||
*/
|
||||
onRelocated(query: UserDraftLastSyncQuery, listener: (newPath: string) => void): () => void {
|
||||
const key = draftKey(query.workspace, query.itemKind, query.path)
|
||||
return {
|
||||
get move() {
|
||||
return moves.get(key)
|
||||
}
|
||||
let set = relocationListeners.get(key)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
relocationListeners.set(key, set)
|
||||
}
|
||||
set.add(listener)
|
||||
return () => {
|
||||
const s = relocationListeners.get(key)
|
||||
if (!s) return
|
||||
s.delete(listener)
|
||||
if (s.size === 0) relocationListeners.delete(key)
|
||||
}
|
||||
},
|
||||
|
||||
clearMove(query: UserDraftLastSyncQuery): void {
|
||||
moves.delete(draftKey(query.workspace, query.itemKind, query.path))
|
||||
},
|
||||
|
||||
/** Re-point a key's move verdict, for an item that moved again while its
|
||||
* carry was in flight (A→B→C). Without this the prompt keeps naming B, and
|
||||
* every retry is refused for the same reason. */
|
||||
recordMove(query: UserDraftLastSyncQuery, move: DraftMovedInfo): void {
|
||||
moves.set(draftKey(query.workspace, query.itemKind, query.path), move)
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -240,7 +240,8 @@
|
||||
if ($workspaceStore && path) {
|
||||
UserDraftDbSyncer.recordRemoteSync(
|
||||
{ workspace: $workspaceStore, itemKind: 'app', path },
|
||||
backendApp.draft_saved_at
|
||||
backendApp.draft_saved_at,
|
||||
backendApp.draft_id
|
||||
)
|
||||
}
|
||||
// The app autosave stores a raw `App`, but this loader (and AppEditor's
|
||||
|
||||
@@ -302,7 +302,10 @@
|
||||
if (getDraft) {
|
||||
otherDraftsUsers = (backendApp.other_drafts_users ?? []) as OtherDraftUser[]
|
||||
}
|
||||
draftSync.recordRemoteSync(backendApp.draft_saved_at as string | undefined)
|
||||
draftSync.recordRemoteSync(
|
||||
backendApp.draft_saved_at as string | undefined,
|
||||
backendApp.draft_id as number | undefined
|
||||
)
|
||||
isNewApp = !!backendApp.no_deployed
|
||||
// Per-response, NOT sticky: a later no-own-draft load in the same editor
|
||||
// must reset this so it can't wrongly force overlay mode.
|
||||
|
||||
@@ -358,7 +358,10 @@
|
||||
if (getDraft) {
|
||||
otherDraftsUsers = (backendFlow.other_drafts_users ?? []) as OtherDraftUser[]
|
||||
}
|
||||
draftSync.recordRemoteSync(backendFlow.draft_saved_at as string | undefined)
|
||||
draftSync.recordRemoteSync(
|
||||
backendFlow.draft_saved_at as string | undefined,
|
||||
backendFlow.draft_id
|
||||
)
|
||||
// Re-evaluate per load: true for draft-only paths, false once deployed.
|
||||
isNewFlow = !!backendFlow.no_deployed
|
||||
// Per-response, NOT sticky: a later no-own-draft load in the same editor
|
||||
@@ -522,6 +525,7 @@
|
||||
{draftBaseVersion}
|
||||
deployedHeadVersion={version != null ? String(version) : undefined}
|
||||
onViewDiff={() => flowBuilder?.openDiffDrawer()}
|
||||
onBeforeRelocate={() => flowBuilder?.saveDraft()}
|
||||
onLoadLatestDeploy={async () => {
|
||||
// stopSync-bracketed; see /scripts/edit's restoreDeployed for the race.
|
||||
if (!$workspaceStore) return
|
||||
|
||||
@@ -317,7 +317,10 @@
|
||||
// Seed the per-tab `last_sync` so the next autosave attaches a matching
|
||||
// timestamp the backend can stale-check. `undefined` (no draft) clears
|
||||
// it, making the next save take the "first push" branch.
|
||||
draftSync.recordRemoteSync(backendScript.draft_saved_at as string | undefined)
|
||||
draftSync.recordRemoteSync(
|
||||
backendScript.draft_saved_at as string | undefined,
|
||||
backendScript.draft_id
|
||||
)
|
||||
// Per-response, NOT sticky: navigating to another path in the same editor
|
||||
// must reset this, else a later no-own-draft load wrongly enters overlay.
|
||||
const hasOwnDraft = !!backendScript.is_draft
|
||||
@@ -483,6 +486,7 @@
|
||||
draftBaseVersion={draftBaseHash}
|
||||
deployedHeadVersion={deployedHeadHash}
|
||||
onViewDiff={() => scriptBuilder?.openDiffDrawer()}
|
||||
onBeforeRelocate={() => scriptBuilder?.saveDraft()}
|
||||
onLoadLatestDeploy={async () => {
|
||||
// stopSync-bracketed; see restoreDeployed for the race.
|
||||
if (!$workspaceStore) return
|
||||
|
||||
Reference in New Issue
Block a user