feat: add Alembic database migration system

Implements Alembic for managing token storage database schema versions.
Migrations run automatically on startup with full backward compatibility.

**Changes:**
- Add Alembic dependency (1.14.0+) and SQLAlchemy (auto-installed)
- Create migration infrastructure in alembic/ directory
- Add initial migration (001) capturing current schema
- Modify RefreshTokenStorage.initialize() to run migrations via anyio
- Add CLI commands: db upgrade, current, history, downgrade, migrate
- Add comprehensive migration documentation

**Backward Compatibility:**
- Pre-Alembic databases automatically stamped with revision 001
- No schema changes for existing databases
- Automatic upgrade on first startup after update

**Migration Strategy:**
Three scenarios handled:
1. New database → Run migrations from scratch
2. Pre-Alembic database → Stamp with 001 (no changes)
3. Alembic-managed → Upgrade to latest

**Architecture:**
- Uses anyio.to_thread.run_sync() for structured concurrency
- Alembic env.py runs with anyio.run() in worker thread
- SQLite-friendly migration patterns documented
- No ThreadPoolExecutor needed (anyio handles it)

**CLI Usage:**
```bash
nextcloud-mcp-server db upgrade    # Upgrade to latest
nextcloud-mcp-server db current    # Show version
nextcloud-mcp-server db history    # View changelog
nextcloud-mcp-server db downgrade  # Rollback (with confirmation)
nextcloud-mcp-server db migrate "description"  # Create migration
```

**Testing:**
- All 13 webhook storage tests pass
- New/pre-Alembic database scenarios validated
- anyio integration tested

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2025-12-18 00:02:09 +01:00
co-authored by Claude Sonnet 4.5
parent a4a34e46a8
commit 3fa376905c
17 changed files with 1436 additions and 152 deletions
+55
View File
@@ -501,6 +501,10 @@ export default {
return this.results.filter(r => (r.score || 0) >= threshold)
},
},
mounted() {
// Check for URL parameters to open chunk viewer
this.handleUrlParameters()
},
beforeDestroy() {
// Clean up Plotly event handlers to prevent memory leaks
const plotDiv = document.getElementById('viz-plot')
@@ -509,6 +513,51 @@ export default {
}
},
methods: {
handleUrlParameters() {
// Parse URL parameters
const urlParams = new URLSearchParams(window.location.search)
const docType = urlParams.get('doc_type')
const docId = urlParams.get('doc_id')
const chunkStart = urlParams.get('chunk_start')
const chunkEnd = urlParams.get('chunk_end')
// If we have chunk parameters, open the viewer
if (docType && docId && chunkStart !== null && chunkEnd !== null) {
// Construct a minimal result object
const result = {
doc_type: docType,
id: parseInt(docId, 10),
chunk_start_offset: parseInt(chunkStart, 10),
chunk_end_offset: parseInt(chunkEnd, 10),
title: urlParams.get('title') || this.t('astrolabe', 'Chunk Viewer'),
metadata: {},
}
// Add optional metadata
const path = urlParams.get('path')
if (path) {
result.metadata.path = path
}
const pageNumber = urlParams.get('page_number')
if (pageNumber) {
result.page_number = parseInt(pageNumber, 10)
}
const boardId = urlParams.get('board_id')
if (boardId) {
result.metadata.board_id = boardId
}
// Open the chunk viewer
this.$nextTick(() => {
this.viewChunk(result)
})
// Clear URL parameters to avoid reopening on navigation
const newUrl = window.location.pathname
window.history.replaceState({}, '', newUrl)
}
},
toggleDocType(docTypeId, checked) {
if (checked && !this.selectedDocTypes.includes(docTypeId)) {
this.selectedDocTypes.push(docTypeId)
@@ -616,6 +665,12 @@ export default {
case 'note':
return generateUrl(`/apps/notes/#/note/${id}`)
case 'file':
// For PDFs with page numbers, use the PDF viewer with page anchor
if (result.page_number && metadata.path) {
const pageParam = `#page=${result.page_number}`
return generateUrl(`/apps/files_pdfviewer/?file=${encodeURIComponent(metadata.path)}${pageParam}`)
}
// For other files, use the standard file viewer
if (id) {
return generateUrl(`/apps/files/files/${id}?dir=/&editing=false&openfile=true`)
}