mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-06 00:02:13 +00:00
* feat(triggers): add AMQP (RabbitMQ) trigger using the lapin library Fixes WIN-2214 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(triggers): defer AMQP cross-workspace deploy pending utils-internal publish Revert the amqp_trigger additions to the shared windmill-utils-internal TriggerDeployKind and the frontend cross-workspace deploy adapter: the frontend installs the published npm package, which lacks the new kind until a release is cut. AMQP create/edit/delete/list/sync/capture are unaffected (they use local types); only cross-workspace deploy/merge of AMQP triggers waits on the package bump. Also document the at-most-once ack in the consumer loop. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(triggers): address AMQP review — at-least-once ack, workspace cascade, contracts - ack AMQP deliveries only after successful dispatch; nack+requeue on failure - add ON DELETE CASCADE workspace FK so amqp_trigger rows are cleaned on workspace deletion (and the listener stops) - fix the /amqp_triggers/test OpenAPI body and add amqp_trigger to WorkspaceDiffRow.kind - register AMQP in the generated workspace trigger tool (create_trigger) - drop banned $bindable defaults on optional props in the config section - add build_uri unit tests (encoding, ports, vhost) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(triggers): stop AMQP poison-message loop and reconnect on transient drops Chaos testing against a live RabbitMQ broker showed the previous nack(requeue) + immediate re-poll spun a tight redelivery loop (~1000 critical-error reports/sec) on a poison message, and any connection blip permanently disabled the trigger (lapin has no built-in reconnect). - on dispatch failure: nack+requeue then stop consuming; the listener framework re-lists the trigger after its ping goes stale (~15s), backing redelivery off to that cadence instead of a tight loop (verified: rate dropped from ~1000/s to ~1 per ~26s, message preserved) - on connection/stream error: stop and let the framework reconnect instead of disabling; persistent failures are still disabled via get_consumer (verified: a forced connection close now auto-reconnects and resumes) - finish the AI create-trigger action wiring for AMQP: add amqp to CreatedResourceTriggerKind, the action-card registry, and the drawer registry so the result card renders and its "Open" action works Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(triggers): complete AMQP frontend registries and defer merge rows - add amqp to capturableTriggerTypes (so AmqpCapture mounts), the Runs jobTriggerKinds filter, and CLOUD_DISABLED_TRIGGER_TYPES - wire AMQP into global AI chat mode: TRIGGER_KINDS, the request union, writeTriggerSchema, triggerServices, and the draft adapter - stop emitting actionable AMQP fork-comparison rows (revert amqp_trigger from TRIGGER_OR_SCHEDULE_TABLES) since cross-workspace deploy is deferred until windmill-utils-internal is published — avoids a deploy that fails with "Unknown kind: amqp_trigger" - use design-system TextInput instead of raw <input> in the config section Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(triggers): complete AMQP session/draft registries and constrain prefetch - add amqp to the session-deploy, draft-compare, preview-router, and copilot workspace-item registries so AMQP drafts/deploys/nav/path resolution work - include amqp_count in the MoveDrawer attached-trigger rename warning - replace the raw prefetch <input> with a design-system TextInput bounded to an integer 1-65535 (backend u16) and block save on invalid values Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(triggers): make AMQP disconnect/reconnect consistent with the Kafka trigger lapin, like rdkafka, has no transparent reconnect, so the AMQP listener now mirrors the Kafka trigger's explicit reconnect loop instead of relying on the framework re-list (which disabled the trigger once get_consumer failed on a sustained outage): - get_consumer returns cheaply; consume owns a (re)connect loop that retries with a 30s backoff, reports a critical error every 10 failed attempts, and reports a recovered critical error once it reconnects — never disabling the trigger on a connectivity failure - a consumer/stream error breaks out to reconnect rather than disabling - dispatch failure still nacks+requeues (at-least-once) with a short backoff to avoid a tight poison-message loop, keeping the connection alive Verified against a live RabbitMQ broker: killing the broker keeps the trigger enabled and retrying (attempt N), and restarting it auto-reconnects (logs "reconnected after N attempts") and resumes dispatch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(triggers): complete AMQP capture registries and constrain prefetch contract - add the 'amqp' case to triggerKindToTriggerType so opening the AMQP editor from a capture button no longer throws "Unknown TriggerKind: amqp" - register AmqpIcon in CaptureTable's icon map and add an AMQP entry to the script/flow CaptureButton menu - bound the OpenAPI prefetch_count to an integer 1-65535 (matches the Rust u16) and regenerate clients/prompts - require a non-empty exchange name when the exchange binding is enabled - build_uri: fall back to "/" on a blank vhost and bracket IPv6 hosts (+ tests) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(triggers): wire AMQP into pipeline graph, git-sync, and preprocessor types - asset_graph: discover attached amqp_trigger rows and emit an AMQP TriggerEdge so AMQP triggers render (and can be opened/deleted) on the data-pipeline canvas - frontend pipeline graph: add amqp to NativeTriggerKind, the add-trigger menu, node presentation, event-trigger set, annotation keywords, and the editor/service registrations - git-sync: add the amqp_trigger include pattern (+ test) so an AMQP git-sync deployment stages only its .amqp_trigger.* file, not an unrelated same-path object - preprocessor starters: add the AMQP event to the generated TS/Python/PHP trigger event types (kind/payload/exchange/routing_key/queue_name/redelivered/ delivery_tag) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(triggers): finish AMQP pipeline/parser wiring, prefetch validation, source lists - fix a stray edit that corrupted the pre-existing MqttTriggerEditor import ($lib/... path) in PipelineTriggerEditors.svelte - reject prefetch_count = 0 server-side in validate_config (RabbitMQ treats 0 as unlimited) and defensively skip basic_qos(0) in build_consumer (covers the capture path that bypasses CRUD validation) - recognize `// on amqp` in the canonical parser (TriggerSpec::Amqp) and add amqp to the CLI non-autorun/event-trigger sets so a pipeline cascade never runs an AMQP-only node as a manual root without an event - add amqp to the preprocessor intro lists and both pipeline AI instructions Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(triggers): reject zero AMQP prefetch in all paths and finish guidance lists - extract a shared validate_amqp_options used by both CRUD validate_config and build_consumer, so capture configs (which bypass CRUD validation) also reject prefetch 0 instead of silently connecting with an unlimited buffer (+ unit tests for 0/1/65535/None) - add AMQP to the main script-writing preprocessor-sources prompt and the CLI triggers-skill guidance list Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(triggers): de-duplicate AMQP prefetch comment and fix GET response text - keep the zero-prefetch rationale only on the shared validate_amqp_options doc; drop the redundant call-site comments - correct the getAmqpTrigger OpenAPI 200 description ("deleted" -> "retrieved") Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref to aaa6cb89b05b76139252c64f057e53b94d12ac60 This commit updates the EE repository reference after PR #680 was merged in windmill-ee-private. Previous ee-repo-ref: 5da5fd65aca9594b2611837a52e4677b544b0380 New ee-repo-ref: aaa6cb89b05b76139252c64f057e53b94d12ac60 Automated by sync-ee-ref workflow. * chore(migrations): consolidate the four AMQP migrations into one The table and the three enum ADD VALUE statements (trigger_kind, job_trigger_kind, draft_kind) are one atomic feature. ALTER TYPE ... ADD VALUE runs inside the migration transaction on PG >= 14 (Windmill's minimum) since the amqp_trigger table doesn't reference those enum types, so they can share a single migration instead of four. Verified applying cleanly in a single transaction on a fresh DB. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
932 lines
32 KiB
TypeScript
932 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_amqp',
|
|
'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()
|
|
}
|