converted to proper npm sveltekit project
This commit is contained in:
138
src/lib/workspace.svelte.ts
Normal file
138
src/lib/workspace.svelte.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
// 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);
|
||||
}
|
||||
Reference in New Issue
Block a user