feat(frontend): track remote rev metadata on UserDraft entries

Extends StoredDraft<V> with two optional rev fields used by the
forthcoming staleness modal:

- remoteRev — the deployed version's id/hash/timestamp at the moment
  the local draft was created. Compared against the latest deployed
  rev on reload.
- remoteDraftRev — the DB-draft created_at at the moment the local
  draft was created. Only meaningful for kinds that have a DB draft
  (script, flow, app, raw_app). Checked first so a teammate's draft
  push is detected before the "deployed version moved" case.

API additions on the handle returned by UserDraft.use():

- handle.meta — read the rev metadata currently stored.
- handle.setDraftAndMeta(value, meta) — atomic write of value + meta in
  a single state.val assignment. Editor routes use this on load so the
  baseline rev rides along with the value without consuming the
  saveInitialValue=false dedup slot twice.
- handle.setMeta(meta) — update just the rev metadata after the user
  picks "Keep current draft" in the staleness modal.
- handle.draft = X — unchanged surface; now preserves existing rev
  metadata across user edits.

Plus UserDraft.getMeta() and UserDraft.save() preserves any persisted
rev metadata when called without a live handle.

7 new tests cover the metadata surface; all 35 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-05-12 22:31:55 +02:00
co-authored by Claude Opus 4.7
parent d844566421
commit 946dfb12f4
2 changed files with 182 additions and 8 deletions
+93 -8
View File
@@ -42,12 +42,27 @@ export type UserDraftUseOptions<V> = 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<V> = { value: V }
type StoredDraft<V> = { value: V } & UserDraftMeta
type DraftState<V> = { val: StoredDraft<V> | undefined }
@@ -78,14 +93,26 @@ function isLocalOnly(path: string): boolean {
return path === ''
}
function wrap<V>(value: V | undefined): StoredDraft<V> | undefined {
return value === undefined ? undefined : { value }
function wrap<V>(value: V | undefined, meta?: UserDraftMeta): StoredDraft<V> | undefined {
if (value === undefined) return undefined
const out: StoredDraft<V> = { value }
if (meta?.remoteRev !== undefined) out.remoteRev = meta.remoteRev
if (meta?.remoteDraftRev !== undefined) out.remoteDraftRev = meta.remoteDraftRev
return out
}
function unwrap<V>(stored: StoredDraft<V> | undefined): V | undefined {
return stored?.value
}
function extractMeta(stored: StoredDraft<unknown> | 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<V>(key: string): StoredDraft<V> | undefined {
try {
const raw = localStorage.getItem(key)
@@ -125,6 +152,28 @@ function localStorageKey(workspace: string, itemKind: UserDraftItemKind, path: s
export type UserDraftHandle<V> = {
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<unknown> | 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<unknown>(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<V>(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<unknown> | undefined)
if (isLocalOnly(path)) return {}
return extractMeta(readPersisted<unknown>(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<V> | 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<V> | undefined
sharedEntry.state.val = wrap(value, extractMeta(current))
},
get meta(): UserDraftMeta {
return extractMeta(sharedEntry.state.val as StoredDraft<unknown> | 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<V> | undefined
if (current === undefined) return
sharedEntry.state.val = wrap(current.value, meta)
}
}
}
+89
View File
@@ -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<string>('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<string>('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<string>('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.