"""fold note_items into the note body and drop the table Revision ID: 0027 Revises: 0026 Create Date: 2026-08-24 M304. A checklist item becomes a `- [ ] milk` line of `notes.body`, and `note_items` goes. The reason is positional, not cosmetic: a row had a position in a table and no position in the text, so a separate list could only ever render AFTER the prose. With the items in the body, a list can sit between two paragraphs — which is the thing that could not be built before and no amount of restyling would have delivered. ## This migration rewrites real content Every note that has items gets its body appended to. The Google Keep import is genuine content on this instance, not fixtures, so the rules here are strict: * Rows are read BEFORE the table is dropped, in this one transaction. * The existing body is never rewritten, only appended to. * The layout — a blank line between prose and the list, nothing between consecutive items — is byte-for-byte what `_note_markdown` has always exported and what `derive::append_item` produces on every client. All three landing on the same text is what lets the clients migrate their own SQLite stores independently and still agree with the server, with no sync required to reconcile them. ## The fold is inlined on purpose `notes/checklist.py` has this same function and this migration deliberately does not import it. A migration has to keep producing what it produced the day it ran; if the app's spacing rule ever changes, this file must not change with it. ## `updated_at` is left alone, and that is load-bearing Raw SQL, so SQLAlchemy's `onupdate` never fires. Two reasons, and the second matters more than the first. Every client folds the same rows the same way, so the new body is news to nobody. And a client holding an UNPUSHED body edit still has the newer `updated_at`, so when it pulls the migrated note last-write-wins keeps its edit instead of the migration silently winning. The `notes` row's own `sync_revision` trigger (migration 0015) does fire, so every migrated note becomes pullable once. That is wanted: it is what makes a client whose local fold somehow differed converge on the server's text. ## The downgrade is not a true inverse, and says so It recreates an empty `note_items` and leaves the bodies alone. Nothing is lost — every item is still there as text, which is where this migration put it — but the old code would show those notes as prose with no checklist. A faithful inverse is not possible: once the items are lines, nothing distinguishes a line this migration wrote from one somebody typed, and a downgrade that guessed would eat hand-written task lists. The real rollback is a database restore. Recreating the table is not decoration, though. Migration 0015's downgrade runs `DROP TRIGGER IF EXISTS trg_note_items_bump_note ON note_items`, and `IF EXISTS` covers the trigger, not the table — against a missing table that statement errors. So this is what keeps the migration chain runnable all the way back down. """ import re import sqlalchemy as sa from alembic import op from sqlalchemy.dialects.postgresql import UUID revision = "0027" down_revision = "0026" branch_labels = None depends_on = None _TASK_RE = re.compile(r"^\s*[-*] +\[[ xX]\](?: +.*)?$") def _append_item(body: str, text: str, checked: bool) -> str: mark = "x" if checked else " " text = (text or "").strip() line = f"- [{mark}] {text}" if text else f"- [{mark}]" trimmed = (body or "").rstrip("\n") if not trimmed.strip(): return line follows_a_list = bool(_TASK_RE.match(trimmed.split("\n")[-1])) return f"{trimmed}\n{line}" if follows_a_list else f"{trimmed}\n\n{line}" def upgrade(): bind = op.get_bind() rows = bind.execute( sa.text("SELECT note_id, text, checked FROM note_items ORDER BY note_id, position, created_at") ).fetchall() grouped: dict = {} for note_id, text, checked in rows: grouped.setdefault(note_id, []).append((text, bool(checked))) for note_id, items in grouped.items(): body = bind.execute(sa.text("SELECT body FROM notes WHERE id = :id"), {"id": note_id}).scalar() # An item whose note is already gone has nothing to fold into. The foreign key # should make this impossible; skipping costs nothing and failing here would # leave the database half-migrated. if body is None: continue for text, checked in items: body = _append_item(body, text, checked) bind.execute(sa.text("UPDATE notes SET body = :body WHERE id = :id"), {"body": body, "id": note_id}) op.drop_table("note_items") def downgrade(): # Column-for-column as migration 0006 created it, index name included: 0015's # downgrade names both the table and its trigger, so a near-enough copy is not # good enough. op.create_table( "note_items", sa.Column("id", UUID(as_uuid=True), primary_key=True), sa.Column("note_id", UUID(as_uuid=True), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False), sa.Column("text", sa.Text(), nullable=False), sa.Column("checked", sa.Boolean(), nullable=False, server_default=sa.false()), sa.Column("position", sa.Integer(), nullable=False, server_default="0"), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), ) op.create_index("ix_note_items_note", "note_items", ["note_id"])