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:
co-authored by
Claude Sonnet 4.5
parent
a4a34e46a8
commit
3fa376905c
+36
-24
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Vendored
+55
@@ -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`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user