Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions scaffold/pm-api/sql/009-doc-versioning.sql
Original file line number Diff line number Diff line change
@@ -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;
50 changes: 42 additions & 8 deletions scaffold/pm-api/src/routes/v2-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,59 @@ 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])
if (!rows.length) return c.json({ error: 'Document not found' }, 404)
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
2 changes: 2 additions & 0 deletions scaffold/spec-site/src/App.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script setup lang="ts">
import AppHeader from './components/AppHeader.vue'
import MemoSidebar from './components/MemoSidebar.vue'
import ConfirmDialog from './components/ConfirmDialog.vue'
</script>

<template>
Expand All @@ -10,6 +11,7 @@ import MemoSidebar from './components/MemoSidebar.vue'
<router-view />
</main>
<MemoSidebar />
<ConfirmDialog />
</div>
</template>

Expand Down
5 changes: 3 additions & 2 deletions scaffold/spec-site/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,10 @@ async function apiMutate<T>(
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()
Expand Down
99 changes: 99 additions & 0 deletions scaffold/spec-site/src/components/ConfirmDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<script setup lang="ts">
import { useConfirm } from '@/composables/useConfirm'

const { visible, title, message, confirmText, cancelText, isAlertMode, onConfirm, onCancel } = useConfirm()
</script>

<template>
<Teleport to="body">
<Transition name="modal-fade">
<div v-if="visible" class="modal-overlay" @click.self="isAlertMode ? onConfirm() : onCancel()">
<div class="modal-card" role="dialog" :aria-modal="true" aria-labelledby="confirm-dialog-title">
<p v-if="title" id="confirm-dialog-title" class="modal-title">{{ title }}</p>
<p class="modal-message">{{ message }}</p>
<div class="modal-actions">
<button v-if="!isAlertMode" class="btn-cancel" @click="onCancel">{{ cancelText }}</button>
<button class="btn-confirm" @click="onConfirm">{{ confirmText }}</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>

<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}

.modal-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
padding: 24px;
min-width: 320px;
max-width: 480px;
width: 90%;
}

.modal-title {
font-weight: 600;
font-size: 1rem;
color: var(--text-primary);
margin: 0 0 8px 0;
}

.modal-message {
font-size: 0.9rem;
color: var(--text-secondary);
margin: 0 0 20px 0;
line-height: 1.5;
}

.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}

.btn-cancel {
padding: 7px 16px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-secondary);
font-size: 0.875rem;
cursor: pointer;
transition: background 0.15s;
}
.btn-cancel:hover { background: var(--border-light); }

.btn-confirm {
padding: 7px 16px;
border: none;
border-radius: var(--radius-sm);
background: var(--primary);
color: #fff;
font-size: 0.875rem;
cursor: pointer;
transition: background 0.15s;
}
.btn-confirm:hover { background: var(--primary-dark); }

/* Transition */
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.15s ease;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
</style>
5 changes: 4 additions & 1 deletion scaffold/spec-site/src/components/DocComments.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { apiGet, apiPost, apiPatch, apiDelete } from '@/composables/useTurso'
import { useConfirm } from '@/composables/useConfirm'

const { showConfirm } = useConfirm()

interface Comment {
id: number; doc_id: string; parent_id: number | null; author: string; content: string; created_at: string; updated_at: string
Expand Down Expand Up @@ -49,7 +52,7 @@ async function saveEdit() {
}

async function remove(id: number) {
if (!confirm('Delete this comment?')) return
if (!await showConfirm('Delete this comment?')) return
await apiDelete(`/api/v2/docs/comments/${id}`)
await load()
}
Expand Down
7 changes: 5 additions & 2 deletions scaffold/spec-site/src/components/DocsSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { apiGet, apiPut, apiPatch } from '@/composables/useTurso'
import { useConfirm } from '@/composables/useConfirm'

const { showConfirm, showAlert } = useConfirm()
import TreeNode from './TreeNode.vue'
import Icon from './Icon.vue'

Expand Down Expand Up @@ -93,12 +96,12 @@ async function bulkUploadMd(e: Event) {
input.value = ''
const failed = fileArr.length - count
uploadProgress.value = { current: 0, total: 0 }
alert(`${count} uploaded successfully${failed ? `, ${failed} failed` : ''}`)
await showAlert(`${count} uploaded successfully${failed ? `, ${failed} failed` : ''}`)
await loadTree()
}

async function ctxDelete() {
if (!ctxMenu.value || !confirm('Delete this document?')) return
if (!ctxMenu.value || !await showConfirm('Delete this document?')) return
await apiPatch(`/api/v2/docs/${ctxMenu.value.node.id}`, { archived: 1 })
closeCtxMenu(); await loadTree()
}
Expand Down
5 changes: 4 additions & 1 deletion scaffold/spec-site/src/components/MemoRelations.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { apiGet, apiPost, apiDelete } from '@/composables/useTurso'
import { useConfirm } from '@/composables/useConfirm'

const { showConfirm } = useConfirm()

interface Relation { id: number; source_memo_id: number; target_memo_id: number; relation_type: string; created_by: string }

Expand Down Expand Up @@ -36,7 +39,7 @@ async function addRelation(targetId: number) {
}

async function removeRelation(relId: number) {
if (!confirm('Delete this relation?')) return
if (!await showConfirm('Delete this relation?')) return
await apiDelete(`/api/v2/memos/relations/${relId}`)
await loadRelations()
}
Expand Down
79 changes: 79 additions & 0 deletions scaffold/spec-site/src/composables/useConfirm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* useConfirm — programmatic confirm/alert dialog
*
* Singleton pattern: module-level state, shared across all consumers.
* Works both inside Vue components and in plain TS modules (e.g. TipTap extensions).
*
* Usage:
* const { showConfirm, showAlert } = useConfirm()
* const ok = await showConfirm('Delete this item?')
* if (!ok) return
* await showAlert('Done!')
*/

import { ref } from 'vue'

// Module-level singletons
const visible = ref(false)
const title = ref('')
const message = ref('')
const confirmText = ref('OK')
const cancelText = ref('Cancel')
const isAlertMode = ref(false)

let _resolve: (val: boolean) => void = () => {}

export interface ConfirmOptions {
title?: string
confirmText?: string
cancelText?: string
}

export function useConfirm() {
function showConfirm(msg: string, opts: ConfirmOptions = {}): Promise<boolean> {
return new Promise<boolean>(resolve => {
title.value = opts.title ?? ''
message.value = msg
confirmText.value = opts.confirmText ?? 'Confirm'
cancelText.value = opts.cancelText ?? 'Cancel'
isAlertMode.value = false
visible.value = true
_resolve = resolve
})
}

function showAlert(msg: string, opts: ConfirmOptions = {}): Promise<void> {
return new Promise<void>(resolve => {
title.value = opts.title ?? ''
message.value = msg
confirmText.value = opts.confirmText ?? 'OK'
cancelText.value = ''
isAlertMode.value = true
visible.value = true
_resolve = () => resolve()
})
}

function onConfirm() {
visible.value = false
_resolve(true)
}

function onCancel() {
visible.value = false
_resolve(false)
}

return {
visible,
title,
message,
confirmText,
cancelText,
isAlertMode,
showConfirm,
showAlert,
onConfirm,
onCancel,
}
}
Loading
Loading