From 1f40a8b787dd4c8dcd16e96045108734f0105f90 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Thu, 28 May 2026 00:29:58 +0200 Subject: [PATCH] feat: add UserDraftDbSyncer service for bi-directional draft sync --- frontend/src/lib/userDraftDbSyncer.svelte.ts | 280 +++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 frontend/src/lib/userDraftDbSyncer.svelte.ts diff --git a/frontend/src/lib/userDraftDbSyncer.svelte.ts b/frontend/src/lib/userDraftDbSyncer.svelte.ts new file mode 100644 index 0000000000..84c49fefc9 --- /dev/null +++ b/frontend/src/lib/userDraftDbSyncer.svelte.ts @@ -0,0 +1,280 @@ +/** + * Bi-directional sync layer between the local `UserDraft` autosave and the + * server-side `draft` table. The transport is `DraftService.syncDrafts`, + * which doubles as "what the server has for me that I haven't seen yet" and + * "push these drafts up, rejecting any whose server copy moved forward + * since my last sync". + * + * The syncer is a single shared module-level service (not per-handle) so + * that pushes from any UserDraft.save call across the app coalesce into one + * batched sync request. + */ +import type { UserDraftItemKind } from './userDraft.svelte' +import { DraftService, type SyncDraftsResponse } from './gen' +import { useLocalStorageValue } from './svelte5Utils.svelte' + +const LAST_SYNC_KEY = 'userdraft/lastSync' +const DEBOUNCE_MS = 2_000 +const MAX_DEBOUNCE_MS = 10_000 + +export type SyncableItemKind = 'script' | 'flow' | 'app' + +/** + * UserDraft item kinds that have a matching backend `draft.typ` enum value. + * Other kinds (resources, variables, individual triggers...) only exist in + * localStorage for now and are filtered out of every push. + */ +const SYNCABLE_KINDS: ReadonlySet = new Set(['script', 'flow', 'app']) + +export function isSyncableKind(kind: UserDraftItemKind): kind is SyncableItemKind { + return SYNCABLE_KINDS.has(kind) +} + +export type MissedDraft = SyncDraftsResponse['missed_drafts'][number] +export type RejectedDraft = Extract + +export type PendingDraft = { + itemKind: SyncableItemKind + path: string + value: V +} + +export type MissedDraftCallback = (drafts: MissedDraft[]) => void +export type RejectedDraftsCallback = (rejected: RejectedDraft[]) => void + +export type SyncOptions = { + workspace: string + user: string + drafts: PendingDraft[] + force?: boolean + onMissedDrafts?: MissedDraftCallback + onDraftsRejected?: RejectedDraftsCallback +} + +type QueueKey = string +function queueKey(workspace: string, user: string, kind: SyncableItemKind, path: string): QueueKey { + return `${workspace}|${user}|${kind}|${path}` +} + +type QueuedEntry = { + workspace: string + user: string + itemKind: SyncableItemKind + path: string + value: unknown + force: boolean + onMissedDrafts?: MissedDraftCallback + onDraftsRejected?: RejectedDraftsCallback +} + +// `useLocalStorageValue` needs to run inside a Svelte effect scope to set up +// its persist `$effect`. The module-level handle is lazily initialized on +// first use so it gets a real scope (the calling component's) instead of +// firing at import time where there's no scope. +type LastSyncCell = { + val: string | undefined + skipNextWriteOnce(): void + setWithoutPersist(newVal: string | undefined): void +} +let lastSyncCell: LastSyncCell | undefined + +function readPersistedLastSync(): string | undefined { + if (typeof localStorage === 'undefined') return undefined + try { + const raw = localStorage.getItem(LAST_SYNC_KEY) + return raw ?? undefined + } catch { + return undefined + } +} + +function writeLastSyncDirect(value: string | undefined): void { + if (typeof localStorage === 'undefined') return + try { + if (value === undefined) { + localStorage.removeItem(LAST_SYNC_KEY) + } else { + localStorage.setItem(LAST_SYNC_KEY, value) + } + } catch (e) { + console.error('UserDraftDbSyncer: failed to persist lastSync', e) + } +} + +function getLastSync(): string | undefined { + if (lastSyncCell) return lastSyncCell.val + return readPersistedLastSync() +} + +function setLastSync(value: string | undefined): void { + if (lastSyncCell) { + lastSyncCell.val = value + } else { + writeLastSyncDirect(value) + } +} + +function bumpLastSync(serverTimestamp: string): void { + const previous = getLastSync() + if (!previous || new Date(serverTimestamp).getTime() > new Date(previous).getTime()) { + setLastSync(serverTimestamp) + } +} + +const queue = new Map() +let debounceTimer: ReturnType | undefined +let maxDebounceTimer: ReturnType | undefined + +function clearTimers(): void { + if (debounceTimer !== undefined) { + clearTimeout(debounceTimer) + debounceTimer = undefined + } + if (maxDebounceTimer !== undefined) { + clearTimeout(maxDebounceTimer) + maxDebounceTimer = undefined + } +} + +async function flushQueue(): Promise { + clearTimers() + if (queue.size === 0) return + + const entries = Array.from(queue.values()) + queue.clear() + + // Group entries by (workspace, user) — every sync call is scoped to a + // single user, so we issue one request per distinct group. In practice + // the queue is dominated by the active session's workspace+user, so + // there's almost always exactly one group. + const groups = new Map() + for (const entry of entries) { + const key = `${entry.workspace}|${entry.user}` + const list = groups.get(key) + if (list) list.push(entry) + else groups.set(key, [entry]) + } + + for (const group of groups.values()) { + await runSync(group[0].workspace, group) + } +} + +async function runSync(workspace: string, group: QueuedEntry[]): Promise { + const onMissedDrafts = group.find((e) => e.onMissedDrafts)?.onMissedDrafts + const onDraftsRejected = group.find((e) => e.onDraftsRejected)?.onDraftsRejected + const force = group.some((e) => e.force) + const drafts: PendingDraft[] = group.map(({ itemKind, path, value }) => ({ + itemKind, + path, + value + })) + await syncDrafts({ + workspace, + user: group[0].user, + drafts, + force, + onMissedDrafts, + onDraftsRejected + }) +} + +/** + * Immediate sync. Bypasses the queue. Caller is responsible for handling + * the missed/rejected lists. + */ +export async function syncDrafts(opts: SyncOptions): Promise { + const lastSync = getLastSync() + const payloadDrafts = opts.drafts.map((d) => ({ + path: d.path, + typ: d.itemKind, + value: d.value as any + })) + const result = await DraftService.syncDrafts({ + workspace: opts.workspace, + requestBody: { + last_sync: lastSync, + drafts: payloadDrafts, + force: opts.force ?? false + } + }) + bumpLastSync(result.current_timestamp as unknown as string) + + if (result.missed_drafts.length > 0 && opts.onMissedDrafts) { + opts.onMissedDrafts(result.missed_drafts) + } + + const rejected = result.statuses.filter((s): s is RejectedDraft => s.status === 'rejected') + if (rejected.length > 0 && opts.onDraftsRejected) { + opts.onDraftsRejected(rejected) + } +} + +export const UserDraftDbSyncer = { + /** + * Call inside a component (e.g. the root layout) to bind the persisted + * `lastSync` cell to a Svelte effect scope. Without this the syncer + * still works (it falls back to raw localStorage reads/writes) but the + * value won't react across multiple tabs / SSR replays. + */ + mount(): void { + if (lastSyncCell) return + lastSyncCell = useLocalStorageValue(LAST_SYNC_KEY, undefined, 'string', { + saveInitialValue: false + }) + }, + + getLastSync, + + /** + * Test-only: clear the in-memory queue + lastSync cell so successive + * tests start with a clean slate. + */ + __resetForTesting(): void { + clearTimers() + queue.clear() + lastSyncCell = undefined + }, + + /** + * Enqueue drafts for a batched sync. Repeated pushes for the same + * (workspace, user, itemKind, path) coalesce — only the latest value / + * callbacks survive. Returns the same Promise as the eventual + * `syncDrafts` so callers can `await` flush completion. + */ + pushDrafts(opts: SyncOptions): void { + const ws = opts.workspace + const user = opts.user + for (const d of opts.drafts) { + queue.set(queueKey(ws, user, d.itemKind, d.path), { + workspace: ws, + user, + itemKind: d.itemKind, + path: d.path, + value: d.value, + force: opts.force ?? false, + onMissedDrafts: opts.onMissedDrafts, + onDraftsRejected: opts.onDraftsRejected + }) + } + + if (debounceTimer !== undefined) clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => { + void flushQueue() + }, DEBOUNCE_MS) + + if (maxDebounceTimer === undefined) { + maxDebounceTimer = setTimeout(() => { + void flushQueue() + }, MAX_DEBOUNCE_MS) + } + }, + + /** + * Force a synchronous flush of any queued pushes. Useful for tests and + * for "logout" / "navigate away" hooks that want to guarantee delivery. + */ + async flush(): Promise { + await flushQueue() + } +}