mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-19 08:03:16 +00:00
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:
co-authored by
Claude Opus 4.7
parent
d844566421
commit
946dfb12f4
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user