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
+36 -24
View File
@@ -223,39 +223,51 @@ class SemanticSearchProvider implements IProvider {
}
/**
* Build URL to navigate to the original document.
* Build URL to navigate to Astrolabe with chunk viewer.
*
* URL formats match App.vue's getDocumentUrl() implementation for consistency.
* Links to Astrolabe app with query parameters that trigger the chunk modal,
* allowing users to preview the chunk before navigating to the full document.
*/
private function buildResourceUrl(array $result): string {
// Build base URL to Astrolabe app
$baseUrl = $this->urlGenerator->linkToRoute(Application::APP_ID . '.page.index');
// Extract chunk parameters
$docType = $result['doc_type'] ?? 'unknown';
$id = $result['id'] ?? null;
$path = $result['path'] ?? null;
$chunkStart = $result['chunk_start_offset'] ?? null;
$chunkEnd = $result['chunk_end_offset'] ?? null;
return match ($docType) {
'note' => $id
? $this->urlGenerator->linkToRoute('notes.page.index') . '/#/note/' . $id
: $this->urlGenerator->linkToRoute('notes.page.index'),
// If we have chunk information, build URL with parameters
if ($id !== null && $chunkStart !== null && $chunkEnd !== null) {
$params = [
'doc_type' => $docType,
'doc_id' => $id,
'chunk_start' => $chunkStart,
'chunk_end' => $chunkEnd,
];
'file' => $id
? $this->urlGenerator->linkToRouteAbsolute('files.view.index') . 'files/' . $id . '?dir=/&editing=false&openfile=true'
: $this->urlGenerator->linkToRouteAbsolute('files.view.index'),
// Add optional metadata
if (isset($result['title'])) {
$params['title'] = $result['title'];
}
if (isset($result['path'])) {
$params['path'] = $result['path'];
}
if (isset($result['page_number'])) {
$params['page_number'] = $result['page_number'];
}
if (isset($result['board_id'])) {
$params['board_id'] = $result['board_id'];
}
'deck_card' => isset($result['board_id']) && $id
? $this->urlGenerator->linkToRoute('deck.page.index')
. "board/{$result['board_id']}/card/{$id}"
: $this->urlGenerator->linkToRoute('deck.page.index'),
// Encode parameters for URL
$queryString = http_build_query($params);
return $baseUrl . '?' . $queryString;
}
'calendar', 'calendar_event' => $this->urlGenerator->linkToRoute('calendar.view.index'),
'news_item' => $id
? $this->urlGenerator->linkToRoute('news.page.index') . 'item/' . $id
: $this->urlGenerator->linkToRoute('news.page.index'),
'contact' => $this->urlGenerator->linkToRoute('contacts.page.index'),
default => $this->urlGenerator->linkToRoute(Application::APP_ID . '.page.index'),
};
// Fallback to base URL if no chunk information
return $baseUrl;
}
/**
+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`)
}