Files
nick-sde-value-driver-website/src/lib/workspace.svelte.ts
2026-07-22 14:52:35 -05:00

139 lines
3.9 KiB
TypeScript

// Reactive client-side workspace synchronizer.
//
// Usage in a component:
// const ws = new Workspace<WorkshopState>('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<T> {
tool: ToolId;
name: string;
state = $state<T | null>(null);
version = $state(0);
status = $state<SyncStatus>('loading');
updatedAt = $state<string | null>(null);
message = $state('');
#saveTimer: ReturnType<typeof setTimeout> | null = null;
#pollTimer: ReturnType<typeof setInterval> | 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<void> {
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<void> {
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<T>;
this.#adopt(env);
this.status = 'idle';
} catch (e) {
this.status = 'error';
this.message = e instanceof Error ? e.message : 'Could not load';
}
}
#adopt(env: WorkspaceEnvelope<T>): 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<void> {
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<T> };
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<T>;
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<void> {
const res = await fetch(`${this.base}/reset`, { method: 'POST' });
if (res.ok) {
this.#adopt((await res.json()) as WorkspaceEnvelope<T>);
this.status = 'idle';
}
}
async #poll(): Promise<void> {
// 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<T>;
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);
}