feat: add userdraft listing primitives (#9268)

* feat: add userdraft listing primitives

* fix: cancel stale userdraft discard writes

* docs: remove global ai userdraft plan
This commit is contained in:
centdix
2026-05-21 17:30:17 +02:00
committed by GitHub
parent 0692b97c8a
commit d0ee697e8b
3 changed files with 420 additions and 36 deletions
+21 -5
View File
@@ -601,7 +601,7 @@ export function useLocalStorageValue<T>(
*/
transformBeforePersist?: (val: T) => T
}
): { val: T; skipNextWriteOnce(): void } {
): { val: T; skipNextWriteOnce(): void; setWithoutPersist(newVal: T): void } {
const saveInitialValue = options?.saveInitialValue ?? true
const debounceMs = options?.debounce ?? 0
const transformBeforePersist = options?.transformBeforePersist
@@ -626,7 +626,9 @@ export function useLocalStorageValue<T>(
}
}
if (typeof window === 'undefined') return { val: defaultValue, skipNextWriteOnce: () => {} }
if (typeof window === 'undefined') {
return { val: defaultValue, skipNextWriteOnce: () => {}, setWithoutPersist: () => {} }
}
const savedValue = localStorage.getItem(key)
let s = $state<T>(
savedValue != null && savedValue !== 'undefined' ? (deserialize(savedValue) as T) : defaultValue
@@ -662,6 +664,13 @@ export function useLocalStorageValue<T>(
pendingValue = undefined
}, debounceMs)
}
const cancelPendingWrite = () => {
if (debounceTimer != null) {
clearTimeout(debounceTimer)
debounceTimer = undefined
}
pendingValue = undefined
}
$effect(() => {
readFieldsRecursively(s)
@@ -698,12 +707,19 @@ export function useLocalStorageValue<T>(
/**
* Arm the persist skip so the next `set val` (or deep-mutation flush)
* updates only the in-memory cell and leaves localStorage untouched.
* Used by `UserDraft.discard` to reset the in-memory state to a
* fallback without re-persisting it — pairs with an explicit LS
* delete to leave the slot empty.
*/
skipNextWriteOnce(): void {
skipNextWrite = true
},
/**
* Reset the in-memory state while canceling any queued debounced write.
* Used when a caller performs its own synchronous persistence action.
*/
setWithoutPersist(newVal: T): void {
cancelPendingWrite()
lastSerialized = newVal === undefined ? undefined : serialize(newVal)
skipNextWrite = false
s = newVal
}
}
}
+159 -31
View File
@@ -4,31 +4,34 @@ import { deepEqual } from 'fast-equals'
import { workspaceStore } from './stores'
import { useLocalStorageValue } from './svelte5Utils.svelte'
export type UserDraftItemKind =
| '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'
export const USER_DRAFT_ITEM_KINDS = [
'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'
] as const
export type UserDraftItemKind = (typeof USER_DRAFT_ITEM_KINDS)[number]
export type UserDraftOptions = {
workspace?: string
@@ -43,6 +46,10 @@ export type UserDraftUseOptions<V> = UserDraftOptions & {
defaultValue?: V
}
export type UserDraftListOptions = UserDraftOptions & {
itemKinds?: readonly UserDraftItemKind[]
}
/**
* A single (kind, path, workspace) tuple that `useMany` should hold a handle
* for. The shape mirrors `use()`'s arguments, just bundled into one object
@@ -93,10 +100,14 @@ function stamp<V>(stored: StoredDraft<V> | undefined): StoredDraft<V> | undefine
type DraftState<V> = {
val: StoredDraft<V> | undefined
skipNextWriteOnce(): void
setWithoutPersist(newVal: StoredDraft<V> | undefined): void
}
type DraftEntry = {
count: number
workspace: string
itemKind: UserDraftItemKind
path: string
state: DraftState<unknown>
/**
* Tears down the `$effect.root` scope that owns the entry's
@@ -109,6 +120,16 @@ type DraftEntry = {
destroyRoot?: () => void
}
export type UserDraftEntry<V = unknown> = {
workspace: string
itemKind: UserDraftItemKind
path: string
value: V | undefined
meta: UserDraftMeta
persisted: boolean
live: boolean
}
const entries = new Map<string, DraftEntry>()
function resolveWorkspace(opts?: UserDraftOptions): string {
@@ -209,6 +230,36 @@ function localStorageKey(workspace: string, itemKind: UserDraftItemKind, path: s
return `userdraft/w/${workspace}/${itemKind}/${path}`
}
function parseLocalStorageKey(
key: string,
workspace: string,
itemKinds: readonly UserDraftItemKind[]
): { itemKind: UserDraftItemKind; path: string } | undefined {
const prefix = `userdraft/w/${workspace}/`
if (!key.startsWith(prefix)) return undefined
const rest = key.slice(prefix.length)
for (const itemKind of itemKinds) {
const kindPrefix = `${itemKind}/`
if (rest.startsWith(kindPrefix)) {
return { itemKind, path: rest.slice(kindPrefix.length) }
}
}
return undefined
}
function snapshotDraftValue<V>(value: V | undefined): V | undefined {
if (value === undefined) return undefined
try {
return structuredClone($state.snapshot(value)) as V
} catch {
try {
return JSON.parse(JSON.stringify(value)) as V
} catch {
return undefined
}
}
}
export type UserDraftHandle<V> = {
get draft(): V | undefined
set draft(value: V | undefined)
@@ -299,6 +350,27 @@ export const UserDraft = {
}
},
setDraftAndMeta<V>(
itemKind: UserDraftItemKind,
path: string,
value: V | undefined,
meta: UserDraftMeta,
opts?: UserDraftOptions
): void {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) {
entry.state.val = wrap(value, meta)
// Static writes represent explicit external draft mutations. A
// freshly acquired live entry may still have the initial-write skip
// armed, so force the storage slot to match the live value.
persistDirect(localStorageKey(ws, itemKind, path), value, meta)
return
}
persistDirect(localStorageKey(ws, itemKind, path), value, meta)
},
/**
* Autosave gate: persist `value` only when it differs (after
* `normalizeForCompare`) from the `deployed` baseline; otherwise remove
@@ -396,6 +468,61 @@ export const UserDraft = {
}
},
clear(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
UserDraft.discard(itemKind, path, undefined, opts)
},
list<V = unknown>(opts?: UserDraftListOptions): UserDraftEntry<V>[] {
const ws = resolveWorkspace(opts)
const itemKinds = opts?.itemKinds ?? USER_DRAFT_ITEM_KINDS
const out = new Map<string, UserDraftEntry<V>>()
if (typeof localStorage !== 'undefined') {
const keys: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key != null && key.startsWith(`userdraft/w/${ws}/`)) keys.push(key)
}
for (const key of keys) {
const parsed = parseLocalStorageKey(key, ws, itemKinds)
if (!parsed) continue
const stored = readPersisted<V>(key)
if (stored === undefined) continue
out.set(mapKey(ws, parsed.itemKind, parsed.path), {
workspace: ws,
itemKind: parsed.itemKind,
path: parsed.path,
value: snapshotDraftValue(unwrap(stored)),
meta: extractMeta(stored),
persisted: true,
live: false
})
}
}
for (const entry of entries.values()) {
if (entry.workspace !== ws || !itemKinds.includes(entry.itemKind)) continue
const stored = untrack(() => entry.state.val as StoredDraft<V> | undefined)
const mk = mapKey(entry.workspace, entry.itemKind, entry.path)
if (stored === undefined) {
out.delete(mk)
continue
}
const existing = out.get(mk)
out.set(mk, {
workspace: entry.workspace,
itemKind: entry.itemKind,
path: entry.path,
value: snapshotDraftValue(unwrap(stored)),
meta: extractMeta(stored),
persisted: existing?.persisted ?? false,
live: true
})
}
return Array.from(out.values())
},
/**
* Like `remove`, but also resets any live handle's `draft` to
* `fallback` in-memory (so reactive readers see it immediately) and
@@ -412,10 +539,11 @@ export const UserDraft = {
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) {
// Arm the skip before the cell write so the setter suppresses
// the persist; the removeItem below actually clears the slot.
entry.state.skipNextWriteOnce()
entry.state.val = wrap(fallback) as StoredDraft<unknown> | undefined
// Drop any queued debounced write owned by this live entry before
// resetting the in-memory value. Otherwise a timer from the old
// entry can outlive unmount and later delete a freshly written
// draft for the same key.
entry.state.setWithoutPersist(wrap(fallback) as StoredDraft<unknown> | undefined)
}
try {
localStorage.removeItem(localStorageKey(ws, itemKind, path))
@@ -549,7 +677,7 @@ function acquireEntry(
)
})
if (stateRef) {
entries.set(mk, { count: 1, state: stateRef, destroyRoot })
entries.set(mk, { count: 1, workspace, itemKind, path, state: stateRef, destroyRoot })
return
}
// Fallback for the vitest runtime where `$effect.root`'s callback isn't
@@ -560,7 +688,7 @@ function acquireEntry(
undefined,
useLocalStorageOptions
)
entries.set(mk, { count: 1, state })
entries.set(mk, { count: 1, workspace, itemKind, path, state })
}
function releaseEntry(mk: string): void {
+240
View File
@@ -719,3 +719,243 @@ describe('UserDraft.saveIfChanged', () => {
expect(storedShape(KEY)).toBe(wrapped(value))
})
})
describe('UserDraft.list / clear / setDraftAndMeta', () => {
it('enumerates persisted-only drafts for the requested workspace and kinds', () => {
UserDraft.setDraftAndMeta('script', 'f/a', { path: 'f/a', content: 'a' }, { remoteRev: 'h1' })
UserDraft.setDraftAndMeta(
'flow',
'f/b',
{ path: 'f/b', value: { modules: [] } },
{ remoteRev: 2 },
{ workspace: 'other_ws' }
)
UserDraft.setDraftAndMeta('resource', 'f/c', { path: 'f/c' }, {})
expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([
{
workspace: 'test_ws',
itemKind: 'script',
path: 'f/a',
value: { path: 'f/a', content: 'a' },
meta: { remoteRev: 'h1' },
persisted: true,
live: false
}
])
expect(UserDraft.list({ workspace: 'other_ws' })).toEqual([
expect.objectContaining({
workspace: 'other_ws',
itemKind: 'flow',
path: 'f/b',
persisted: true,
live: false
})
])
})
it('keeps multiple path-addressed drafts and the empty-path scratch draft distinct', () => {
UserDraft.setDraftAndMeta('script', '', { path: '', content: 'scratch' }, {})
UserDraft.setDraftAndMeta('script', 'f/new-a', { path: 'f/new-a', content: 'a' }, {})
UserDraft.setDraftAndMeta('script', 'f/new-b', { path: 'f/new-b', content: 'b' }, {})
const entries = UserDraft.list<{ path: string; content: string }>({ itemKinds: ['script'] })
expect(entries).toHaveLength(3)
expect(entries).toEqual(
expect.arrayContaining([
expect.objectContaining({
itemKind: 'script',
path: '',
value: { path: '', content: 'scratch' }
}),
expect.objectContaining({
itemKind: 'script',
path: 'f/new-a',
value: { path: 'f/new-a', content: 'a' }
}),
expect.objectContaining({
itemKind: 'script',
path: 'f/new-b',
value: { path: 'f/new-b', content: 'b' }
})
])
)
})
it('enumerates live-only drafts before the debounce persists them', () => {
const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/live')
handle.setDraftAndMeta({ path: 'f/live', content: 'live' }, { remoteRev: 'h1' })
expect(localStorage.getItem('userdraft/w/test_ws/script/f/live')).toBeNull()
expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([
{
workspace: 'test_ws',
itemKind: 'script',
path: 'f/live',
value: { path: 'f/live', content: 'live' },
meta: { remoteRev: 'h1' },
persisted: false,
live: true
}
])
})
it('dedupes entries that are both persisted and live', () => {
UserDraft.setDraftAndMeta(
'script',
'f/both',
{ path: 'f/both', content: 'persisted' },
{
remoteRev: 'h1'
}
)
const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/both')
handle.draft = { path: 'f/both', content: 'live' }
expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([
{
workspace: 'test_ws',
itemKind: 'script',
path: 'f/both',
value: { path: 'f/both', content: 'live' },
meta: { remoteRev: 'h1' },
persisted: true,
live: true
}
])
})
it('clear removes persisted storage and live state without re-persisting', () => {
UserDraft.setDraftAndMeta(
'script',
'f/clear',
{ path: 'f/clear', content: 'x' },
{
remoteRev: 'h1'
}
)
const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/clear')
expect(handle.draft).toEqual({ path: 'f/clear', content: 'x' })
UserDraft.clear('script', 'f/clear')
flushPersist()
expect(handle.draft).toBeUndefined()
expect(localStorage.getItem('userdraft/w/test_ws/script/f/clear')).toBeNull()
expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([])
})
it('clear cancels pending debounced live writes', () => {
const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/pending-clear')
handle.draft = { path: 'f/pending-clear', content: 'initial' }
handle.draft = { path: 'f/pending-clear', content: 'pending' }
UserDraft.clear('script', 'f/pending-clear')
expect(handle.draft).toBeUndefined()
expect(localStorage.getItem('userdraft/w/test_ws/script/f/pending-clear')).toBeNull()
flushPersist()
expect(localStorage.getItem('userdraft/w/test_ws/script/f/pending-clear')).toBeNull()
})
it('clear does not let an old debounced remove delete a later direct write', () => {
const key = 'userdraft/w/test_ws/script/f/rewrite-after-clear'
const handle = UserDraft.use<{ path: string; content: string }>(
'script',
'f/rewrite-after-clear'
)
handle.draft = { path: 'f/rewrite-after-clear', content: 'initial' }
handle.draft = { path: 'f/rewrite-after-clear', content: 'pending' }
UserDraft.clear('script', 'f/rewrite-after-clear')
flushDestroyCallbacks()
UserDraft.setDraftAndMeta(
'script',
'f/rewrite-after-clear',
{ path: 'f/rewrite-after-clear', content: 'new' },
{}
)
flushPersist()
expect(storedShape(key)).toBe(
wrapped({ path: 'f/rewrite-after-clear', content: 'new' })
)
})
it('list hides persisted drafts when a live handle has cleared the value', () => {
UserDraft.setDraftAndMeta(
'script',
'f/live-clear',
{ path: 'f/live-clear', content: 'persisted' },
{}
)
const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/live-clear')
handle.draft = { path: 'f/live-clear', content: 'edited' }
handle.draft = undefined
expect(localStorage.getItem('userdraft/w/test_ws/script/f/live-clear')).not.toBeNull()
expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([])
})
it('setDraftAndMeta updates live handles atomically and preserves metadata on later draft writes', () => {
const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/meta')
UserDraft.setDraftAndMeta(
'script',
'f/meta',
{ path: 'f/meta', content: 'first' },
{
remoteRev: 'h1',
remoteDraftRev: 'd1'
}
)
handle.draft = { path: 'f/meta', content: 'second' }
expect(handle.draft).toEqual({ path: 'f/meta', content: 'second' })
expect(handle.meta).toEqual({ remoteRev: 'h1', remoteDraftRev: 'd1' })
expect(UserDraft.list({ itemKinds: ['script'] })[0]).toEqual(
expect.objectContaining({
value: { path: 'f/meta', content: 'second' },
meta: { remoteRev: 'h1', remoteDraftRev: 'd1' }
})
)
})
it('static setDraftAndMeta persists first writes even when a live handle exists', () => {
const handle = UserDraft.use<{ path: string; content: string }>('script', 'f/static-live')
UserDraft.setDraftAndMeta(
'script',
'f/static-live',
{ path: 'f/static-live', content: 'first' },
{ remoteRev: 'h1' }
)
expect(handle.draft).toEqual({ path: 'f/static-live', content: 'first' })
expect(storedShape('userdraft/w/test_ws/script/f/static-live')).toBe(
JSON.stringify({
value: { path: 'f/static-live', content: 'first' },
remoteRev: 'h1'
})
)
})
it('lists live drafts with runtime-only values without throwing', () => {
const handle = UserDraft.use<Record<string, unknown>>('script', 'f/runtime')
handle.draft = {
path: 'f/runtime',
content: 'x',
callback: () => 'not serializable'
}
expect(() => UserDraft.list({ itemKinds: ['script'] })).not.toThrow()
expect(UserDraft.list({ itemKinds: ['script'] })).toEqual([
expect.objectContaining({
itemKind: 'script',
path: 'f/runtime',
value: { path: 'f/runtime', content: 'x' }
})
])
})
})