feat: carry every draft with an item when it moves

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-08-07 18:50:50 +02:00
parent 17a2a1ba64
commit 4157795162
13 changed files with 738 additions and 1 deletions
@@ -0,0 +1,59 @@
{
"db_name": "PostgreSQL",
"query": "SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email = $4",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
"Text",
{
"Custom": {
"name": "draft_kind",
"kind": {
"Enum": [
"script",
"flow",
"app",
"raw_app",
"resource",
"variable",
"trigger_schedule",
"trigger_webhook",
"trigger_default_email",
"trigger_email",
"trigger_http",
"trigger_websocket",
"trigger_postgres",
"trigger_kafka",
"trigger_nats",
"trigger_mqtt",
"trigger_sqs",
"trigger_gcp",
"trigger_azure",
"trigger_poll",
"trigger_cli",
"trigger_nextcloud",
"trigger_google",
"trigger_github",
"data_pipeline",
"trigger_amqp"
]
}
}
},
"Text"
]
},
"nullable": [
null
]
},
"hash": "10f8e68e31df7b6e0bcd10091a46e8f2a00674eb87a5d025484751674656ca25"
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft AS d\n SET path = $3,\n value = to_json(\n jsonb_set(to_jsonb(d.value), ARRAY[$4::text], to_jsonb($3::text), false)\n )\n WHERE d.workspace_id = $1\n AND d.path = $2\n AND d.typ::text = ANY($5::text[])\n AND NOT EXISTS (\n SELECT 1 FROM draft o\n WHERE o.workspace_id = d.workspace_id\n AND o.path = $3\n AND o.typ = d.typ\n AND o.email IS NOT DISTINCT FROM d.email\n )\n RETURNING d.id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Text",
"TextArray"
]
},
"nullable": [
false
]
},
"hash": "30958446d29c3fa521bda176c23994caad0c7a1c05374f872f87ea838a412801"
}
@@ -0,0 +1,36 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n EXISTS(SELECT 1 FROM flow WHERE workspace_id = $1 AND path = $2) as \"still_here!\",\n (SELECT fv.path FROM flow_version fv\n JOIN flow f ON f.workspace_id = fv.workspace_id AND f.path = fv.path\n WHERE fv.id = $3 AND fv.workspace_id = $1) as new_path,\n (SELECT f.edited_by FROM flow_version fv\n JOIN flow f ON f.workspace_id = fv.workspace_id AND f.path = fv.path\n WHERE fv.id = $3 AND fv.workspace_id = $1) as new_by",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "still_here!",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "new_path",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "new_by",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Int8"
]
},
"nullable": [
null,
null,
null
]
},
"hash": "56c66db90fc43db69357c41d55d685fc9f4c2947ab6aef60b36d09c0edd1ce89"
}
@@ -0,0 +1,62 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft\n SET path = $3,\n value = to_json(\n jsonb_set(\n CASE WHEN $7::text IS NULL THEN to_jsonb(value)\n ELSE jsonb_set(to_jsonb(value), ARRAY['summary'], to_jsonb($7::text))\n END,\n ARRAY[$5::text], to_jsonb($3::text), false\n )\n )\n WHERE workspace_id = $1\n AND path = $2\n AND typ = $4\n AND email = $6\n AND NOT EXISTS (\n SELECT 1 FROM draft o\n WHERE o.workspace_id = $1 AND o.path = $3 AND o.typ = $4 AND o.email = $6\n )\n RETURNING id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
{
"Custom": {
"name": "draft_kind",
"kind": {
"Enum": [
"script",
"flow",
"app",
"raw_app",
"resource",
"variable",
"trigger_schedule",
"trigger_webhook",
"trigger_default_email",
"trigger_email",
"trigger_http",
"trigger_websocket",
"trigger_postgres",
"trigger_kafka",
"trigger_nats",
"trigger_mqtt",
"trigger_sqs",
"trigger_gcp",
"trigger_azure",
"trigger_poll",
"trigger_cli",
"trigger_nextcloud",
"trigger_google",
"trigger_github",
"data_pipeline",
"trigger_amqp"
]
}
}
},
"Text",
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "5980a2c9348982eab53e092496dc2d2e2319d9e87a9deb05238f311928201695"
}
@@ -0,0 +1,36 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n EXISTS(SELECT 1 FROM app WHERE workspace_id = $1 AND path = $2) as \"still_here!\",\n (SELECT a.path FROM app_version av JOIN app a ON a.id = av.app_id\n WHERE av.id = $3 AND a.workspace_id = $1) as new_path,\n (SELECT av.created_by FROM app_version av JOIN app a ON a.id = av.app_id\n WHERE av.id = $3 AND a.workspace_id = $1) as new_by",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "still_here!",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "new_path",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "new_by",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Int8"
]
},
"nullable": [
null,
null,
null
]
},
"hash": "5aafeaefd99e25c66c705e1cbc8681ccec81817226a5d31927b10bf97152c8d9"
}
@@ -0,0 +1,36 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2\n AND NOT archived AND NOT deleted) as \"still_here!\",\n (SELECT path FROM script WHERE workspace_id = $1 AND $3 = ANY(parent_hashes)\n AND NOT archived AND NOT deleted ORDER BY created_at DESC LIMIT 1) as new_path,\n (SELECT created_by FROM script WHERE workspace_id = $1 AND $3 = ANY(parent_hashes)\n AND NOT archived AND NOT deleted ORDER BY created_at DESC LIMIT 1) as new_by",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "still_here!",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "new_path",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "new_by",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Int8"
]
},
"nullable": [
null,
null,
null
]
},
"hash": "9031e85807fb009d212ab5e9a158f2d467a10ae71ec0aa3f4aae603be1453798"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE draft\n SET value = to_json(\n jsonb_set(to_jsonb(value), ARRAY[$1::text], $2::text::jsonb, false)\n )\n WHERE id = ANY($3)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Int8Array"
]
},
"nullable": []
},
"hash": "d9a6dd74f55006a6ea314af83d6f8fb463d6bc57c9ef20dfd415558ab4565738"
}
+17
View File
@@ -1350,6 +1350,23 @@ async fn update_flow(
.await?;
}
if is_new_path {
// Everything left at the old path is a draft this deploy didn't consume
// — teammates' rows, and the deployer's own when the caller asked us to
// keep it (a move re-deploys the DEPLOYED content, not the draft).
// Carry them rather than strand them.
windmill_common::user_drafts::move_drafts_for_path(
&mut tx,
&w_id,
&[UserDraftItemKind::Flow],
flow_path,
&nf.path,
UserDraftItemKind::Flow.typed_path_field(),
Some(("version_id", version.to_string())),
)
.await?;
}
audit_log(
&mut *tx,
&authed,
@@ -1982,6 +1982,24 @@ async fn create_script_internal<'c>(
.await?;
}
if p_path != &ns.path {
// Everything left at the old path is a draft this deploy didn't
// consume — teammates' rows, and the deployer's own when the caller
// asked us to keep it (a move re-deploys the DEPLOYED content, not
// the draft). Carry them rather than strand them.
windmill_common::user_drafts::move_drafts_for_path(
&mut tx,
&w_id,
&[UserDraftItemKind::Script],
p_path,
&ns.path,
UserDraftItemKind::Script.typed_path_field(),
// Drafts store a hash the way the API serializes one: hex text.
Some(("parent_hash", format!("\"{}\"", hash))),
)
.await?;
}
sqlx::query!(
"UPDATE capture_config SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS FALSE",
ns.path,
+44 -1
View File
@@ -9142,12 +9142,55 @@ paths:
properties:
status:
type: string
enum: [saved, conflict]
enum: [saved, conflict, moved]
description: "`moved`: the item left this path, so nothing was written — see `moved_to`."
current_timestamp:
type: string
format: date-time
moved_to:
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."
required: [status, current_timestamp]
/w/{workspace}/drafts/move/{kind}/{path}:
post:
summary: move the current user's draft-only item to another path
description: Relocates the authed user's own draft row (and the typed path inside its value). Only for draft-only items — a deployed item must be moved through its own deploy endpoint, which carries every draft with it.
operationId: moveDraft
tags:
- draft
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: kind
in: path
required: true
schema:
$ref: "#/components/schemas/UserDraftItemKind"
- $ref: "#/components/parameters/ScriptPath"
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
new_path:
type: string
summary:
type: string
description: Also restate the draft's summary.
required: [new_path]
responses:
"200":
description: move result
content:
text/plain:
schema:
type: string
/w/{workspace}/drafts/migrate_legacy/{kind}/{path}:
post:
summary: resolve a legacy (workspace-level) draft (admin only)
+16
View File
@@ -3231,6 +3231,22 @@ async fn update_app_internal<'a>(
.execute(&mut *tx)
.await?;
}
if npath != path {
// Everything left at the old path is a draft this deploy didn't consume
// — teammates' rows, and the deployer's own when the caller asked us to
// keep it (a move re-deploys the DEPLOYED content, not the draft).
// Carry them rather than strand them.
windmill_common::user_drafts::move_drafts_for_path(
&mut tx,
&w_id,
&[UserDraftItemKind::App, UserDraftItemKind::RawApp],
path,
&npath,
UserDraftItemKind::App.typed_path_field(),
Some(("parent_version", v_id.to_string())),
)
.await?;
}
audit_log(
&mut *tx,
&authed,
+283
View File
@@ -29,6 +29,7 @@ pub fn workspaced_service() -> Router {
.route("/get/{kind}/{*path}", get(get_draft_for_user))
.route("/get_own/{kind}/{*path}", get(get_own_draft))
.route("/update/{kind}/{*path}", post(update_draft))
.route("/move/{kind}/{*path}", post(move_draft))
.route("/migrate_legacy/{kind}/{*path}", post(migrate_legacy_draft))
}
@@ -308,6 +309,10 @@ 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)]
@@ -315,7 +320,147 @@ 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.
#[serde(skip_serializing_if = "Option::is_none")]
pub moved_to: Option<String>,
/// `moved` only: who last deployed it at its new path. Best-effort.
#[serde(skip_serializing_if = "Option::is_none")]
pub moved_by: Option<String>,
}
/// The version a draft forked from, as the editors write it into `draft.value`.
/// Each kind names it differently and only one is ever set.
#[derive(Deserialize)]
struct DraftBaseVersion {
/// Scripts: hex-encoded script hash.
#[serde(default)]
parent_hash: Option<String>,
/// Flows: `flow_version.id`.
#[serde(default)]
version_id: Option<i64>,
/// Apps / raw apps: `app_version.id`.
#[serde(default)]
parent_version: Option<i64>,
}
/// 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`.
///
/// `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.
///
/// Read 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,
value: &str,
) -> Result<Option<(String, Option<String>)>> {
use UserDraftItemKind::*;
if !matches!(kind, Script | Flow | App | RawApp) {
return Ok(None);
}
// A malformed or version-less draft is simply a draft with no lineage. This
// is also the autosave hot path's escape hatch: no base version, no query.
let Ok(base) = serde_json::from_str::<DraftBaseVersion>(value) else {
return Ok(None);
};
let script_hash = base
.parent_hash
.as_deref()
.and_then(|h| windmill_common::scripts::to_i64(h).ok());
let has_base = match kind {
Script => script_hash.is_some(),
Flow => base.version_id.is_some(),
_ => base.parent_version.is_some(),
};
if !has_base {
return Ok(None);
}
// One round-trip per save: "is it still here" and "where did it go" answered
// together, so a normal autosave costs a single indexed lookup.
let mut tx = user_db.clone().begin(authed).await?;
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 r = sqlx::query!(
r#"SELECT
EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2
AND NOT archived AND NOT deleted) as "still_here!",
(SELECT path FROM script WHERE workspace_id = $1 AND $3 = ANY(parent_hashes)
AND NOT archived AND NOT deleted ORDER BY created_at DESC LIMIT 1) as new_path,
(SELECT created_by FROM script WHERE workspace_id = $1 AND $3 = ANY(parent_hashes)
AND NOT archived AND NOT deleted ORDER BY created_at DESC LIMIT 1) as new_by"#,
w_id,
path,
script_hash,
)
.fetch_one(&mut *tx)
.await?;
(!r.still_here)
.then_some(r.new_path)
.flatten()
.map(|p| (p, r.new_by))
}
Flow => {
let r = sqlx::query!(
r#"SELECT
EXISTS(SELECT 1 FROM flow WHERE workspace_id = $1 AND path = $2) as "still_here!",
(SELECT fv.path FROM flow_version fv
JOIN flow f ON f.workspace_id = fv.workspace_id AND f.path = fv.path
WHERE fv.id = $3 AND fv.workspace_id = $1) as new_path,
(SELECT f.edited_by FROM flow_version fv
JOIN flow f ON f.workspace_id = fv.workspace_id AND f.path = fv.path
WHERE fv.id = $3 AND fv.workspace_id = $1) as new_by"#,
w_id,
path,
base.version_id,
)
.fetch_one(&mut *tx)
.await?;
(!r.still_here)
.then_some(r.new_path)
.flatten()
.map(|p| (p, r.new_by))
}
App | RawApp => {
let r = sqlx::query!(
r#"SELECT
EXISTS(SELECT 1 FROM app WHERE workspace_id = $1 AND path = $2) as "still_here!",
(SELECT a.path FROM app_version av JOIN app a ON a.id = av.app_id
WHERE av.id = $3 AND a.workspace_id = $1) as new_path,
(SELECT av.created_by FROM app_version av JOIN app a ON a.id = av.app_id
WHERE av.id = $3 AND a.workspace_id = $1) as new_by"#,
w_id,
path,
base.parent_version,
)
.fetch_one(&mut *tx)
.await?;
(!r.still_here)
.then_some(r.new_path)
.flatten()
.map(|p| (p, r.new_by))
}
_ => None,
};
tx.commit().await?;
// Same path back ⇒ nothing moved (a stale read, or a path reused).
Ok(moved.filter(|(new_path, _)| new_path != path))
}
/// Apply the current user's draft at (workspace, kind, path): non-null `value`
@@ -343,6 +488,25 @@ async fn update_draft(
require_can_write_path(&authed, &db, &user_db, &w_id, kind, path).await?;
}
// 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.
if let Some(value) = &req.value {
if let Some((moved_to, moved_by)) =
resolve_moved_to(&authed, &user_db, &w_id, kind, path, value.0.get()).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,
}));
}
}
let applied_at = if let Some(value) = &req.value {
// Secret variable values must never sit in `draft.value` in plaintext
// (see `encrypt_secret_variable_value`).
@@ -409,6 +573,8 @@ async fn update_draft(
return Ok(Json(SaveDraftResponse {
status: SaveDraftStatus::Saved,
current_timestamp: ts,
moved_to: None,
moved_by: None,
}));
}
@@ -433,6 +599,8 @@ async fn update_draft(
Some(ts) => Ok(Json(SaveDraftResponse {
status: SaveDraftStatus::Conflict,
current_timestamp: ts,
moved_to: None,
moved_by: None,
})),
// Delete + nothing-was-there ⇒ report success with server's NOW().
None => {
@@ -442,11 +610,126 @@ async fn update_draft(
Ok(Json(SaveDraftResponse {
status: SaveDraftStatus::Saved,
current_timestamp: now,
moved_to: None,
moved_by: None,
}))
}
}
}
#[derive(Deserialize)]
pub struct MoveDraftRequest {
pub new_path: String,
/// Also restate the draft's summary, so the same drawer that renames a
/// deployed item can retitle a draft-only one.
#[serde(default)]
pub summary: Option<String>,
}
/// Relocate the authed user's own DRAFT-ONLY item. Such an item is nothing but
/// its draft row, so moving it is a rewrite of that row's path plus the typed
/// path inside its value — there is no deployed row, schedule or trigger to
/// cascade to, and no second party to notify (a draft is private to its owner).
///
/// Scoped to the caller's own row on purpose: two users can each have a draft
/// at the same never-deployed path, and those are two separate items.
///
/// A DEPLOYED item must move through its own deploy endpoint instead, which
/// cascades everything that references the path and carries every draft along.
async fn move_draft(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, kind, path)): Path<(String, UserDraftItemKind, windmill_common::utils::StripPath)>,
Json(req): Json<MoveDraftRequest>,
) -> Result<String> {
let path = path.to_path();
let new_path = req.new_path.as_str();
if new_path == path {
return Ok("unchanged".to_string());
}
require_can_write_path(&authed, &db, &user_db, &w_id, kind, path).await?;
require_can_write_path(&authed, &db, &user_db, &w_id, kind, new_path).await?;
if let Some(table) = kind.deployed_table() {
// `table` is from the closed `deployed_table()` enum, never user input.
let query = format!("SELECT 1 FROM {table} WHERE path = $1 AND workspace_id = $2 LIMIT 1");
let mut tx = user_db.clone().begin(&authed).await?;
let deployed_at_old = sqlx::query_scalar::<_, i32>(&query)
.bind(path)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
let deployed_at_new = sqlx::query_scalar::<_, i32>(&query)
.bind(new_path)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
if deployed_at_old.is_some() {
return Err(Error::BadRequest(format!(
"'{path}' is deployed — move it from its editor so schedules and triggers follow"
)));
}
if deployed_at_new.is_some() {
return Err(Error::BadRequest(format!(
"'{new_path}' already has a deployed item — moving there would turn this into a draft on top of it"
)));
}
}
let moved = sqlx::query_scalar!(
r#"UPDATE draft
SET path = $3,
value = to_json(
jsonb_set(
CASE WHEN $7::text IS NULL THEN to_jsonb(value)
ELSE jsonb_set(to_jsonb(value), ARRAY['summary'], to_jsonb($7::text))
END,
ARRAY[$5::text], to_jsonb($3::text), false
)
)
WHERE workspace_id = $1
AND path = $2
AND typ = $4
AND email = $6
AND NOT EXISTS (
SELECT 1 FROM draft o
WHERE o.workspace_id = $1 AND o.path = $3 AND o.typ = $4 AND o.email = $6
)
RETURNING id"#,
&w_id,
path,
new_path,
kind as UserDraftItemKind,
kind.typed_path_field(),
&authed.email,
req.summary,
)
.fetch_optional(&db)
.await?;
if moved.is_none() {
let exists_at_target = sqlx::query_scalar!(
"SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email = $4",
&w_id,
new_path,
kind as UserDraftItemKind,
&authed.email,
)
.fetch_optional(&db)
.await?
.is_some();
return Err(Error::BadRequest(if exists_at_target {
format!("You already have a draft at '{new_path}'")
} else {
format!("You have no draft at '{path}'")
}));
}
Ok(format!("moved draft {path} to {new_path}"))
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
pub enum MigrateLegacyDraftAction {
@@ -160,6 +160,17 @@ impl UserDraftItemKind {
}
}
/// The `draft.value` key holding the user-typed target path — where a
/// deploy of this draft would land when the user staged a rename. A script
/// draft round-trips its own `path`; every other kind writes a separate
/// `draft_path`, and only when it differs from the row's path.
pub fn typed_path_field(&self) -> &'static str {
match self {
UserDraftItemKind::Script => "path",
_ => "draft_path",
}
}
/// Whether OTHER users' drafts at a path are visible to a viewer (the
/// "others are editing" list, owner circles, and the `get_draft_for_user`
/// View JSON / Fork endpoint). Enabled only for the full-page editor items
@@ -479,6 +490,84 @@ pub async fn delete_own_draft_for_path(
Ok(())
}
/// Carry every draft at `old_path` over to `new_path` when an item MOVES
/// (rename or relocation). A draft is bound to its item by nothing but the path
/// string, so without this a move detaches every draft on the item. No owner
/// filter: teammates' rows and the legacy NULL-email row follow too.
///
/// `typed_path_field` is the draft JSON key holding the user-typed target path
/// (`path` for scripts, `draft_path` for flows/apps). It is rewritten whenever
/// present — a target staged against the old location would otherwise un-move
/// the item the next time that draft is deployed. Absent means "same as the
/// row's path", which the move already fixed.
///
/// `base_version` restamps the version the draft forked from so the carried
/// draft doesn't read as stale against the version the move just created. The
/// value is JSON text, because the kinds disagree on its type: a script's
/// `parent_hash` is a hex string, a flow's `version_id` and an app's
/// `parent_version` are numbers. Only restamps rows that already carry the
/// field.
///
/// A row whose owner already has a draft at `new_path` stays put: the target
/// draft is work in its own right and is never overwritten.
pub async fn move_drafts_for_path(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
w_id: &str,
kinds: &[UserDraftItemKind],
old_path: &str,
new_path: &str,
typed_path_field: &str,
base_version: Option<(&str, String)>,
) -> Result<()> {
let typs = kinds.iter().map(|k| k.as_str()).collect::<Vec<_>>();
// `create_missing = false` on the typed path: absent means "same as the
// row's path", which `SET path` already points at `new_path`.
let moved = sqlx::query_scalar!(
r#"UPDATE draft AS d
SET path = $3,
value = to_json(
jsonb_set(to_jsonb(d.value), ARRAY[$4::text], to_jsonb($3::text), false)
)
WHERE d.workspace_id = $1
AND d.path = $2
AND d.typ::text = ANY($5::text[])
AND NOT EXISTS (
SELECT 1 FROM draft o
WHERE o.workspace_id = d.workspace_id
AND o.path = $3
AND o.typ = d.typ
AND o.email IS NOT DISTINCT FROM d.email
)
RETURNING d.id"#,
w_id,
old_path,
new_path,
typed_path_field,
&typs as &[&str],
)
.fetch_all(&mut **tx)
.await?;
if let Some((field, version)) = base_version {
if !moved.is_empty() {
sqlx::query!(
r#"UPDATE draft
SET value = to_json(
jsonb_set(to_jsonb(value), ARRAY[$1::text], $2::text::jsonb, false)
)
WHERE id = ANY($3)"#,
field,
version,
&moved,
)
.execute(&mut **tx)
.await?;
}
}
Ok(())
}
/// 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