diff --git a/scaffold/pm-api/sql/009-doc-versioning.sql b/scaffold/pm-api/sql/009-doc-versioning.sql new file mode 100644 index 00000000..f7503de4 --- /dev/null +++ b/scaffold/pm-api/sql/009-doc-versioning.sql @@ -0,0 +1,6 @@ +-- Migration 009: Add version column to docs for optimistic locking +-- Prevents silent data loss when multiple users edit the same document simultaneously. +-- Strategy: version-based (integer increment), not CRDT. +-- On save: WHERE version = ? + increment. If 0 rows updated → 409 Conflict. + +ALTER TABLE docs ADD COLUMN version INTEGER NOT NULL DEFAULT 1; diff --git a/scaffold/pm-api/src/routes/v2-docs.ts b/scaffold/pm-api/src/routes/v2-docs.ts index d7b6bf25..338d138f 100644 --- a/scaffold/pm-api/src/routes/v2-docs.ts +++ b/scaffold/pm-api/src/routes/v2-docs.ts @@ -10,7 +10,7 @@ app.get('/', async (c) => { return c.json({ docs: rows }) }) -// GET /:id - document detail +// GET /:id - document detail (includes version for optimistic locking) app.get('/:id', async (c) => { const id = c.req.param('id') const { rows } = await queryOrThrow('SELECT * FROM docs WHERE id = ?', [id]) @@ -18,17 +18,51 @@ app.get('/:id', async (c) => { return c.json({ doc: rows[0] }) }) -// PUT /:id - create/update document +// PUT /:id - create/update document with optimistic locking +// Body: { title, content, version? } +// - New doc (INSERT): version is ignored; server sets version = 1 +// - Existing doc (UPDATE): must supply version matching current row +// → 409 Conflict if version mismatch (someone else saved in between) app.put('/:id', async (c) => { const id = c.req.param('id') - const body = await c.req.json<{ title: string; content: string }>() + const body = await c.req.json<{ title: string; content: string; version?: number }>() const createdBy = c.get('userName') || 'unknown' - await executeOrThrow( - `INSERT INTO docs (id, title, content, created_by) VALUES (?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET title = excluded.title, content = excluded.content, updated_at = CURRENT_TIMESTAMP`, - [id, body.title, body.content, createdBy], + + // Check if doc already exists + const { rows } = await queryOrThrow('SELECT version FROM docs WHERE id = ?', [id]) + + if (rows.length === 0) { + // New document — INSERT with version = 1 + await executeOrThrow( + `INSERT INTO docs (id, title, content, created_by, version) VALUES (?, ?, ?, ?, 1)`, + [id, body.title, body.content, createdBy], + ) + return c.json({ ok: true, version: 1 }) + } + + // Existing document — optimistic locking + const currentVersion = (rows[0] as { version: number }).version ?? 1 + const clientVersion = body.version + + if (clientVersion === undefined || clientVersion === null) { + return c.json({ error: 'version required for optimistic locking' }, 400) + } + + // Versioned save: only update if version matches + const result = await executeOrThrow( + `UPDATE docs SET title = ?, content = ?, updated_at = CURRENT_TIMESTAMP, version = version + 1 + WHERE id = ? AND version = ?`, + [body.title, body.content, id, clientVersion], ) - return c.json({ ok: true }) + + if (result.rowsAffected === 0) { + return c.json( + { error: 'conflict', message: 'Document was modified by another user', currentVersion }, + 409, + ) + } + + return c.json({ ok: true, version: currentVersion + 1 }) }) export default app diff --git a/scaffold/spec-site/src/App.vue b/scaffold/spec-site/src/App.vue index 5b4a4484..9714a348 100644 --- a/scaffold/spec-site/src/App.vue +++ b/scaffold/spec-site/src/App.vue @@ -1,6 +1,7 @@ diff --git a/scaffold/spec-site/src/api/client.ts b/scaffold/spec-site/src/api/client.ts index 091ff3de..0569aac3 100644 --- a/scaffold/spec-site/src/api/client.ts +++ b/scaffold/spec-site/src/api/client.ts @@ -113,9 +113,10 @@ async function apiMutate( signal: AbortSignal.timeout(5000), }) if (!resp.ok) { - const text = await resp.text().catch(() => '') if (resp.status !== 401 && resp.status !== 403 && _reachable === null) _reachable = false - return { error: `HTTP ${resp.status}: ${text}` } + // Try to parse structured error body (e.g. 409 conflict payloads) + const errorData = await resp.json().catch(() => null) + return { error: `HTTP ${resp.status}`, data: errorData ?? undefined } } _reachable = true const data = await resp.json() diff --git a/scaffold/spec-site/src/components/ConfirmDialog.vue b/scaffold/spec-site/src/components/ConfirmDialog.vue new file mode 100644 index 00000000..0a37af0f --- /dev/null +++ b/scaffold/spec-site/src/components/ConfirmDialog.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/scaffold/spec-site/src/components/DocComments.vue b/scaffold/spec-site/src/components/DocComments.vue index 68fa6845..43bbf3fb 100644 --- a/scaffold/spec-site/src/components/DocComments.vue +++ b/scaffold/spec-site/src/components/DocComments.vue @@ -1,6 +1,9 @@