139 lines
3.9 KiB
TypeScript
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);
|
|
}
|