Add idempotent Alembic migrations, auto-migrate on startup, update summary

Rewrite migrations to raw SQL with IF NOT EXISTS/EXCEPTION guards for
full idempotency. Add notes table migration (0001), fix migration chain,
and run alembic upgrade head in Dockerfile CMD on container start.
Update summary.md with Phase 3.5 changes and Alembic instructions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 23:55:40 -05:00
parent 22a3a3c1d1
commit e2338918b0
4 changed files with 232 additions and 95 deletions
+1 -1
View File
@@ -22,4 +22,4 @@ COPY alembic/ alembic/
ENV PYTHONPATH=/app/src
EXPOSE 5000
CMD ["hypercorn", "fabledassistant.app:create_app()", "--bind", "0.0.0.0:5000"]
CMD ["sh", "-c", "(alembic stamp --purge base 2>/dev/null || true) && alembic upgrade head && hypercorn 'fabledassistant.app:create_app()' --bind 0.0.0.0:5000"]
@@ -0,0 +1,33 @@
"""create notes table
Revision ID: 0001
Revises:
Create Date: 2025-01-01 00:00:00.000000
"""
from alembic import op
revision = "0001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
CREATE TABLE IF NOT EXISTS notes (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
tags TEXT[] NOT NULL DEFAULT '{}',
parent_id INTEGER REFERENCES notes(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_notes_tags ON notes USING gin (tags)")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_notes_tags")
op.execute("DROP TABLE IF EXISTS notes")
+37 -59
View File
@@ -1,76 +1,54 @@
"""create tasks table
Revision ID: 0002
Revises:
Revises: 0001
Create Date: 2025-01-01 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import ARRAY
revision = "0002"
down_revision = None
down_revision = "0001"
branch_labels = None
depends_on = None
def upgrade() -> None:
task_status = sa.Enum("todo", "in_progress", "done", name="task_status")
task_status.create(op.get_bind(), checkfirst=True)
task_priority = sa.Enum("none", "low", "medium", "high", name="task_priority")
task_priority.create(op.get_bind(), checkfirst=True)
op.create_table(
"tasks",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("title", sa.Text(), nullable=False, server_default=""),
sa.Column("description", sa.Text(), nullable=False, server_default=""),
sa.Column(
"status",
task_status,
nullable=False,
server_default="todo",
),
sa.Column(
"priority",
task_priority,
nullable=False,
server_default="none",
),
sa.Column("due_date", sa.Date(), nullable=True),
sa.Column(
"note_id",
sa.Integer(),
sa.ForeignKey("notes.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("tags", ARRAY(sa.Text()), nullable=False, server_default="{}"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)
op.create_index("ix_tasks_tags", "tasks", ["tags"], postgresql_using="gin")
op.create_index("ix_tasks_note_id", "tasks", ["note_id"])
op.create_index("ix_tasks_status", "tasks", ["status"])
op.execute("""
DO $$ BEGIN
CREATE TYPE task_status AS ENUM ('todo', 'in_progress', 'done');
EXCEPTION WHEN duplicate_object THEN null;
END $$
""")
op.execute("""
DO $$ BEGIN
CREATE TYPE task_priority AS ENUM ('none', 'low', 'medium', 'high');
EXCEPTION WHEN duplicate_object THEN null;
END $$
""")
op.execute("""
CREATE TABLE IF NOT EXISTS tasks (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
status task_status NOT NULL DEFAULT 'todo',
priority task_priority NOT NULL DEFAULT 'none',
due_date DATE,
note_id INTEGER REFERENCES notes(id) ON DELETE SET NULL,
tags TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_tasks_tags ON tasks USING gin (tags)")
op.execute("CREATE INDEX IF NOT EXISTS ix_tasks_note_id ON tasks (note_id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_tasks_status ON tasks (status)")
def downgrade() -> None:
op.drop_index("ix_tasks_status", table_name="tasks")
op.drop_index("ix_tasks_note_id", table_name="tasks")
op.drop_index("ix_tasks_tags", table_name="tasks")
op.drop_table("tasks")
sa.Enum(name="task_priority").drop(op.get_bind(), checkfirst=True)
sa.Enum(name="task_status").drop(op.get_bind(), checkfirst=True)
op.execute("DROP INDEX IF EXISTS ix_tasks_status")
op.execute("DROP INDEX IF EXISTS ix_tasks_note_id")
op.execute("DROP INDEX IF EXISTS ix_tasks_tags")
op.execute("DROP TABLE IF EXISTS tasks")
op.execute("DROP TYPE IF EXISTS task_priority")
op.execute("DROP TYPE IF EXISTS task_status")
+161 -35
View File
@@ -1,10 +1,10 @@
# Fabled Assistant - Project Context
> **Purpose:** This file is the canonical reference for re-initializing Claude Code
> context on this project. It should be updated after every significant change.
> context on this project. **Update this file after every change session.**
## Last Updated
2026-02-08 — Phase 3 complete (Tasks CRUD + Wikilinks)
2026-02-09 — Phase 3.5 complete (Task-Note companions, wikilink auto-create, backlinks, bug fixes, Alembic migration infrastructure)
## Project Overview
Fabled Assistant is a self-hosted note-taking and task-tracking application with
@@ -27,6 +27,10 @@ for AI-assisted features.
- **Single container for frontend + API:** Quart serves the Vue.js production
build as static files and exposes the REST API under `/api/`.
- **Quart chosen for familiarity:** The maintainer (bvandeusen) knows Quart well.
- **SPA routing via 404 handler:** `app.py` uses `@app.errorhandler(404)` (not a
catch-all route) to serve static files or fall back to `index.html`. API routes
(`/api/*`) get JSON 404 responses. This avoids a catch-all `/<path:path>` route
intercepting API GETs.
- **LLM integration is a separate service:** The app communicates with Ollama (or
compatible) over HTTP.
- **Inline tag extraction:** Tags are extracted from note/task body text using
@@ -37,10 +41,18 @@ for AI-assisted features.
`LIKE` prefix.
- **Dark-first theming:** CSS custom properties on `:root` (light) and
`[data-theme="dark"]`, with `prefers-color-scheme` detection defaulting to dark.
- **Task-Note linking:** Tasks have an optional `note_id` FK (one task → one note).
Notes display their linked tasks in a "Linked Tasks" section.
- **Task-Note companion link:** Every task automatically gets a companion note
(created in `create_task()`). Title changes sync bidirectionally. Deleting either
cascades to the other. `_skip_cascade` flag prevents infinite loops.
- **Wikilinks:** Obsidian-style `[[Title]]` and `[[Title|Display Text]]` in markdown
bodies resolve to notes by exact title match via `/api/notes/by-title`.
bodies. Clicking a wikilink uses `POST /api/notes/resolve-title` to auto-create
missing notes.
- **Backlinks:** `GET /api/notes/:id/backlinks` searches all note bodies and task
descriptions for `[[Title]]` patterns referencing the given note.
- **Idempotent raw SQL migrations:** All Alembic migrations use raw SQL with
`CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and `DO $$ BEGIN
CREATE TYPE ... EXCEPTION WHEN duplicate_object` to allow safe re-runs and
fresh database creation.
### High-Level Component Diagram
```
@@ -76,14 +88,17 @@ for AI-assisted features.
- Tags are auto-extracted from body text on create/update via `#tag` regex
- Supports hierarchical organization via `parent_id`
- Lookup by exact title via `get_note_by_title()` for wikilink resolution
- Auto-create via `get_or_create_note_by_title()` for wikilink clicks
### Tasks (implemented)
- `id` (int PK), `title` (str), `description` (markdown str),
`status` (enum: todo/in_progress/done), `priority` (enum: none/low/medium/high),
`due_date` (date, nullable), `note_id` (FK to notes, nullable, SET NULL),
`due_date` (date, nullable), `note_id` (FK to notes, auto-created companion),
`tags` (ARRAY[str]), `created_at`, `updated_at`
- Tags auto-extracted from description on create/update
- Status enum with quick-toggle cycling: todo → in_progress → done → todo
- Companion note auto-created on task creation, title synced bidirectionally
- Deleting a task deletes its companion note (and vice versa)
- Indexes: GIN on tags, B-tree on note_id, B-tree on status
### LLM Interactions (Phase 4)
@@ -97,15 +112,17 @@ fabledassistant/
├── pyproject.toml # Python project config
├── Dockerfile # Multi-stage build (Node → Python)
├── docker-compose.yml # Dev compose (app, PostgreSQL, Ollama)
├── alembic/ # DB migrations
├── alembic.ini
├── alembic.ini # Alembic config (prepend_sys_path = src)
├── alembic/
│ ├── env.py # Async migration runner
│ └── versions/
── 0002_create_tasks_table.py # Tasks table + enums migration
── 0001_create_notes_table.py # Notes table (raw SQL, idempotent)
│ ├── 0002_create_tasks_table.py # Tasks table + enums (raw SQL, idempotent)
│ └── 0003_task_note_companion.py # Data migration: create companion notes for existing tasks
├── src/
│ └── fabledassistant/
│ ├── __init__.py
│ ├── app.py # Quart app factory, serves SPA + registers blueprints
│ ├── app.py # Quart app factory: SPA via 404 handler, JSON 404/500 for API
│ ├── config.py # Config from env vars
│ ├── models/
│ │ ├── __init__.py # async_session factory, Base, imports Note + Task
@@ -114,11 +131,11 @@ fabledassistant/
│ ├── routes/
│ │ ├── __init__.py
│ │ ├── api.py # /api blueprint with /health endpoint
│ │ ├── notes.py # /api/notes CRUD + /by-title endpoint
│ │ ├── notes.py # /api/notes CRUD + /by-title + /resolve-title + /convert-to-task + /backlinks
│ │ └── tasks.py # /api/tasks CRUD + PATCH status
│ ├── services/
│ │ ├── notes.py # Business logic: CRUD, hierarchical tag filter, get_note_by_title
│ │ └── tasks.py # Task CRUD: filter by status/priority/note_id/tags/search
│ │ ├── notes.py # CRUD, tag filter, get_or_create_by_title, convert_note_to_task, get_backlinks, cascade delete/sync
│ │ └── tasks.py # CRUD with auto companion note, cascade delete/sync, _skip_cascade flag
│ ├── utils/
│ │ ├── __init__.py
│ │ └── tags.py # extract_tags() — regex #tag extraction, skips code fences
@@ -135,30 +152,33 @@ fabledassistant/
│ ├── api/
│ │ └── client.ts # apiGet/apiPost/apiPut/apiPatch/apiDelete helpers
│ ├── composables/
│ │ ── useTheme.ts # Theme toggle, localStorage, prefers-color-scheme
│ │ ── useTheme.ts # Theme toggle, localStorage, prefers-color-scheme
│ │ └── useAutocomplete.ts # #tag + [[wikilink]] autocomplete: Tab cycling, debounced search
│ ├── stores/
│ │ ├── notes.ts # Notes state: CRUD + tag filter, pagination, sort, search
│ │ ├── tasks.ts # Tasks state: CRUD + status/priority filter, patchStatus
│ │ ├── notes.ts # CRUD + tag filter, resolveTitle, convertToTask, fetchBacklinks, fetchAllTags
│ │ ├── tasks.ts # CRUD + status/priority filter, patchStatus
│ │ └── toast.ts # Toast notification state, 3s auto-dismiss
│ ├── types/
│ │ ├── note.ts # Note, NoteListResponse interfaces
│ │ └── task.ts # Task, TaskStatus, TaskPriority, TaskListResponse
│ ├── utils/
│ │ ── tags.ts # extractTags(), linkifyTags(), linkifyWikilinks()
│ │ ── tags.ts # extractTags(), linkifyTags(), linkifyWikilinks()
│ │ └── markdown.ts # renderMarkdown() (full), renderPreview() (strips links/images for cards)
│ ├── views/
│ │ ├── HomeView.vue # Landing page with health status + links to Notes/Tasks
│ │ ├── HomeView.vue # Landing page: recent notes + tasks (independent error handling)
│ │ ├── NotesListView.vue # Note list: search, sort, tag filter pills, pagination
│ │ ├── NoteEditorView.vue # Create/edit: Ctrl+S, unsaved guard, toasts
│ │ ├── NoteViewerView.vue # Markdown render: DOMPurify, inline tags, wikilinks, linked tasks
│ │ ├── NoteEditorView.vue # Create/edit: Ctrl+S, unsaved guard, toasts, autocomplete
│ │ ├── NoteViewerView.vue # Markdown render, wikilink auto-create, companion task link, convert-to-task, backlinks
│ │ ├── TasksListView.vue # Task list: search, status/priority filters, sort, pagination, status toggle
│ │ ├── TaskEditorView.vue # Create/edit task: all fields, note-link autocomplete, Ctrl+S, dirty guard
│ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges, linked note, wikilinks
│ │ ├── TaskEditorView.vue # Create/edit task: fields, companion note link, Ctrl+S, dirty guard, autocomplete
│ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges, companion note link, backlinks
│ ├── components/
│ │ ├── AppHeader.vue # Nav bar: brand, Notes + Tasks links, theme toggle
│ │ ├── NoteCard.vue # Card with TagPill, tag-click emit
│ │ ├── TaskCard.vue # Card with StatusBadge (clickable), PriorityBadge, due date, tags
│ │ ├── NoteCard.vue # Card with rendered markdown preview (v-html), TagPill, tag-click emit
│ │ ├── TaskCard.vue # Card with rendered preview, StatusBadge (clickable), PriorityBadge, due date, tags
│ │ ├── StatusBadge.vue # Color-coded status badge, optional clickable cycling
│ │ ├── PriorityBadge.vue # Color-coded priority indicator (hidden for "none")
│ │ ├── MarkdownToolbar.vue # Bold/italic/link/list/heading toolbar for editor
│ │ ├── SearchBar.vue # Debounced search input
│ │ ├── TagPill.vue # Clickable/dismissible tag pill
│ │ ├── PaginationBar.vue # Prev/next + page numbers
@@ -175,16 +195,100 @@ fabledassistant/
| GET | `/api/health` | Health check |
| GET | `/api/notes` | List notes (params: `q`, `tag`, `sort`, `order`, `limit`, `offset`) |
| POST | `/api/notes` | Create note (body: `{title, body}` — tags auto-extracted) |
| GET | `/api/notes/by-title?title=...` | Resolve note by exact title (for wikilinks) |
| GET | `/api/notes/tags` | List all tags from notes + tasks (param: `q` for filter) |
| GET | `/api/notes/by-title?title=...` | Resolve note by exact title (case-insensitive) |
| POST | `/api/notes/resolve-title` | Get-or-create note by title (for wikilink clicks) |
| GET | `/api/notes/:id` | Get single note |
| PUT | `/api/notes/:id` | Update note (body: `{title?, body?}` — tags re-extracted if body changes) |
| DELETE | `/api/notes/:id` | Delete note |
| DELETE | `/api/notes/:id` | Delete note (cascades to companion task) |
| POST | `/api/notes/:id/convert-to-task` | Convert note into a task (deletes original note) |
| GET | `/api/notes/:id/backlinks` | List notes/tasks that reference this note via wikilinks |
| GET | `/api/tasks` | List tasks (params: `q`, `tag`, `status`, `priority`, `note_id`, `sort`, `order`, `limit`, `offset`) |
| POST | `/api/tasks` | Create task (body: `{title, description, status?, priority?, due_date?, note_id?}`) |
| POST | `/api/tasks` | Create task (body: `{title, description, status?, priority?, due_date?}` — companion note auto-created) |
| GET | `/api/tasks/:id` | Get single task |
| PUT | `/api/tasks/:id` | Update task |
| PUT | `/api/tasks/:id` | Update task (title syncs to companion note) |
| PATCH | `/api/tasks/:id/status` | Quick status toggle (body: `{status}`) |
| DELETE | `/api/tasks/:id` | Delete task |
| DELETE | `/api/tasks/:id` | Delete task (cascades to companion note) |
## Alembic Migrations
### Overview
Migrations use raw SQL for full idempotency — safe to run on a fresh database
or re-run on an existing one. The Dockerfile runs migrations automatically on
container startup.
### Migration Chain
```
0001_create_notes_table.py → 0002_create_tasks_table.py → 0003_task_note_companion.py
```
### How Migrations Run
The Dockerfile CMD runs:
```sh
(alembic stamp --purge base 2>/dev/null || true) && alembic upgrade head && hypercorn ...
```
- `alembic stamp --purge base` clears any stale revision stamps (safe no-op if none)
- `alembic upgrade head` runs all pending migrations in order
- Migrations are idempotent so re-running is safe
### Creating a New Migration
When adding a new migration, follow these conventions:
1. **Create the migration file:**
```
alembic/versions/0004_description.py
```
2. **Use raw SQL for idempotency:**
```python
from alembic import op
revision = "0004"
down_revision = "0003"
def upgrade() -> None:
# For new enums:
op.execute("""
DO $$ BEGIN
CREATE TYPE my_enum AS ENUM ('a', 'b');
EXCEPTION WHEN duplicate_object THEN null;
END $$
""")
# For new tables:
op.execute("""
CREATE TABLE IF NOT EXISTS my_table (...)
""")
# For new indexes:
op.execute("CREATE INDEX IF NOT EXISTS ix_name ON table (col)")
# For adding columns:
op.execute("""
DO $$ BEGIN
ALTER TABLE my_table ADD COLUMN new_col TEXT DEFAULT '';
EXCEPTION WHEN duplicate_column THEN null;
END $$
""")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_name")
op.execute("DROP TABLE IF EXISTS my_table")
op.execute("DROP TYPE IF EXISTS my_enum")
```
3. **Rebuild and test:**
```bash
docker compose up --build
```
To test from a completely fresh database:
```bash
docker compose down -v && docker compose up --build
```
### Important Notes
- Do NOT use `op.create_table()` or `sa.Enum()` — SQLAlchemy's event system
can fire `CREATE TYPE` even with `create_type=False`, causing failures on re-run
- Always write raw SQL with PostgreSQL idempotency guards
- Set `down_revision` to chain from the previous migration's `revision`
- Data migrations (like 0003) should use `op.get_bind()` + `sa.text()` for queries
## Phased Roadmap
@@ -226,6 +330,22 @@ fabledassistant/
- [x] "Linked Tasks" section on NoteViewerView with inline status toggling
- [x] Theme variables for status/priority/wikilink/overdue colors (light + dark)
### Phase 3.5 — Note-Task Integration + Bug Fixes ✓
- [x] **Task-Note companion link:** Every task auto-creates a companion note on creation
- [x] **Bidirectional title sync:** Renaming task syncs to companion note and vice versa
- [x] **Cascade delete:** Deleting a task deletes its companion note (and vice versa)
- [x] **Wikilink auto-create:** Clicking `[[New Page]]` creates the note if it doesn't exist
- [x] **Convert note to task:** Button on note viewer, creates task with companion note, deletes original
- [x] **Backlinks system:** "What links here" section on note and task viewers
- [x] **Rendered markdown previews:** NoteCard/TaskCard show rendered markdown (links/images stripped)
- [x] **Tag autocomplete Tab cycling:** Tab cycles through suggestions, single match accepts immediately
- [x] **HomeView error isolation:** Notes and tasks load independently (one failure doesn't break both)
- [x] **SPA routing fix:** Replaced catch-all route with 404 error handler to prevent API interception
- [x] **500 error handler:** JSON error responses for API routes, traceback logging
- [x] **Idempotent migrations:** All migrations rewritten to raw SQL with IF NOT EXISTS guards
- [x] **Auto-migration on startup:** Dockerfile runs `alembic upgrade head` before starting app
- [x] **Data migration 0003:** Creates companion notes for pre-existing tasks
### Phase 4 — LLM Integration (next)
- [ ] Ollama client in the backend (async HTTP via httpx/aiohttp)
- [ ] API endpoints for LLM features (summarize note, generate tasks from note,
@@ -253,18 +373,24 @@ fabledassistant/
- Frontend dev: Vite dev server with proxy to Quart (via Docker)
- Production build: Dockerfile multi-stage — Vite builds Vue into static files,
Quart serves them
- To reset database: `docker compose down -v && docker compose up --build`
## Open Questions
- Authentication model: single-user (password-only) vs multi-user?
- Should LLM streaming use WebSockets from Phase 4 or defer to Phase 5?
## Current Status
**Phase:** Phase 3 complete. Tasks CRUD with wikilinks fully implemented.
**Phase:** Phase 3.5 complete. Note-task companion system, wikilink auto-create,
backlinks, and bug fixes fully implemented.
- Full notes CRUD with markdown editing, rendering, and wikilinks
- Full tasks CRUD with status/priority enums, filters, note linking
- Inline `#tag` extraction for both notes and tasks
- Obsidian-style `[[wikilinks]]` with title resolution
- StatusBadge with clickable cycling, PriorityBadge, overdue date styling
- "Linked Tasks" section on note viewer with inline status toggling
- Full tasks CRUD with status/priority enums, filters, companion note linking
- Automatic companion note creation for every task
- Bidirectional title sync and cascade delete between tasks and notes
- Convert note to task functionality
- Backlinks system showing "what links here" for notes and tasks
- Wikilink clicks auto-create missing notes
- Rendered markdown previews on list cards
- Tag autocomplete with Tab cycling
- Idempotent raw SQL migrations with auto-run on startup
- Dark/light theme with status/priority/wikilink color variables
- Ready for Phase 4: LLM Integration