108 lines
3.4 KiB
TypeScript
108 lines
3.4 KiB
TypeScript
// Repository for reading and writing tool workspaces.
|
|
//
|
|
// Concurrency model: optimistic locking on an integer `version`. A client loads
|
|
// a workspace (getting its version), edits locally, then saves with that same
|
|
// version as `baseVersion`. If nobody else saved in between, the row's version
|
|
// still matches and the write wins (bumping version). If someone else saved
|
|
// first, the versions differ, no row updates, and we report a conflict so the
|
|
// client can reload instead of clobbering their teammate.
|
|
|
|
import { query } from './index';
|
|
import { defaultStateFor } from '$lib/defaults';
|
|
import type { ToolId, WorkspaceEnvelope } from '$lib/types';
|
|
|
|
interface Row {
|
|
tool: ToolId;
|
|
name: string;
|
|
state: unknown;
|
|
version: number;
|
|
updated_at: Date;
|
|
updated_by: string | null;
|
|
}
|
|
|
|
function toEnvelope<T>(row: Row): WorkspaceEnvelope<T> {
|
|
return {
|
|
tool: row.tool,
|
|
name: row.name,
|
|
state: row.state as T,
|
|
version: row.version,
|
|
updatedAt: row.updated_at.toISOString(),
|
|
updatedBy: row.updated_by
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Load a workspace. If it does not exist yet, seed it from the tool's default
|
|
* baseline state so the first visitor sees a populated, useful tool.
|
|
*/
|
|
export async function getOrCreateWorkspace<T>(
|
|
tool: ToolId,
|
|
name: string
|
|
): Promise<WorkspaceEnvelope<T>> {
|
|
const existing = await query<Row>(
|
|
'SELECT tool, name, state, version, updated_at, updated_by FROM workspaces WHERE tool = $1 AND name = $2',
|
|
[tool, name]
|
|
);
|
|
if (existing.rows.length > 0) {
|
|
return toEnvelope<T>(existing.rows[0]);
|
|
}
|
|
|
|
const seed = defaultStateFor(tool);
|
|
// ON CONFLICT guards against a race where two first-visitors insert at once.
|
|
const inserted = await query<Row>(
|
|
`INSERT INTO workspaces (tool, name, state)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (tool, name) DO UPDATE SET tool = EXCLUDED.tool
|
|
RETURNING tool, name, state, version, updated_at, updated_by`,
|
|
[tool, name, seed]
|
|
);
|
|
return toEnvelope<T>(inserted.rows[0]);
|
|
}
|
|
|
|
export type SaveResult<T> =
|
|
| { ok: true; workspace: WorkspaceEnvelope<T> }
|
|
| { ok: false; conflict: WorkspaceEnvelope<T> };
|
|
|
|
/** Save state with optimistic concurrency. */
|
|
export async function saveWorkspace<T>(
|
|
tool: ToolId,
|
|
name: string,
|
|
state: T,
|
|
baseVersion: number,
|
|
updatedBy: string | null
|
|
): Promise<SaveResult<T>> {
|
|
const updated = await query<Row>(
|
|
`UPDATE workspaces
|
|
SET state = $3, version = version + 1, updated_at = now(), updated_by = $5
|
|
WHERE tool = $1 AND name = $2 AND version = $4
|
|
RETURNING tool, name, state, version, updated_at, updated_by`,
|
|
[tool, name, state, baseVersion, updatedBy]
|
|
);
|
|
|
|
if (updated.rows.length === 1) {
|
|
return { ok: true, workspace: toEnvelope<T>(updated.rows[0]) };
|
|
}
|
|
|
|
// No row updated: either the workspace does not exist, or the version moved.
|
|
// Return the current server copy so the client can reconcile.
|
|
const current = await getOrCreateWorkspace<T>(tool, name);
|
|
return { ok: false, conflict: current };
|
|
}
|
|
|
|
/** Reset a workspace back to the tool's baseline. */
|
|
export async function resetWorkspace<T>(
|
|
tool: ToolId,
|
|
name: string
|
|
): Promise<WorkspaceEnvelope<T>> {
|
|
const seed = defaultStateFor(tool);
|
|
const reset = await query<Row>(
|
|
`INSERT INTO workspaces (tool, name, state)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (tool, name)
|
|
DO UPDATE SET state = EXCLUDED.state, version = workspaces.version + 1, updated_at = now(), updated_by = NULL
|
|
RETURNING tool, name, state, version, updated_at, updated_by`,
|
|
[tool, name, seed]
|
|
);
|
|
return toEnvelope<T>(reset.rows[0]);
|
|
}
|