feat: store the version a draft forked from in one draft.base column

Every kind kept its fork base under a different name and type inside the
value: parent_hash (hex) for scripts, version_id for flows, parent_version for
apps. draft.base holds it as one text id, derived on save from the value so
every writer fills it the same way, backfilled by the migration (rows holding a
NUL are skipped, since ->> raises on them). The get-by-path overlay exposes it
as draft_base and the drafts list as base; the editors and the compare page
read that one field and compare it to the head as text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-11 10:35:18 +02:00
co-authored by Claude Fable 5.1
parent 57d0756160
commit 7ad7312cd0
15 changed files with 136 additions and 75 deletions
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value as \"value!: sqlx::types::Json<Box<serde_json::value::RawValue>>\",\n created_at\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 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": [
{
@@ -12,6 +12,11 @@
"ordinal": 1,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "base",
"type_info": "Text"
}
],
"parameters": {
@@ -58,8 +63,9 @@
},
"nullable": [
false,
false
false,
true
]
},
"hash": "d4e0da9f9653d532770066310f85e59e5edda1faea72f39603814afb6a3cd596"
"hash": "0cc6770a81ecaecafe0d9b7100f94b329c4ec7978b016af4c964415e0c38396c"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO draft (workspace_id, email, path, typ, value, created_at)\n VALUES ($1, $2, $3, $4, $5::text::json, COALESCE($8::timestamptz, now()))\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 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 created_at",
"describe": {
"columns": [
{
@@ -52,12 +52,13 @@
"Text",
"Timestamptz",
"Bool",
"Timestamptz"
"Timestamptz",
"Text"
]
},
"nullable": [
false
]
},
"hash": "c8fb2a1491f90951a6d2e4e9c56d288eb60f6c6644a73cdf949de0159215a7f7"
"hash": "d3686c73788b866090f25383da507e9a7d9f90d5f86fa4a1b89026cb088d0a58"
}
@@ -0,0 +1 @@
ALTER TABLE draft DROP COLUMN base;
@@ -0,0 +1,14 @@
-- The version a draft forked from, as one opaque text id whatever the kind: a
-- script hash (hex), a flow_version.id, an app_version.id. NULL for a draft that
-- was never forked from a deploy and for kinds that keep no lineage.
ALTER TABLE draft ADD COLUMN base TEXT;
-- A U+0000 inside a `json` value makes `->>` raise 22P05; such rows keep NULL and
-- get their base on their next save.
UPDATE draft SET base = CASE typ::text
WHEN 'script' THEN value ->> 'parent_hash'
WHEN 'flow' THEN value ->> 'version_id'
ELSE value ->> 'parent_version'
END
WHERE typ::text IN ('script', 'flow', 'app', 'raw_app')
AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) = 0;
+10
View File
@@ -9301,6 +9301,9 @@ paths:
created_at:
type: string
format: date-time
base:
type: string
description: The deployed version this draft forked from (script hash, flow version id or app version id, as text). Absent when the draft was never forked from a deploy.
can_write:
type: boolean
description: Whether the current user may deploy/discard this draft (same check the deploy/discard endpoints enforce).
@@ -26102,6 +26105,13 @@ components:
draft_saved_at:
type: string
format: date-time
draft_base:
type: string
description: |
The deployed version the draft forked from, as text whatever the
kind (script hash, flow version id, app version id). Compare to the
deployed head to tell a draft that is behind. Absent when there is
no draft or it was never forked from a deploy.
no_deployed:
type: boolean
draft:
+28 -3
View File
@@ -57,6 +57,11 @@ pub struct DraftListItem {
/// row exists at this (path, kind) — the DISTINCT ON prefers an owned row.
pub legacy_draft: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
/// The deployed version this draft forked from (`draft.base`): a script
/// hash, a flow version id or an app version id, always as text. `None` for
/// a draft that was never forked from a deploy.
#[serde(skip_serializing_if = "Option::is_none")]
pub base: Option<String>,
/// All draft authors at this `(path, kind)`, for the shared full-page-editor
/// kinds (script/flow/app/raw_app) only — feeds the home-page-style owner
/// circles on the review page. `None` for drawer kinds, which keep their
@@ -245,6 +250,7 @@ fn list_drafts_query(all_users: bool) -> String {
d.path,
d.typ AS kind,
d.created_at,
d.base,
d.value ->> 'summary' AS summary,
{draft_users} AS draft_users,
-- Friendly typed path, by kind (mirrors the home-page list
@@ -357,6 +363,20 @@ struct DraftBaseVersion {
parent_version: Option<i64>,
}
impl DraftBaseVersion {
/// The base as the `draft.base` column stores it: one opaque text id whatever
/// the kind, so a reader compares it to the head without knowing the kind's
/// own field name or type.
fn as_text(&self, kind: UserDraftItemKind) -> Option<String> {
use UserDraftItemKind::*;
match kind {
Script => self.parent_hash.clone(),
Flow => self.version_id.map(|v| v.to_string()),
_ => self.parent_version.map(|v| v.to_string()),
}
}
}
/// 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
@@ -705,11 +725,15 @@ async fn update_draft(
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!(
r#"INSERT INTO draft (workspace_id, email, path, typ, value, created_at)
VALUES ($1, $2, $3, $4, $5::text::json, COALESCE($8::timestamptz, now()))
r#"INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)
VALUES ($1, $2, $3, $4, $5::text::json, COALESCE($8::timestamptz, now()), $9)
ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL
DO UPDATE SET value = EXCLUDED.value, created_at = EXCLUDED.created_at
DO UPDATE SET value = EXCLUDED.value, created_at = EXCLUDED.created_at,
base = EXCLUDED.base
WHERE $7::bool = true
OR $6::timestamptz IS NULL
OR draft.created_at <= $6::timestamptz
@@ -722,6 +746,7 @@ async fn update_draft(
req.last_sync,
req.force,
req.created_at,
base.as_deref(),
)
.fetch_optional(match (tx.as_mut(), plain.as_mut()) {
(Some(tx), _) => &mut **tx as &mut sqlx::PgConnection,
+12 -3
View File
@@ -188,7 +188,6 @@ impl UserDraftItemKind {
}
}
/// 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
@@ -241,6 +240,12 @@ pub struct WithDraftOverlay {
pub is_draft: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_saved_at: Option<DateTime<Utc>>,
/// 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
/// forked from a deploy.
#[serde(skip_serializing_if = "Option::is_none")]
pub draft_base: Option<String>,
/// True when no deployed row exists at this path: `inner` is only a
/// best-effort stand-in synthesized from the draft and only `draft` is
/// canonical. Frontend uses this to disable "diff/reset vs deployed" and
@@ -326,6 +331,7 @@ where
inner: Box::new(deployed),
is_draft: false,
draft_saved_at: None,
draft_base: None,
no_deployed: false,
draft: None,
other_drafts_users: Vec::new(),
@@ -346,7 +352,7 @@ where
// row when an owned one exists.
let row = sqlx::query!(
r#"SELECT value as "value!: sqlx::types::Json<Box<serde_json::value::RawValue>>",
created_at
created_at, base
FROM draft
WHERE workspace_id = $1
AND (email = $2 OR email IS NULL)
@@ -367,6 +373,7 @@ where
inner: Box::new(deployed),
is_draft: false,
draft_saved_at: None,
draft_base: None,
no_deployed: false,
draft: None,
other_drafts_users,
@@ -379,6 +386,7 @@ where
inner: Box::new(deployed),
is_draft: true,
draft_saved_at: Some(row.created_at),
draft_base: row.base,
no_deployed: false,
draft: Some(draft_json),
other_drafts_users,
@@ -676,7 +684,7 @@ pub async fn fetch_draft_only(
// 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>>",
created_at
created_at, base
FROM draft
WHERE workspace_id = $1
AND (email = $2 OR email IS NULL)
@@ -707,6 +715,7 @@ pub async fn fetch_draft_only(
inner: Box::new(draft_json.clone()),
is_draft: true,
draft_saved_at: Some(row.created_at),
draft_base: row.base,
no_deployed: true,
draft: Some(draft_json),
other_drafts_users,
@@ -292,7 +292,7 @@
// a draft, else the load-time head. This catches both a concurrent deploy
// (head moved since open) AND a stale draft reopened after a deploy (head ==
// load-time head, but the draft was forked from an older version).
const base = draftBaseVersion ?? version
const base = draftBaseVersion ?? (version != null ? String(version) : undefined)
if (base === undefined) {
return
}
@@ -303,7 +303,7 @@
path: initialPath
})
onLatest = base === flowVersion?.id
onLatest = flowVersion != null && base === String(flowVersion.id)
} else {
onLatest = true
}
@@ -40,15 +40,14 @@
draftSavedAt?: string | undefined
/** ISO timestamp of the latest deploy at this path. */
deployedAt?: string | undefined
/** Precise staleness inputs: the deployed version the draft is pinned to, and
* the current deployed head. When both are set they drive `isStale` and the
* dedup key instead of the timestamps, which drift past `deployedAt` as you
* keep editing. Flows/apps pass version ids and pin the true fork point;
* scripts pass hashes and re-pin to the head on each load, so one edit after
* a dismissed stale prompt stops it recurring — the same self-healing the
* timestamps had. Absent (pre-feature drafts) ⇒ timestamp fallback. */
draftBaseVersion?: number | string | undefined
deployedHeadVersion?: number | string | undefined
/** Precise staleness inputs: the deployed version the draft forked from
* (`draft_base` on the get-by-path response) and the current deployed head,
* both as text whatever the kind. When both are set they drive `isStale` and
* the dedup key instead of the timestamps, which drift past `deployedAt` as
* you keep editing. Absent (a draft never forked from a deploy) ⇒ timestamp
* fallback. */
draftBaseVersion?: string | undefined
deployedHeadVersion?: string | undefined
/** Discard the draft and reload deployed (same as "Reset to deployed"). */
onLoadLatestDeploy?: () => void | Promise<void>
/** Opens the editor's Deployed↔Current diff from the stale prompt, so the
@@ -85,9 +84,7 @@
// Prefer the version comparison over the timestamp for every kind that supplies
// one: `draftSavedAt` advances past `deployedAt` as you keep editing, hiding the
// staleness outright. What the version pins differs — flows/apps hold the fork
// point, scripts the head of their last load — so see `draftBaseVersion` above
// before reasoning about when this stops firing.
// staleness outright.
const useVersion = $derived(draftBaseVersion != null && deployedHeadVersion != null)
const isStale = $derived(
!!onLoadLatestDeploy &&
+4 -3
View File
@@ -28,9 +28,10 @@ export type FlowBuilderProps = {
disabledFlowInputs?: boolean
savedPrimarySchedule?: ScheduleTrigger | undefined // used to set the primary schedule in the legacy primaryScheduleStore
version?: number | undefined
/** flow_version the draft was forked from; when set, the deploy-time staleness
* check compares it (not the load-time head `version`) against the latest. */
draftBaseVersion?: number | undefined
/** flow_version the draft was forked from (as text, like `draft_base`); when
* set, the deploy-time staleness check compares it (not the load-time head
* `version`) against the latest. */
draftBaseVersion?: string | undefined
draftTriggersFromUrl?: Trigger[] | undefined
selectedTriggerIndexFromUrl?: number | undefined
children?: import('svelte').Snippet
+13 -17
View File
@@ -38,30 +38,26 @@ import { ScriptService, FlowService } from '$lib/gen'
// fabricates) the "started from an older deployed version" warning.
describe('draftBaseIsStale', () => {
it('script: stale iff the draft parent_hash differs from the deployed hash', () => {
expect(draftBaseIsStale('script', { hash: 'v2', draft: { parent_hash: 'v1' } })).toBe(true)
expect(draftBaseIsStale('script', { hash: 'v2', draft: { parent_hash: 'v2' } })).toBe(false)
it('script: stale iff draft_base differs from the deployed hash', () => {
expect(draftBaseIsStale('script', { hash: 'v2', draft_base: 'v1' })).toBe(true)
expect(draftBaseIsStale('script', { hash: 'v2', draft_base: 'v2' })).toBe(false)
})
it('script: no base pointer or no head → not stale (nothing to compare)', () => {
it('script: no base or no head → not stale (nothing to compare)', () => {
expect(draftBaseIsStale('script', { hash: 'v2', draft: {} })).toBe(false)
expect(draftBaseIsStale('script', { draft: { parent_hash: 'v1' } })).toBe(false)
expect(draftBaseIsStale('script', { draft_base: 'v1' })).toBe(false)
})
it('flow: compares the pinned version_id against the deployed head', () => {
expect(draftBaseIsStale('flow', { version_id: 7, draft: { version_id: 5 } })).toBe(true)
expect(draftBaseIsStale('flow', { version_id: 7, draft: { version_id: 7 } })).toBe(false)
expect(draftBaseIsStale('flow', { version_id: 7, draft: {} })).toBe(false)
it('flow: compares draft_base against the deployed version_id as text', () => {
expect(draftBaseIsStale('flow', { version_id: 7, draft_base: '5' })).toBe(true)
expect(draftBaseIsStale('flow', { version_id: 7, draft_base: '7' })).toBe(false)
expect(draftBaseIsStale('flow', { version_id: 7 })).toBe(false)
})
it('app/raw_app: compares parent_version against the last of versions', () => {
expect(draftBaseIsStale('app', { versions: [1, 2, 3], draft: { parent_version: 2 } })).toBe(
true
)
expect(draftBaseIsStale('raw_app', { versions: [1, 2, 3], draft: { parent_version: 3 } })).toBe(
false
)
expect(draftBaseIsStale('app', { versions: [], draft: { parent_version: 2 } })).toBe(false)
it('app/raw_app: compares draft_base against the last of versions', () => {
expect(draftBaseIsStale('app', { versions: [1, 2, 3], draft_base: '2' })).toBe(true)
expect(draftBaseIsStale('raw_app', { versions: [1, 2, 3], draft_base: '3' })).toBe(false)
expect(draftBaseIsStale('app', { versions: [], draft_base: '2' })).toBe(false)
})
it('no draft on the response → not stale', () => {
+16 -16
View File
@@ -356,24 +356,24 @@ export async function getDraftDiffValues(
/**
* Whether a draft's base is stale: the deployed version the draft forked from
* no longer matches the current deployed head — a newer version was deployed
* after the draft began, so deploying the draft would silently revert it.
* Scripts compare the draft's `parent_hash` vs the deployed `hash`; flows the
* pinned `version_id` vs the deployed head `version_id`; apps (incl. raw) the
* pinned `parent_version` vs the head of `versions`. `r` is the item fetched
* with `get_draft=true`; only script/flow/app kinds carry a base pointer.
* (`draft_base`, text whatever the kind) no longer matches the current deployed
* head — a newer version was deployed after the draft began, so deploying the
* draft would silently revert it. The head is the deployed `hash` for scripts,
* `version_id` for flows, the last of `versions` for apps (incl. raw). `r` is
* the item fetched with `get_draft=true`.
*/
export function draftBaseIsStale(draftKind: UserDraftItemKind, r: any): boolean {
const draft = r?.draft
if (!draft) return false
if (draftKind === 'script') {
return !!r.hash && !!draft.parent_hash && draft.parent_hash !== r.hash
}
if (draftKind === 'flow') {
return r.version_id != null && draft.version_id != null && draft.version_id !== r.version_id
}
const head = Array.isArray(r.versions) ? r.versions[r.versions.length - 1] : undefined
return head != null && draft.parent_version != null && draft.parent_version !== head
const base = r?.draft_base
if (base == null) return false
const head =
draftKind === 'script'
? r.hash
: draftKind === 'flow'
? r.version_id
: Array.isArray(r.versions)
? r.versions[r.versions.length - 1]
: undefined
return head != null && String(head) !== base
}
/** Fetch-and-test wrapper over `draftBaseIsStale` for one draft item. Returns
@@ -52,8 +52,8 @@
let deployedAt = $state<string | undefined>(undefined)
// The app_version the draft was forked from (pinned), + the deployed head, for
// the precise staleness check in DraftEditorModals (vs the drifting timestamp).
let draftBaseVersion = $state<number | undefined>(undefined)
let deployedHeadVersion = $state<number | undefined>(undefined)
let draftBaseVersion = $state<string | undefined>(undefined)
let deployedHeadVersion = $state<string | undefined>(undefined)
/** Increments per `loadApp` call. Stale loads (e.g. when picker
* navigation races a draft-discard reload) bail at the next checkpoint
@@ -289,13 +289,14 @@
// `no_deployed` — no baseline to be older than.
draftSavedAt = backendApp.draft_saved_at as string | undefined
deployedAt = backendApp.no_deployed ? undefined : (backendApp.created_at as string | undefined)
// `parent_version` rides on the persisted draft (pinned at fork); undefined
// for a pre-feature draft. Head = the last entry of the deployed `versions`.
draftBaseVersion = savedDraftApp?.parent_version
deployedHeadVersion =
// The app_version the draft forked from; undefined for a draft never forked
// from a deploy. Head = the last entry of the deployed `versions`.
draftBaseVersion = backendApp.draft_base
const headVersion =
backendApp.no_deployed || !backendApp.versions
? undefined
: backendApp.versions[backendApp.versions.length - 1]
deployedHeadVersion = headVersion != null ? String(headVersion) : undefined
const backendApp_ = structuredClone(stateSnapshot(backendApp))
savedApp = {
summary: backendApp_.summary,
@@ -58,7 +58,7 @@
let deployedAt = $state<string | undefined>(undefined)
// The flow_version the draft was forked from (pinned, doesn't drift), for the
// precise staleness check in DraftEditorModals + FlowBuilder's deploy guard.
let draftBaseVersion = $state<number | undefined>(undefined)
let draftBaseVersion = $state<string | undefined>(undefined)
// Editor-displayed path; defaults to the URL path. Cleared to '' in the
// `new_draft` branch so the Path widget's `initPath` seeds the friendly name.
let flowInitialPath = $state(page.params.path ?? '')
@@ -372,9 +372,9 @@
// Layer the draft (`.draft`, if any) over the deployed payload at the field
// level. See /scripts/edit's loader for the rationale.
const { draft: draftFromBackend, ...deployedFlow } = backendFlow as any
// `version_id` rides on the persisted draft (pinned at fork); undefined for a
// pre-feature draft or when editing the deployed flow directly (no draft).
draftBaseVersion = draftFromBackend?.version_id as number | undefined
// The flow_version the draft forked from; undefined when editing the deployed
// flow directly (no draft) or for a draft never forked from a deploy.
draftBaseVersion = backendFlow.draft_base
const effectiveFlow: Flow = draftFromBackend
? ({ ...deployedFlow, ...draftFromBackend } as Flow)
: (deployedFlow as Flow)
@@ -520,7 +520,7 @@
{draftSavedAt}
{deployedAt}
{draftBaseVersion}
deployedHeadVersion={version}
deployedHeadVersion={version != null ? String(version) : undefined}
onViewDiff={() => flowBuilder?.openDiffDrawer()}
onLoadLatestDeploy={async () => {
// stopSync-bracketed; see /scripts/edit's restoreDeployed for the race.
@@ -333,7 +333,7 @@
// Exact staleness, preferred over the timestamps: a draft carried across
// a move keeps its old save time while the move mints a fresh deploy, so
// the timestamps alone would call every carried draft stale.
draftBaseHash = draftFromBackend?.parent_hash as string | undefined
draftBaseHash = backendScript.draft_base
deployedHeadHash = backendScript.hash as string | undefined
const effectiveScript: EditableScript = draftFromBackend
? { ...deployedScript, ...draftFromBackend }
@@ -343,7 +343,7 @@
// it (by discarding or rebasing). Seeding it from the head here would let
// the next autosave persist the head as the base, so a draft behind the
// deploy reads as up to date after one open.
const parentHash = topHash ?? draftFromBackend?.parent_hash ?? backendScript.hash
const parentHash = topHash ?? backendScript.draft_base ?? backendScript.hash
// Baseline for the autosave `discardIf`: the deployed script with the
// same `parent_hash` graft the seed below applies, so the unedited draft
// compares equal. `undefined` when there's no deployed row.