// Reactive client-side workspace synchronizer. // // Usage in a component: // const ws = new Workspace('workshop', 'default'); // onMount(() => ws.init()); // ... read/write ws.state ...; call ws.touch() after any mutation. // // It debounces saves, uses the version returned by the server for optimistic // concurrency, and polls so another person's saves appear here too. import type { ToolId, WorkspaceEnvelope } from './types'; export type SyncStatus = 'loading' | 'idle' | 'saving' | 'saved' | 'conflict' | 'error'; const SAVE_DEBOUNCE_MS = 700; const POLL_INTERVAL_MS = 6000; export class Workspace { tool: ToolId; name: string; state = $state(null); version = $state(0); status = $state('loading'); updatedAt = $state(null); message = $state(''); #saveTimer: ReturnType | null = null; #pollTimer: ReturnType | null = null; #dirty = false; // local edits not yet saved #saving = false; constructor(tool: ToolId, name = 'default') { this.tool = tool; this.name = name; } get base(): string { return `/api/workspaces/${this.tool}/${encodeURIComponent(this.name)}`; } async init(): Promise { await this.load(); this.#pollTimer = setInterval(() => this.#poll(), POLL_INTERVAL_MS); } destroy(): void { if (this.#saveTimer) clearTimeout(this.#saveTimer); if (this.#pollTimer) clearInterval(this.#pollTimer); } async load(): Promise { this.status = 'loading'; try { const res = await fetch(this.base); if (!res.ok) throw new Error(`load failed (${res.status})`); const env = (await res.json()) as WorkspaceEnvelope; this.#adopt(env); this.status = 'idle'; } catch (e) { this.status = 'error'; this.message = e instanceof Error ? e.message : 'Could not load'; } } #adopt(env: WorkspaceEnvelope): void { this.state = env.state; this.version = env.version; this.updatedAt = env.updatedAt; this.#dirty = false; } /** Call after mutating `state`. Schedules a debounced save. */ touch(): void { this.#dirty = true; this.status = 'saving'; if (this.#saveTimer) clearTimeout(this.#saveTimer); this.#saveTimer = setTimeout(() => this.save(), SAVE_DEBOUNCE_MS); } async save(): Promise { if (this.#saving || this.state === null) return; this.#saving = true; try { const res = await fetch(this.base, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ state: $state.snapshot(this.state), baseVersion: this.version }) }); if (res.status === 409) { // Someone else saved first. Adopt their copy so we don't clobber it. const { current } = (await res.json()) as { current: WorkspaceEnvelope }; this.#adopt(current); this.status = 'conflict'; this.message = 'A teammate updated this workspace; their changes were loaded.'; return; } if (!res.ok) throw new Error(`save failed (${res.status})`); const env = (await res.json()) as WorkspaceEnvelope; this.version = env.version; this.updatedAt = env.updatedAt; this.#dirty = false; this.status = 'saved'; } catch (e) { this.status = 'error'; this.message = e instanceof Error ? e.message : 'Could not save'; } finally { this.#saving = false; } } async reset(): Promise { const res = await fetch(`${this.base}/reset`, { method: 'POST' }); if (res.ok) { this.#adopt((await res.json()) as WorkspaceEnvelope); this.status = 'idle'; } } async #poll(): Promise { // Don't stomp on unsaved local edits or an in-flight save. if (this.#dirty || this.#saving) return; try { const res = await fetch(this.base); if (!res.ok) return; const env = (await res.json()) as WorkspaceEnvelope; if (env.version > this.version) this.#adopt(env); } catch { // Ignore transient poll failures. } } } export function uid(): string { return 'x' + Math.random().toString(36).slice(2, 9); }