mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-11 00:06:06 +00:00
* fix(sessions): sync AI-session editor preview with workspace edits + stop phantom autosave (WIN-2160) Two related draft-sync fixes surfaced by the new AI sessions preview. 1. Session preview went stale after a workspace edit. A session's editor runtime cell (content store + loadedPath) outlives the sessions page: it survives toggling to workspace mode and MRU tab eviction. The shared per-user draft can change while the editor is unmounted — most visibly by editing the same item in the classic workspace editor, or from another device — but on the next mount the load early-returns on the still-set loadedPath and the preview keeps showing the pre-toggle content. Fix: invalidate the cell's loadedPath when SessionEditorTarget unmounts, so the next mount re-fetches the draft as a clean first load. This also sidesteps a Monaco model-reuse race (a force-reload that remounts the editor while the old one is still disposing renders a stale model) and prevents the outbound draft-sync from posting the stale store back (ready() stays false until the reload lands). Applies to all three editor kinds (script, flow, raw app) since they share SessionEditorTarget. 2. Opening a deployed script in the full-page editor autosaved a phantom draft with no user change. The deployed baseline carries a server-derived assets: [] that the editor's draft value never reproduces, so draftValuesEqual never matched baseline, discardIf returned false, and the settle-time write posted a no-op draft. Fix: ignore assets in the draft-vs-baseline comparison (it's derived from content, so it can't mask a real change). Fixes WIN-2160 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(sessions): condense teardown-invalidation comment to repo comment-length rule --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
931 lines
32 KiB
TypeScript
931 lines
32 KiB
TypeScript
import { get } from 'svelte/store'
|
|
import { onDestroy, untrack } from 'svelte'
|
|
import { deepEqual } from 'fast-equals'
|
|
import { workspaceStore } from './stores'
|
|
import { readFieldsRecursively } from './utils'
|
|
import { UserDraftDbSyncer } from './userDraftDbSyncer.svelte'
|
|
import type { UserDraftItemKind } from './gen'
|
|
|
|
export type { UserDraftItemKind }
|
|
|
|
// Runtime mirror of the generated `UserDraftItemKind` union. `satisfies`
|
|
// + the exhaustiveness check below make a drift between the two a type error.
|
|
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',
|
|
'data_pipeline'
|
|
] as const satisfies readonly UserDraftItemKind[]
|
|
|
|
// Reverse direction: every union member must appear in the array above.
|
|
type _AssertKindsExhaustive =
|
|
Exclude<UserDraftItemKind, (typeof USER_DRAFT_ITEM_KINDS)[number]> extends never ? true : never
|
|
const _: _AssertKindsExhaustive = true
|
|
void _
|
|
|
|
export type UserDraftOptions = {
|
|
workspace?: string
|
|
}
|
|
|
|
export type UserDraftListOptions = UserDraftOptions & {
|
|
itemKinds?: readonly UserDraftItemKind[]
|
|
}
|
|
|
|
type DraftState<V> = {
|
|
val: V | undefined
|
|
}
|
|
|
|
type DraftEntry = {
|
|
count: number
|
|
workspace: string
|
|
itemKind: UserDraftItemKind
|
|
path: string
|
|
state: DraftState<unknown>
|
|
/**
|
|
* Single-shot: skip the next reactive POST. Set by callers that already
|
|
* pushed the right thing (e.g. `discard`'s explicit `value: null` POST)
|
|
* before the reactive write, so the effect doesn't fire a duplicate.
|
|
*/
|
|
skipNextSync: boolean
|
|
/**
|
|
* Sticky `skipNextSync`: while true the sync effect updates local state
|
|
* but never POSTs. For programmatic bootstrap mutations (e.g. seeding
|
|
* `initialCode`). Toggled via `stopSync` / `restartSync`.
|
|
*/
|
|
syncSuspended: boolean
|
|
/**
|
|
* Single-shot: the next cell write is a programmatic SEED (baseline load
|
|
* / new-draft template), not a user edit. The effect adopts it as the
|
|
* baseline and skips the POST. Like `syncSuspended` but scoped to one
|
|
* write, so there's no suspension to forget to resume. Set via `seed`.
|
|
*/
|
|
seedNextWrite: boolean
|
|
/**
|
|
* Tears down the entry's autosave-mirror `$effect.root`; called once at
|
|
* refcount 0. The mirror is created detached from any component (see
|
|
* `acquireEntry`), so this is the ONLY thing that disposes it. Also flips
|
|
* `mirrorDisposed` to defuse the deferred creation if the entry is released
|
|
* before the mirror is even set up. In vitest the deferred callback never
|
|
* runs, so no root is created and this stays a no-op.
|
|
*/
|
|
destroyRoot?: () => void
|
|
/**
|
|
* Set by `destroyRoot` so the microtask-deferred mirror creation aborts when
|
|
* the entry was released (refcount 0) before the mirror was established.
|
|
*/
|
|
mirrorDisposed?: boolean
|
|
}
|
|
|
|
export type UserDraftEntry<V = unknown> = {
|
|
workspace: string
|
|
itemKind: UserDraftItemKind
|
|
path: string
|
|
value: V | undefined
|
|
}
|
|
|
|
export type LiveEditorDraft = {
|
|
workspace: string
|
|
itemKind: UserDraftItemKind
|
|
storagePath: string
|
|
effectivePath?: string
|
|
}
|
|
|
|
export type LiveEditorDraftSpec = {
|
|
itemKind: UserDraftItemKind
|
|
storagePath: string
|
|
effectivePath?: string
|
|
workspace?: string
|
|
}
|
|
|
|
export type ClearLiveEditorDraftOptions = UserDraftOptions & {
|
|
storagePath?: string
|
|
}
|
|
|
|
const entries = new Map<string, DraftEntry>()
|
|
const liveEditorDrafts = new Map<string, LiveEditorDraft>()
|
|
/**
|
|
* Map keys whose entry should start `syncSuspended` on acquire. Lets
|
|
* callers `stopSync` BEFORE the editor has mounted (and called `use`).
|
|
* Consumed by `acquireEntry`; cleared by the matching `restartSync`. */
|
|
const pendingSuspensions = new Set<string>()
|
|
|
|
/**
|
|
* Synchronous read-through cache for values written via `save` while no live
|
|
* editor entry exists. That branch persists through the debounced
|
|
* `UserDraftDbSyncer` (async, fire-and-forget), so without this a same-tab
|
|
* `save(...)` followed by `get(...)` would miss its own write: the global AI
|
|
* chat writes a draft then immediately reads it back to return the result and
|
|
* would otherwise throw "Could not read written draft". A live entry shadows
|
|
* the cache (the entry is authoritative) and a release drops the key; a delete
|
|
* (`remove`/`discard`) evicts it.
|
|
*/
|
|
const writtenCache = new Map<
|
|
string,
|
|
{ workspace: string; itemKind: UserDraftItemKind; path: string; val: unknown }
|
|
>()
|
|
|
|
function rememberWrite(
|
|
workspace: string,
|
|
itemKind: UserDraftItemKind,
|
|
path: string,
|
|
val: unknown
|
|
): void {
|
|
const mk = mapKey(workspace, itemKind, path)
|
|
if (val === undefined) {
|
|
writtenCache.delete(mk)
|
|
} else {
|
|
writtenCache.set(mk, { workspace, itemKind, path, val: snapshotDraftValue(val) })
|
|
}
|
|
}
|
|
|
|
function resolveWorkspace(opts?: UserDraftOptions): string {
|
|
const ws = opts?.workspace ?? get(workspaceStore)
|
|
if (!ws) {
|
|
throw new Error(
|
|
'UserDraft: no workspace available (pass opts.workspace or set $workspaceStore)'
|
|
)
|
|
}
|
|
return ws
|
|
}
|
|
|
|
function mapKey(workspace: string, itemKind: UserDraftItemKind, path: string): string {
|
|
return `${workspace}/${itemKind}/${path}`
|
|
}
|
|
|
|
function liveEditorDraftKey(workspace: string, itemKind: UserDraftItemKind): string {
|
|
return `${workspace}/${itemKind}`
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Top-level fields IGNORED when diffing a draft against its deployed
|
|
* baseline. `permissioned_as` / `preserve_permissioned_as` are deploy
|
|
* run-as directives, not draft content, and the editor round-trips them
|
|
* asymmetrically (`preserve_…` rebuilt as `!!cfg.permissioned_as` on load
|
|
* but `|| undefined` on build) — keeping them produces a phantom banner.
|
|
*
|
|
* The rest are server-managed read-time metadata that ride along on the
|
|
* loaded deployed payload but never appear in the editor's draft content, so
|
|
* comparing them would mask a true baseline match:
|
|
* `draft_saved_at` (the draft's own save time), `edited_at` (deploy time),
|
|
* `edited_by` (deploy author), `workspace_id`, `version_id` (deployed version),
|
|
* `is_draft` (backend presence flag), and `assets` (server-derived on deploy
|
|
* from the content — the editor's draft value never carries it, so a deployed
|
|
* baseline's `assets: []` would otherwise never equal the assets-less draft and
|
|
* every untouched load would autosave a phantom draft).
|
|
*/
|
|
const DRAFT_COMPARE_IGNORED_FIELDS = [
|
|
'permissioned_as',
|
|
'preserve_permissioned_as',
|
|
'extra_perms',
|
|
'draft_saved_at',
|
|
'edited_at',
|
|
'edited_by',
|
|
'workspace_id',
|
|
'version_id',
|
|
'parent_version',
|
|
'is_draft',
|
|
'assets'
|
|
] as const
|
|
|
|
/**
|
|
* Normalize one side of a draft-vs-baseline comparison: JSON round-trip
|
|
* (drafts are stored as json server-side, which strips `undefined` keys,
|
|
* so `{ labels: undefined }` and `{}` must compare equal) and drop the
|
|
* ignored fields above. Returns the input unchanged if unserializable.
|
|
*/
|
|
export function normalizeDraftForCompare<V>(value: V): V {
|
|
try {
|
|
const v = JSON.parse(JSON.stringify(value))
|
|
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
|
|
for (const f of DRAFT_COMPARE_IGNORED_FIELDS) delete v[f]
|
|
}
|
|
return v as V
|
|
} catch {
|
|
return value
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Normalized deep equality for draft values — THE "diverges from deployed
|
|
* baseline" check. Editors must use it for BOTH their "unsaved changes"
|
|
* banner and the `discardIf` predicate so the two can't disagree.
|
|
*/
|
|
export function draftValuesEqual(a: unknown, b: unknown): boolean {
|
|
if (a === undefined || b === undefined) return a === b
|
|
return deepEqual(normalizeDraftForCompare(a), normalizeDraftForCompare(b))
|
|
}
|
|
|
|
export type UserDraftHandle<V> = {
|
|
get draft(): V | undefined
|
|
set draft(value: V | undefined)
|
|
}
|
|
|
|
export const UserDraft = {
|
|
save<V>(itemKind: UserDraftItemKind, path: string, value: V, opts?: UserDraftOptions): void {
|
|
const ws = resolveWorkspace(opts)
|
|
const mk = mapKey(ws, itemKind, path)
|
|
const entry = entries.get(mk)
|
|
if (entry) {
|
|
// The reactive effect in `acquireEntry` observes this write
|
|
// and POSTs it.
|
|
entry.state.val = value
|
|
} else {
|
|
// No live handle: remember the value so a same-tab read-after-write
|
|
// observes it synchronously (the syncer POST below is debounced),
|
|
// then persist. The next editor mount re-fetches from the backend.
|
|
rememberWrite(ws, itemKind, path, value)
|
|
void UserDraftDbSyncer.save({ workspace: ws, itemKind, path, value })
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Persist this key's CURRENT in-memory value to the server immediately,
|
|
* bypassing autosave's change-detection. For "Open in AI session" on a
|
|
* never-deployed item: it may have no draft row yet (an untouched new
|
|
* script's template seed never triggers autosave), and the session preview
|
|
* loads it via get-by-path, which 404s without a persisted draft. No-op
|
|
* when no value is held for this key. Resolves once the POST lands.
|
|
*
|
|
* MUST NOT be a general save path: it skips the handle's `discardIf`
|
|
* baseline check, so a value back at the deployed baseline would persist a
|
|
* no-op draft instead of the delete autosave sends. Only safe for
|
|
* never-deployed items, which have no baseline.
|
|
*/
|
|
async forcePersist(
|
|
itemKind: UserDraftItemKind,
|
|
path: string,
|
|
opts?: UserDraftOptions
|
|
): Promise<void> {
|
|
const ws = resolveWorkspace(opts)
|
|
const mk = mapKey(ws, itemKind, path)
|
|
const entry = entries.get(mk)
|
|
const val = entry ? entry.state.val : writtenCache.get(mk)?.val
|
|
if (val === undefined) return
|
|
await UserDraftDbSyncer.save({
|
|
workspace: ws,
|
|
itemKind,
|
|
path,
|
|
value: snapshotDraftValue(val),
|
|
immediate: true
|
|
})
|
|
},
|
|
|
|
/**
|
|
* Current draft value from the in-memory cell. `undefined` when no
|
|
* editor has mounted a handle for this key in this tab.
|
|
*/
|
|
get<V = unknown>(
|
|
itemKind: UserDraftItemKind,
|
|
path: string,
|
|
opts?: UserDraftOptions
|
|
): V | undefined {
|
|
const ws = resolveWorkspace(opts)
|
|
const mk = mapKey(ws, itemKind, path)
|
|
const entry = entries.get(mk)
|
|
if (entry) return snapshotDraftValue(entry.state.val as V | undefined)
|
|
const cached = writtenCache.get(mk)
|
|
if (cached) return snapshotDraftValue(cached.val as V | undefined)
|
|
return undefined
|
|
},
|
|
|
|
/**
|
|
* Whether a draft exists for this key in the in-memory cache. False when
|
|
* no editor has mounted a handle for it yet.
|
|
*/
|
|
has(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): boolean {
|
|
const ws = resolveWorkspace(opts)
|
|
const mk = mapKey(ws, itemKind, path)
|
|
const entry = entries.get(mk)
|
|
if (entry) return entry.state.val !== undefined
|
|
return writtenCache.get(mk)?.val !== undefined
|
|
},
|
|
|
|
remove(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
|
|
const ws = resolveWorkspace(opts)
|
|
const mk = mapKey(ws, itemKind, path)
|
|
const entry = entries.get(mk)
|
|
if (entry) {
|
|
// Clear the cell so live observers see the delete; arm
|
|
// `skipNextSync` since we POST the `null` explicitly below.
|
|
entry.skipNextSync = true
|
|
entry.state.val = undefined
|
|
}
|
|
writtenCache.delete(mk)
|
|
void UserDraftDbSyncer.save({ workspace: ws, itemKind, path, value: null })
|
|
},
|
|
|
|
clear(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
|
|
UserDraft.discard(itemKind, path, undefined, opts)
|
|
},
|
|
|
|
/**
|
|
* Suspend reactive sync for this key: writes still update the cell and
|
|
* subscribers but don't POST. Use to bracket programmatic bootstrap
|
|
* mutations (seeding `initialCode`, app init) so they don't appear as
|
|
* the user's "first edit".
|
|
*
|
|
* Safe BEFORE the entry is live (queued, applied on acquire). MUST pair
|
|
* every `stopSync` with a `restartSync` — forgetting silently disables
|
|
* autosave for the rest of the session.
|
|
*/
|
|
stopSync(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
|
|
const ws = resolveWorkspace(opts)
|
|
const mk = mapKey(ws, itemKind, path)
|
|
const entry = entries.get(mk)
|
|
if (entry) entry.syncSuspended = true
|
|
else pendingSuspensions.add(mk)
|
|
},
|
|
|
|
/**
|
|
* Resume reactive sync after `stopSync`. Subsequent differing writes
|
|
* POST normally; writes made during the suspension are dropped from the
|
|
* server's view (the cell still reflects them). Also clears a queued
|
|
* (pre-acquire) suspension.
|
|
*/
|
|
restartSync(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
|
|
const ws = resolveWorkspace(opts)
|
|
const mk = mapKey(ws, itemKind, path)
|
|
pendingSuspensions.delete(mk)
|
|
const entry = entries.get(mk)
|
|
if (entry) entry.syncSuspended = false
|
|
},
|
|
|
|
/**
|
|
* Load `value` into the cell as a SEED (baseline reload / new-draft
|
|
* template) that must NOT POST as the user's edit. Reactive readers
|
|
* update immediately; the sync effect adopts it as the new baseline.
|
|
*
|
|
* Prefer over the `stopSync`/`restartSync` bracket for a single write —
|
|
* scoped to exactly this write, nothing to forget to resume. The bracket
|
|
* is still needed when a write fans out across components (e.g. an
|
|
* editor's `initContent` cascading into the bound value).
|
|
*
|
|
* No-op if the entry isn't live yet (acquire via `use`/`useMany` first).
|
|
*/
|
|
seed<V>(itemKind: UserDraftItemKind, path: string, value: V, opts?: UserDraftOptions): void {
|
|
const ws = resolveWorkspace(opts)
|
|
const mk = mapKey(ws, itemKind, path)
|
|
const entry = entries.get(mk)
|
|
if (!entry) return
|
|
entry.seedNextWrite = true
|
|
entry.state.val = snapshotDraftValue(value)
|
|
},
|
|
|
|
/**
|
|
* Currently-mounted live entries for `workspace` (in-tab only — for a
|
|
* workspace-wide view call `DraftService` directly).
|
|
*/
|
|
list<V = unknown>(opts?: UserDraftListOptions): UserDraftEntry<V>[] {
|
|
const ws = resolveWorkspace(opts)
|
|
const itemKinds = opts?.itemKinds ?? USER_DRAFT_ITEM_KINDS
|
|
const out: UserDraftEntry<V>[] = []
|
|
const seen = new Set<string>()
|
|
for (const entry of entries.values()) {
|
|
if (entry.workspace !== ws || !itemKinds.includes(entry.itemKind)) continue
|
|
const val = untrack(() => entry.state.val as V | undefined)
|
|
if (val === undefined) continue
|
|
seen.add(mapKey(entry.workspace, entry.itemKind, entry.path))
|
|
out.push({
|
|
workspace: entry.workspace,
|
|
itemKind: entry.itemKind,
|
|
path: entry.path,
|
|
value: snapshotDraftValue(val)
|
|
})
|
|
}
|
|
// Drafts written without a live entry (e.g. global AI chat) live only in
|
|
// `writtenCache`; surface them too so the list matches what `get` returns.
|
|
for (const cached of writtenCache.values()) {
|
|
if (cached.workspace !== ws || !itemKinds.includes(cached.itemKind)) continue
|
|
const mk = mapKey(cached.workspace, cached.itemKind, cached.path)
|
|
if (seen.has(mk)) continue
|
|
out.push({
|
|
workspace: cached.workspace,
|
|
itemKind: cached.itemKind,
|
|
path: cached.path,
|
|
value: snapshotDraftValue(cached.val as V | undefined)
|
|
})
|
|
}
|
|
return out
|
|
},
|
|
|
|
setLiveEditorDraft(spec: LiveEditorDraftSpec): void {
|
|
const ws = resolveWorkspace({ workspace: spec.workspace })
|
|
liveEditorDrafts.set(liveEditorDraftKey(ws, spec.itemKind), {
|
|
workspace: ws,
|
|
itemKind: spec.itemKind,
|
|
storagePath: spec.storagePath,
|
|
effectivePath: spec.effectivePath || undefined
|
|
})
|
|
},
|
|
|
|
getLiveEditorDraft(
|
|
itemKind: UserDraftItemKind,
|
|
opts?: UserDraftOptions
|
|
): LiveEditorDraft | undefined {
|
|
const ws = resolveWorkspace(opts)
|
|
const draft = liveEditorDrafts.get(liveEditorDraftKey(ws, itemKind))
|
|
return draft ? { ...draft } : undefined
|
|
},
|
|
|
|
clearLiveEditorDraft(itemKind: UserDraftItemKind, opts?: ClearLiveEditorDraftOptions): void {
|
|
const ws = resolveWorkspace(opts)
|
|
const key = liveEditorDraftKey(ws, itemKind)
|
|
const draft = liveEditorDrafts.get(key)
|
|
if (!draft) return
|
|
if (opts?.storagePath !== undefined && draft.storagePath !== opts.storagePath) return
|
|
liveEditorDrafts.delete(key)
|
|
},
|
|
|
|
/**
|
|
* Like `remove`, but also resets any live handle's `draft` to `fallback`
|
|
* in-memory (reactive readers see it at once). The explicit `value: null`
|
|
* POST is the canonical delete; the cell update is UI convenience.
|
|
*
|
|
* `fallback` MUST be deep-cloned: otherwise a caller passing their own
|
|
* live `$state` baseline (e.g. `initialStates[ws]`) would share a proxy
|
|
* with `handle.draft`, so edits mutate both sides and the dirty check
|
|
* never fires.
|
|
*/
|
|
discard<V>(
|
|
itemKind: UserDraftItemKind,
|
|
path: string,
|
|
fallback: V | undefined,
|
|
opts?: UserDraftOptions & {
|
|
/** Mark the `value: null` POST as a reactive autosave (subject to
|
|
* the auto-save toggle) instead of an explicit action. Set by the
|
|
* trigger persist-effect's at-baseline discard; explicit discards
|
|
* leave it unset. */
|
|
auto?: boolean
|
|
}
|
|
): void {
|
|
const ws = resolveWorkspace(opts)
|
|
const mk = mapKey(ws, itemKind, path)
|
|
const entry = entries.get(mk)
|
|
const safeFallback = snapshotDraftValue(fallback)
|
|
if (entry) {
|
|
entry.skipNextSync = true
|
|
entry.state.val = safeFallback
|
|
}
|
|
// The draft is deleted server-side (the `null` POST below); the fallback
|
|
// only resets the live handle's UI. Drop the cache so a no-entry read
|
|
// reports "no draft" rather than the discarded value.
|
|
writtenCache.delete(mk)
|
|
void UserDraftDbSyncer.save({ workspace: ws, itemKind, path, value: null, auto: opts?.auto })
|
|
},
|
|
|
|
use<V = unknown>(
|
|
itemKind: UserDraftItemKind,
|
|
path: string,
|
|
opts?: UserDraftOptions & {
|
|
/** See the `useMany` spec field. Default `false`. */
|
|
canBeDisabled?: boolean
|
|
/** See the `useMany` spec field. Captured once on first acquire. */
|
|
discardIf?: (val: V) => boolean
|
|
}
|
|
): UserDraftHandle<V> {
|
|
// Single-spec wrapper around `useMany`. `untrack` captures reactive
|
|
// opts (e.g. `$workspaceStore`) once: the handle stays bound to this
|
|
// workspace until unmount. For reactive `(kind, path)` use `useReactive`.
|
|
const handles = UserDraft.useMany<V>(() =>
|
|
untrack(() => [
|
|
{
|
|
itemKind,
|
|
path,
|
|
workspace: opts?.workspace,
|
|
canBeDisabled: opts?.canBeDisabled,
|
|
discardIf: opts?.discardIf
|
|
}
|
|
])
|
|
)
|
|
return handles[0]
|
|
},
|
|
|
|
/**
|
|
* Reactive single-spec variant of `use()`. `getSpec` is read inside the
|
|
* `useMany` reconcile, so a changed `(workspace, kind, path)` releases the
|
|
* old entry and acquires a new one. The returned object is a stable proxy
|
|
* forwarding `draft` to the current handle, so `bind:` lvalues survive
|
|
* re-keying. Use for reactive paths (`/scripts/edit/[...path]`).
|
|
*/
|
|
useReactive<V = unknown>(
|
|
getSpec: () => {
|
|
itemKind: UserDraftItemKind
|
|
path: string
|
|
workspace?: string
|
|
canBeDisabled?: boolean
|
|
/** See the `useMany` spec field. Seeds the cell on first acquire
|
|
* without POSTing. Pass a STABLE reference (read it under `untrack`)
|
|
* — it's consumed once, so re-reading reactive state here only churns
|
|
* the reconcile. */
|
|
defaultValue?: V
|
|
/** See the `useMany` spec field. Captured per re-keyed acquire. */
|
|
discardIf?: (val: V) => boolean
|
|
}
|
|
): UserDraftHandle<V> {
|
|
const handles = UserDraft.useMany<V>(() => [getSpec()])
|
|
return {
|
|
get draft(): V | undefined {
|
|
return handles[0]?.draft
|
|
},
|
|
set draft(value: V | undefined) {
|
|
const h = handles[0]
|
|
if (h) h.draft = value
|
|
}
|
|
}
|
|
},
|
|
|
|
useMany<V = unknown>(
|
|
getSpecs: () => {
|
|
itemKind: UserDraftItemKind
|
|
path: string
|
|
workspace?: string
|
|
/**
|
|
* Value the entry's cell is seeded with on first acquire. Swallowed
|
|
* by the syncer's seed guard so it never POSTs. Ignored if the
|
|
* entry already exists (refcount > 0, e.g. another live handle
|
|
* keeps its value).
|
|
*/
|
|
defaultValue?: V
|
|
/**
|
|
* Predicate: is the value about to autosave back at the deployed
|
|
* baseline? When true, the mirror POSTs a delete instead of a
|
|
* baseline-equal copy — keeping it would leave `is_draft` (and the
|
|
* list `*`) stuck on. MUST use the same comparison as the "unsaved
|
|
* changes" banner (`draftValuesEqual`) so the two can't disagree.
|
|
* Return false for draft-only items (no deployed copy — deleting on
|
|
* equality would destroy the item). Captured on first acquire.
|
|
*/
|
|
discardIf?: (val: V) => boolean
|
|
/**
|
|
* Subject autosaves to the auto-save toggle. Only the full-page
|
|
* editors (script / flow / app / raw app) opt in; while off their
|
|
* keystroke saves park for Ctrl/Cmd+S. Default `false`: drawer
|
|
* editors always sync. Captured on first acquire.
|
|
*/
|
|
canBeDisabled?: boolean
|
|
}[]
|
|
): UserDraftHandle<V>[] {
|
|
// Handles array reconciled against `getSpecs()`, indices aligned.
|
|
// Same-key handles are reused across reconciles so callers can hold
|
|
// a stable reference — only the entry's refcount moves.
|
|
const handles = $state<UserDraftHandle<V>[]>([])
|
|
const acquired = new Set<string>()
|
|
const handleCache = new Map<string, UserDraftHandle<V>>()
|
|
// `defaultValue` reference last used to seed each detached (empty-path)
|
|
// handle. The reference is stable within an editing session but swapped
|
|
// for a fresh clone each time the caller restarts (e.g. reopening the
|
|
// new-item drawer) — so a change here means "re-seed", not "live edit".
|
|
const detachedSeeds = new Map<string, unknown>()
|
|
|
|
function reconcile() {
|
|
const specs = getSpecs()
|
|
const seen = new Set<string>()
|
|
const next: UserDraftHandle<V>[] = []
|
|
|
|
for (const spec of specs) {
|
|
// Resolve the workspace WITHOUT throwing: a reactive caller (e.g. an
|
|
// SDK editor mounted before login) may not have one yet. An absent
|
|
// workspace is handled like an empty path below, so the handle
|
|
// re-keys into a real entry once the workspace resolves.
|
|
const ws = spec.workspace ?? get(workspaceStore) ?? undefined
|
|
const mk = mapKey(ws ?? '', spec.itemKind, spec.path)
|
|
|
|
// No workspace yet, or empty path = no draftable item (e.g. a
|
|
// read-only historical-hash view that still binds an editor value).
|
|
// Acquiring would mirror edits into an unroutable
|
|
// `POST /drafts/update/kind/` (permanent "Save failed").
|
|
// Hand out a detached, local-only handle instead.
|
|
if (!ws || !spec.path) {
|
|
seen.add(mk)
|
|
let handle = handleCache.get(mk)
|
|
// Drop the cached handle when the caller hands in a fresh
|
|
// `defaultValue` reference (reopening the new-item drawer seeds a
|
|
// new clone) so the rebuilt handle re-seeds instead of replaying
|
|
// the previous session's edits. Stable reference within a session
|
|
// means live edits are never clobbered.
|
|
if (handle && detachedSeeds.get(mk) !== spec.defaultValue) {
|
|
handleCache.delete(mk)
|
|
handle = undefined
|
|
}
|
|
if (!handle) {
|
|
// Seed with `defaultValue` so consumers (e.g. the new-variable
|
|
// drawer, whose path is empty until the user types one) get a
|
|
// populated cell to bind their form to instead of `undefined`.
|
|
handle = makeDetachedHandle<V>(spec.defaultValue)
|
|
handleCache.set(mk, handle)
|
|
detachedSeeds.set(mk, spec.defaultValue)
|
|
}
|
|
next.push(handle)
|
|
continue
|
|
}
|
|
seen.add(mk)
|
|
|
|
if (!acquired.has(mk)) {
|
|
acquireEntry(
|
|
ws,
|
|
spec.itemKind,
|
|
spec.path,
|
|
spec.defaultValue,
|
|
spec.discardIf as ((val: unknown) => boolean) | undefined,
|
|
spec.canBeDisabled ?? false
|
|
)
|
|
acquired.add(mk)
|
|
}
|
|
let handle = handleCache.get(mk)
|
|
if (!handle) {
|
|
handle = makeHandle<V>(ws, spec.itemKind, spec.path)
|
|
handleCache.set(mk, handle)
|
|
}
|
|
next.push(handle)
|
|
}
|
|
|
|
for (const mk of [...acquired]) {
|
|
if (!seen.has(mk)) {
|
|
releaseEntry(mk)
|
|
acquired.delete(mk)
|
|
handleCache.delete(mk)
|
|
}
|
|
}
|
|
|
|
// Detached handles (empty-path) live only in `handleCache` — they're
|
|
// never in `acquired`. Drop any that fell out of the specs so they
|
|
// don't leak and a later reappearance rebuilds from scratch.
|
|
for (const mk of [...handleCache.keys()]) {
|
|
if (!acquired.has(mk) && !seen.has(mk)) {
|
|
handleCache.delete(mk)
|
|
detachedSeeds.delete(mk)
|
|
}
|
|
}
|
|
|
|
// Skip no-op mutations (cached handles → reference-equal arrays).
|
|
// `untrack` so this effect doesn't subscribe to its own `handles`
|
|
// write — otherwise it self-loops (`effect_update_depth_exceeded`).
|
|
untrack(() => {
|
|
const unchanged = handles.length === next.length && handles.every((h, i) => h === next[i])
|
|
if (!unchanged) handles.splice(0, handles.length, ...next)
|
|
})
|
|
}
|
|
|
|
// Synchronous initial reconcile so single-spec callers get a populated
|
|
// `handles[0]` before returning. `untrack` here — the `$effect` below
|
|
// picks up subsequent changes.
|
|
untrack(reconcile)
|
|
$effect(reconcile)
|
|
onDestroy(() => {
|
|
// Flush each entry's pending autosave BEFORE releasing it. SPA
|
|
// nav doesn't fire `pagehide`, so a debounced edit would silently
|
|
// vanish when the editor unmounts mid-typing. Fire-and-forget —
|
|
// the POST rides the runner's own lifetime, which outlives this
|
|
// component, so destroying the cell here doesn't cancel it.
|
|
//
|
|
// `honorAutosaveToggle`: this unmount flush is an implicit autosave,
|
|
// so a toggle-aware handle whose auto-save is off must NOT persist on
|
|
// leave — the editor's UnsavedConfirmationModal warns the user instead.
|
|
for (const mk of acquired) {
|
|
const entry = entries.get(mk)
|
|
if (!entry) continue
|
|
void UserDraftDbSyncer.flush(
|
|
{
|
|
workspace: entry.workspace,
|
|
itemKind: entry.itemKind,
|
|
path: entry.path
|
|
},
|
|
{ honorAutosaveToggle: true }
|
|
)
|
|
}
|
|
for (const mk of acquired) releaseEntry(mk)
|
|
acquired.clear()
|
|
handleCache.clear()
|
|
})
|
|
|
|
return handles
|
|
}
|
|
}
|
|
|
|
function acquireEntry(
|
|
workspace: string,
|
|
itemKind: UserDraftItemKind,
|
|
path: string,
|
|
defaultValue?: unknown,
|
|
discardIf?: (val: unknown) => boolean,
|
|
canBeDisabled = false
|
|
): void {
|
|
const mk = mapKey(workspace, itemKind, path)
|
|
const existing = entries.get(mk)
|
|
if (existing) {
|
|
existing.count++
|
|
return
|
|
}
|
|
// Seed the cell with `defaultValue` (deep-cloned). The mirror below anchors
|
|
// its baseline to this seed so it never POSTs the template.
|
|
const seed = defaultValue !== undefined ? snapshotDraftValue(defaultValue) : undefined
|
|
const cell = $state<{ val: unknown }>({ val: seed })
|
|
const stateRef = cell as DraftState<unknown>
|
|
const entry: DraftEntry = {
|
|
count: 1,
|
|
workspace,
|
|
itemKind,
|
|
path,
|
|
state: stateRef,
|
|
skipNextSync: false,
|
|
syncSuspended: pendingSuspensions.delete(mk),
|
|
seedNextWrite: false,
|
|
mirrorDisposed: false,
|
|
// Placeholder until the deferred `createMirror` swaps in the real teardown;
|
|
// flips `mirrorDisposed` so a release before then aborts the creation.
|
|
destroyRoot: () => {
|
|
entry.mirrorDisposed = true
|
|
}
|
|
}
|
|
entries.set(mk, entry)
|
|
const createMirror = (): void => {
|
|
// Bail if this entry was released before the microtask ran, or if the key
|
|
// was released and re-acquired (a different entry now owns `mk` — its own
|
|
// `createMirror` will run).
|
|
if (entry.mirrorDisposed || entries.get(mk) !== entry) return
|
|
entry.destroyRoot = $effect.root(() => {
|
|
// Mirror every observable change of `cell.val` to the syncer.
|
|
// `readFieldsRecursively` walks the value so deep mutations
|
|
// (`handle.draft.content = '...'`) re-fire the effect — reading
|
|
// `cell.val` alone only subscribes to the proxy root.
|
|
//
|
|
// `lastSerialized` + `skipNextWrite` dedup no-op updates and treat
|
|
// the seed/restore as no-POST, so landing on `?new_draft` doesn't
|
|
// sync until the user edits.
|
|
//
|
|
// The swallow is anchored to the seed VALUE, not "first change after
|
|
// mount": when a `defaultValue` seeded the cell, `lastSerialized`
|
|
// starts at its serialization so a first mirror run that still equals
|
|
// the seed is a genuine no-op, while a first run that already DIFFERS
|
|
// is the user's edit and must POST. That closes a create-path race —
|
|
// the mirror is created in a deferred microtask (see below) and the
|
|
// handle re-keys detached→acquired as the workspace resolves, so the
|
|
// seed and the user's first edit can land before the mirror's first
|
|
// run and coalesce into it; a blind "swallow the first change" would
|
|
// eat that edit.
|
|
//
|
|
// With no in-hand seed (page editors load & assign the value AFTER
|
|
// mount), arm the blind first-write swallow so that post-load
|
|
// assignment doesn't POST.
|
|
//
|
|
// `cell.val === undefined` is the delete signal (`value: null`).
|
|
// `skipNextSync` lets callers that already POSTed (`discard`,
|
|
// `remove`) suppress the duplicate fire from their own write.
|
|
let lastSerialized: string | undefined = seed === undefined ? undefined : JSON.stringify(seed)
|
|
let skipNextWrite = seed === undefined
|
|
$effect(() => {
|
|
const val = cell.val
|
|
if (val !== undefined) readFieldsRecursively(val)
|
|
const next = val === undefined ? undefined : JSON.stringify(val)
|
|
if (next === lastSerialized) {
|
|
// No-op write. If a `seed` re-seeded the value already in the
|
|
// cell, its one-shot flag consumed nothing — defuse it here or
|
|
// it lingers and swallows the user's NEXT edit. (`skipNextWrite`
|
|
// stays armed: an undefined-seeded cell's initial run lands
|
|
// here, and page editors rely on it to swallow their load write.)
|
|
if (entry.seedNextWrite) entry.seedNextWrite = false
|
|
return
|
|
}
|
|
lastSerialized = next
|
|
if (skipNextWrite) {
|
|
skipNextWrite = false
|
|
// One write consumes BOTH one-shot guards: a `seed` on a fresh
|
|
// entry (still armed with the first-write skip) must not leave
|
|
// `seedNextWrite` behind to swallow the user's first edit.
|
|
if (entry.seedNextWrite) entry.seedNextWrite = false
|
|
return
|
|
}
|
|
// `seed` write: baseline already advanced above; don't POST.
|
|
if (entry.seedNextWrite) {
|
|
entry.seedNextWrite = false
|
|
return
|
|
}
|
|
if (entry.skipNextSync) {
|
|
entry.skipNextSync = false
|
|
return
|
|
}
|
|
// `syncSuspended` swallows the POST but `lastSerialized` advanced
|
|
// above, so only writes made during suspension are dropped — the
|
|
// first change after resume is still detected.
|
|
if (entry.syncSuspended) return
|
|
// At the deployed baseline → sync a delete, not a baseline-equal
|
|
// copy. `untrack` so reactive reads in the predicate (the editor's
|
|
// post-deploy baseline) don't re-fire the mirror.
|
|
const atBaseline = untrack(() => val !== undefined && (discardIf?.(val) ?? false))
|
|
void UserDraftDbSyncer.save({
|
|
workspace,
|
|
itemKind,
|
|
path,
|
|
value: val === undefined || atBaseline ? null : val,
|
|
// Reactive keystroke mirror — `auto`, so suppressed while the
|
|
// auto-save toggle is off (for `canBeDisabled` handles).
|
|
auto: true,
|
|
canBeDisabled
|
|
})
|
|
})
|
|
})
|
|
}
|
|
// Defer so the root is created with no active component/effect — a top-level
|
|
// scope owned by the entry, not by the acquiring component. An inline
|
|
// `$effect.root` would die when that component unmounts while other holders
|
|
// still edit the same draft (see `destroyRoot`).
|
|
queueMicrotask(createMirror)
|
|
}
|
|
|
|
function releaseEntry(mk: string): void {
|
|
const entry = entries.get(mk)
|
|
if (!entry) return
|
|
entry.count--
|
|
// The mirror is owned by the ENTRY (created detached — see `acquireEntry`),
|
|
// not by any holder, so releasing one holder never touches it; it is disposed
|
|
// only here, once, at refcount 0. This is what lets multiple holders (warm
|
|
// session previews + the nav editor) share the entry and drop in any order.
|
|
if (entry.count <= 0) {
|
|
// The live entry was authoritative while mounted; once gone, drop any
|
|
// cached write for this key so a later read falls back to the server
|
|
// rather than a value the editor may have changed in the meantime.
|
|
writtenCache.delete(mk)
|
|
entry.destroyRoot?.()
|
|
entries.delete(mk)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Local-only handle for empty-path specs: a reactive cell that supports
|
|
* `bind:` but is wired to nothing (no entry, no sync, no POSTs). For views
|
|
* that bind an editor value with no draftable item behind it.
|
|
*/
|
|
function makeDetachedHandle<V>(defaultValue?: V): UserDraftHandle<V> {
|
|
let val = $state<V | undefined>(snapshotDraftValue(defaultValue))
|
|
return {
|
|
get draft(): V | undefined {
|
|
return val
|
|
},
|
|
set draft(value: V | undefined) {
|
|
val = value
|
|
}
|
|
}
|
|
}
|
|
|
|
function makeHandle<V>(
|
|
workspace: string,
|
|
itemKind: UserDraftItemKind,
|
|
path: string
|
|
): UserDraftHandle<V> {
|
|
// Reads `entries.get(mk)` on every access; the entry is stable while
|
|
// refcount > 0. After destruction, reads return `undefined` rather than
|
|
// throwing — the consumer should already be torn down by then.
|
|
const mk = mapKey(workspace, itemKind, path)
|
|
const stateOf = (): DraftState<unknown> | undefined => entries.get(mk)?.state
|
|
return {
|
|
get draft(): V | undefined {
|
|
return stateOf()?.val as V | undefined
|
|
},
|
|
set draft(value: V | undefined) {
|
|
const state = stateOf()
|
|
if (state) state.val = value
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Test-only: clear all in-memory entries. */
|
|
export function __resetUserDraftForTesting(): void {
|
|
entries.clear()
|
|
liveEditorDrafts.clear()
|
|
writtenCache.clear()
|
|
}
|