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

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]);
}