fix: drop script lineage a fork does not have when copying drafts into it

A script draft kept its base and parent_hash on the grounds that a fork
shares its parent's script hashes. That holds for what the fork cloned,
but not for a version deployed in the parent afterwards, and a deploy in
the fork naming it as parent is refused. The copy now keeps the lineage
only when the target has that version.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-27 01:08:41 -07:00
co-authored by Claude Opus 5.5
parent bc08c7ffb0
commit a0696922d3
4 changed files with 106 additions and 72 deletions
@@ -1,66 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO draft (workspace_id, path, typ, value, created_at, email, base)\n SELECT $2, path, typ,\n to_json(\n CASE WHEN typ IN ('script', 'flow') THEN clean - 'on_behalf_of' ELSE clean END\n - CASE WHEN typ = 'flow' THEN 'version_id'\n WHEN typ IN ('app', 'raw_app') THEN 'parent_version'\n ELSE '' END\n ),\n created_at, email,\n CASE WHEN typ = 'script' THEN base END\n FROM (\n SELECT d.path, d.typ, d.created_at, d.email, d.base,\n replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)), chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean\n FROM draft d\n WHERE d.workspace_id = $1\n AND CASE WHEN $4::text[] IS NULL THEN d.email = $3 OR d.email IS NULL\n ELSE d.email = $3\n AND (d.typ::text, d.path) IN (SELECT * FROM unnest($4::text[], $5::text[]))\n END\n ) s\n RETURNING typ AS \"typ: UserDraftItemKind\", path",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "typ: UserDraftItemKind",
"type_info": {
"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"
]
}
}
}
},
{
"ordinal": 1,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Varchar",
"Text",
"TextArray",
"TextArray"
]
},
"nullable": [
false,
false
]
},
"hash": "16a1515956430730ac432af5d0bbdd4c20cd50574a47f3d76639cecd7aff67c4"
}
@@ -0,0 +1,66 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO draft (workspace_id, path, typ, value, created_at, email, base)\n SELECT $2::text, path, typ,\n to_json(\n CASE WHEN typ IN ('script', 'flow') THEN clean - 'on_behalf_of' ELSE clean END\n - CASE WHEN typ = 'flow' THEN 'version_id'\n WHEN typ IN ('app', 'raw_app') THEN 'parent_version'\n ELSE '' END\n - CASE WHEN typ = 'script' AND NOT EXISTS (\n SELECT 1 FROM script sc WHERE sc.workspace_id::text = $2::text\n AND lpad(to_hex(sc.hash), 16, '0') = clean->>'parent_hash')\n THEN 'parent_hash' ELSE '' END\n ),\n created_at, email,\n CASE WHEN typ = 'script' AND EXISTS (\n SELECT 1 FROM script sc WHERE sc.workspace_id::text = $2::text\n AND lpad(to_hex(sc.hash), 16, '0') = base)\n THEN base END\n FROM (\n SELECT d.path, d.typ, d.created_at, d.email, d.base,\n replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)), chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean\n FROM draft d\n WHERE d.workspace_id = $1\n AND CASE WHEN $4::text[] IS NULL THEN d.email = $3 OR d.email IS NULL\n ELSE d.email = $3\n AND (d.typ::text, d.path) IN (SELECT * FROM unnest($4::text[], $5::text[]))\n END\n ) s\n RETURNING typ AS \"typ: UserDraftItemKind\", path",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "typ: UserDraftItemKind",
"type_info": {
"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"
]
}
}
}
},
{
"ordinal": 1,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"TextArray",
"TextArray"
]
},
"nullable": [
false,
false
]
},
"hash": "8b62d25c98a6b3db3a0b7039cacf79799e6d7c7c6c9532545844bec9ec314516"
}
@@ -467,6 +467,32 @@ async fn test_transfer_drafts_to_fork(db: Pool<Postgres>) -> anyhow::Result<()>
.await?;
assert_eq!(on_behalf_of, "u/test-user");
// A script version the fork does not have (deployed in the source after the fork was made)
// is no lineage there: a deploy naming it as parent would be refused.
let r = c
.post(save_url(port, "script", "u/test-user/lineage"))
.json(&json!({ "value": { "content": "l", "parent_hash": "0123456789abcdef" } }))
.send()
.await?;
assert_eq!(r.status(), 200, "{}", r.text().await?);
let r = c
.post(&transfer_url)
.json(&json!({
"target_workspace": fork,
"items": [{ "kind": "script", "path": "u/test-user/lineage" }]
}))
.send()
.await?;
assert_eq!(r.status(), 200, "{}", r.text().await?);
let (parent_hash, base): (Option<String>, Option<String>) = sqlx::query_as(
"SELECT value->>'parent_hash', base FROM draft
WHERE workspace_id = $1 AND path = 'u/test-user/lineage'",
)
.bind(fork)
.fetch_one(&db)
.await?;
assert_eq!((parent_hash, base), (None, None));
// Only a fork of the source is a valid target.
let r = c
.post(format!(
+14 -6
View File
@@ -695,10 +695,11 @@ pub async fn copy_drafts_to_workspace(
// deploy time, which is the more accurate answer of the two.
let rows = sqlx::query!(
// A script hash is content-addressed and shared between a workspace and its forks, so
// a script draft's base still names a version the target can have. Flow and app ids
// are minted per workspace, so those drafts arrive with no base (staleness falls back
// to the timestamps), lineage field included, or the next autosave would re-derive the
// source id.
// a script draft keeps its base (and `parent_hash`) when the target has that version,
// which a fork's clone does; one deployed in the source after the fork was made is not
// there, and a deploy naming it as parent would be refused. Flow and app ids are minted
// per workspace, so those drafts arrive with no base (staleness falls back to the
// timestamps), lineage field included, or the next autosave would re-derive the source id.
//
// `clean` is `strip_json_nul`'s parity rule in SQL, so a pre-sanitizer U+0000 escape
// cannot abort the copy on `to_jsonb` or arrive with its principal unstripped: escaped
@@ -706,15 +707,22 @@ pub async fn copy_drafts_to_workspace(
// byte) so only a real NUL is removed, and chr(92) spells the backslash so no escape
// sequence reaches this source file.
r#"INSERT INTO draft (workspace_id, path, typ, value, created_at, email, base)
SELECT $2, path, typ,
SELECT $2::text, path, typ,
to_json(
CASE WHEN typ IN ('script', 'flow') THEN clean - 'on_behalf_of' ELSE clean END
- CASE WHEN typ = 'flow' THEN 'version_id'
WHEN typ IN ('app', 'raw_app') THEN 'parent_version'
ELSE '' END
- CASE WHEN typ = 'script' AND NOT EXISTS (
SELECT 1 FROM script sc WHERE sc.workspace_id::text = $2::text
AND lpad(to_hex(sc.hash), 16, '0') = clean->>'parent_hash')
THEN 'parent_hash' ELSE '' END
),
created_at, email,
CASE WHEN typ = 'script' THEN base END
CASE WHEN typ = 'script' AND EXISTS (
SELECT 1 FROM script sc WHERE sc.workspace_id::text = $2::text
AND lpad(to_hex(sc.hash), 16, '0') = base)
THEN base END
FROM (
SELECT d.path, d.typ, d.created_at, d.email, d.base,
replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)), chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean