diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 050a8c04c6..32018b3cdc 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -42,12 +42,27 @@ export type UserDraftUseOptions = UserDraftOptions & { defaultValue?: V } +/** + * Snapshot of the remote item's freshness at the moment the local draft was + * written. Used by editor routes to detect that the remote has moved on + * (someone else deployed, or saved a DB draft) so we can warn the user + * before they push stale changes. + * + * - `remoteRev`: the deployed version's id/hash/timestamp at draft creation. + * - `remoteDraftRev`: the DB-draft `created_at` at draft creation, only set + * for kinds that have a DB-draft (`script`, `flow`, `app`, `raw_app`). + */ +export type UserDraftMeta = { + remoteRev?: string | number + remoteDraftRev?: string | number +} + /** * The shape of what we actually persist. Wrapping the value lets us add * metadata (timestamps, originating user, schema version, ...) later * without breaking existing entries. */ -type StoredDraft = { value: V } +type StoredDraft = { value: V } & UserDraftMeta type DraftState = { val: StoredDraft | undefined } @@ -78,14 +93,26 @@ function isLocalOnly(path: string): boolean { return path === '' } -function wrap(value: V | undefined): StoredDraft | undefined { - return value === undefined ? undefined : { value } +function wrap(value: V | undefined, meta?: UserDraftMeta): StoredDraft | undefined { + if (value === undefined) return undefined + const out: StoredDraft = { value } + if (meta?.remoteRev !== undefined) out.remoteRev = meta.remoteRev + if (meta?.remoteDraftRev !== undefined) out.remoteDraftRev = meta.remoteDraftRev + return out } function unwrap(stored: StoredDraft | undefined): V | undefined { return stored?.value } +function extractMeta(stored: StoredDraft | undefined): UserDraftMeta { + if (!stored) return {} + const meta: UserDraftMeta = {} + if (stored.remoteRev !== undefined) meta.remoteRev = stored.remoteRev + if (stored.remoteDraftRev !== undefined) meta.remoteDraftRev = stored.remoteDraftRev + return meta +} + function readPersisted(key: string): StoredDraft | undefined { try { const raw = localStorage.getItem(key) @@ -125,6 +152,28 @@ function localStorageKey(workspace: string, itemKind: UserDraftItemKind, path: s export type UserDraftHandle = { get draft(): V | undefined set draft(value: V | undefined) + /** + * Read the rev metadata stored alongside the current draft. Empty object + * if the entry has no draft or no rev was ever recorded. + */ + get meta(): UserDraftMeta + /** + * Atomically set the draft value AND its rev metadata in a single write. + * + * Used by editor routes to record the backend rev at load time without + * triggering an extra persist (combined with the value write, the + * underlying useLocalStorageValue's saveInitialValue=false dedup skips + * it). Subsequent `handle.draft = X` writes only mutate `value` and + * preserve whatever rev metadata is in place. + */ + setDraftAndMeta(value: V | undefined, meta: UserDraftMeta): void + /** + * Update the rev metadata in place without touching the draft value. + * Used after the "Keep current draft" modal action to ack the new remote + * rev so the staleness modal doesn't fire again until the remote moves + * further. + */ + setMeta(meta: UserDraftMeta): void } export const UserDraft = { @@ -136,12 +185,21 @@ export const UserDraft = { // Update the shared reactive state so all observers are notified. // For non-empty paths the underlying useLocalStorageValue setter // persists the wrapped value; for empty paths it stays in-memory. - entry.state.val = wrap(value) + // Preserve any existing rev metadata on the entry. + const current = entry.state.val as StoredDraft | undefined + entry.state.val = wrap(value, extractMeta(current)) return } if (isLocalOnly(path)) return + // External save without a live handle: preserve any persisted meta + // so the staleness signal isn't lost just because the editor wasn't + // open while we wrote. + const existing = readPersisted(localStorageKey(ws, itemKind, path)) try { - localStorage.setItem(localStorageKey(ws, itemKind, path), JSON.stringify(wrap(value))) + localStorage.setItem( + localStorageKey(ws, itemKind, path), + JSON.stringify(wrap(value, extractMeta(existing))) + ) } catch (e) { console.error('UserDraft.save: localStorage write failed', e) } @@ -162,6 +220,19 @@ export const UserDraft = { return unwrap(readPersisted(localStorageKey(ws, itemKind, path))) }, + /** + * Read the rev metadata for the entry. Returns an empty object if there + * is no entry. Useful for staleness checks before reading the draft. + */ + getMeta(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): UserDraftMeta { + const ws = resolveWorkspace(opts) + const mk = mapKey(ws, itemKind, path) + const entry = entries.get(mk) + if (entry) return extractMeta(entry.state.val as StoredDraft | undefined) + if (isLocalOnly(path)) return {} + return extractMeta(readPersisted(localStorageKey(ws, itemKind, path))) + }, + /** * Whether a draft currently exists for (workspace, itemKind, path). * For non-empty paths this checks localStorage; for empty paths it @@ -230,9 +301,23 @@ export const UserDraft = { return unwrap(sharedEntry.state.val as StoredDraft | undefined) }, set draft(value: V | undefined) { - // useLocalStorageValue's setter writes synchronously and - // removes the localStorage entry when value is undefined. - sharedEntry.state.val = wrap(value) + // Preserve existing rev metadata when the user just edits the + // value (e.g. typing in the editor). useLocalStorageValue's + // setter writes synchronously and removes the localStorage + // entry when value is undefined. + const current = sharedEntry.state.val as StoredDraft | undefined + sharedEntry.state.val = wrap(value, extractMeta(current)) + }, + get meta(): UserDraftMeta { + return extractMeta(sharedEntry.state.val as StoredDraft | undefined) + }, + setDraftAndMeta(value: V | undefined, meta: UserDraftMeta): void { + sharedEntry.state.val = wrap(value, meta) + }, + setMeta(meta: UserDraftMeta): void { + const current = sharedEntry.state.val as StoredDraft | undefined + if (current === undefined) return + sharedEntry.state.val = wrap(current.value, meta) } } } diff --git a/frontend/src/lib/userDraft.test.ts b/frontend/src/lib/userDraft.test.ts index 80dab74328..69d25457b3 100644 --- a/frontend/src/lib/userDraft.test.ts +++ b/frontend/src/lib/userDraft.test.ts @@ -277,6 +277,95 @@ describe('UserDraft — empty path (new-item, in-memory only)', () => { }) }) +describe('UserDraft — rev metadata for staleness checks', () => { + it('setDraftAndMeta atomically stores value + rev, and the first write is still skipped', () => { + const handle = UserDraft.use('flow', 'u/me/atomic') + + // Single atomic write — under saveInitialValue=false this counts as the + // initial baseline and shouldn't hit localStorage yet. + handle.setDraftAndMeta('backendValue', { + remoteRev: 42, + remoteDraftRev: '2026-01-01T00:00:00Z' + }) + expect(handle.draft).toBe('backendValue') + expect(handle.meta).toEqual({ remoteRev: 42, remoteDraftRev: '2026-01-01T00:00:00Z' }) + expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/atomic')).toBeNull() + + // A subsequent user edit persists *with* the rev metadata. + handle.draft = 'userEdit' + const raw = localStorage.getItem('userdraft/w/test_ws/flow/u/me/atomic') + expect(raw).toBe( + JSON.stringify({ + value: 'userEdit', + remoteRev: 42, + remoteDraftRev: '2026-01-01T00:00:00Z' + }) + ) + }) + + it('setMeta updates only the rev fields, preserving the value', () => { + const handle = UserDraft.use('flow', 'u/me/setmeta') + handle.setDraftAndMeta('initial', { remoteRev: 1 }) // baseline, not persisted + handle.draft = 'edited' // persisted with remoteRev: 1 + + handle.setMeta({ remoteRev: 2 }) + expect(handle.draft).toBe('edited') + expect(handle.meta).toEqual({ remoteRev: 2 }) + expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/setmeta')).toBe( + JSON.stringify({ value: 'edited', remoteRev: 2 }) + ) + }) + + it('handle.draft setter preserves rev metadata across user edits', () => { + const handle = UserDraft.use<{ count: number }>('flow', 'u/me/preserve') + handle.setDraftAndMeta({ count: 0 }, { remoteRev: 'v1' }) + handle.draft = { count: 1 } // first edit, persisted + handle.draft = { count: 2 } // another edit + + expect(handle.meta).toEqual({ remoteRev: 'v1' }) + expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/preserve')).toBe( + JSON.stringify({ value: { count: 2 }, remoteRev: 'v1' }) + ) + }) + + it('UserDraft.getMeta reads from localStorage when no live handle exists', () => { + localStorage.setItem( + 'userdraft/w/test_ws/flow/u/me/getmeta', + JSON.stringify({ value: 'x', remoteRev: 7, remoteDraftRev: '2026-01-02' }) + ) + expect(UserDraft.getMeta('flow', 'u/me/getmeta')).toEqual({ + remoteRev: 7, + remoteDraftRev: '2026-01-02' + }) + }) + + it('UserDraft.getMeta returns empty object when there is no entry', () => { + expect(UserDraft.getMeta('flow', 'u/me/none')).toEqual({}) + }) + + it('UserDraft.save preserves persisted rev metadata when no live handle exists', () => { + localStorage.setItem( + 'userdraft/w/test_ws/flow/u/me/savepreserve', + JSON.stringify({ value: 'old', remoteRev: 5 }) + ) + UserDraft.save('flow', 'u/me/savepreserve', 'new') + + expect(localStorage.getItem('userdraft/w/test_ws/flow/u/me/savepreserve')).toBe( + JSON.stringify({ value: 'new', remoteRev: 5 }) + ) + }) + + it('handle.meta is empty for a draft persisted without rev (forward compat with older entries)', () => { + localStorage.setItem( + 'userdraft/w/test_ws/flow/u/me/legacy', + JSON.stringify({ value: 'no-rev' }) + ) + const handle = UserDraft.use('flow', 'u/me/legacy') + expect(handle.draft).toBe('no-rev') + expect(handle.meta).toEqual({}) + }) +}) + describe('UserDraft.use() — reference counting & cleanup', () => { it('destroys the entry when the last handle is released', () => { // First handle acquires the entry.