"""fold note_items into the note body and drop the table Revision ID: 0027 Revises: 0026 Create Date: 2026-08-24 A checklist item becomes a `- [ ] milk` line of `notes.body`, so a list can sit between two paragraphs, and `note_items` goes. ## This migration rewrites note bodies * Rows are read before the table is dropped, in this one transaction. * The existing body is only appended to. * The layout (a blank line between prose and the list, nothing between items) is what the export writes and what `derive::append_item` produces, so the clients migrate their own stores independently and still agree with the server. The fold is inlined rather than imported from `notes/checklist.py`: a migration must keep producing what it produced the day it ran. ## `updated_at` is left alone Raw SQL, so `onupdate` never fires, and a client holding an unpushed edit keeps the newer `updated_at` and wins last-write-wins. The `sync_revision` trigger does fire, so every migrated note is pulled once and clients converge. ## The downgrade is not a true inverse It recreates an empty `note_items` and leaves the bodies alone: nothing tells a line this wrote from one somebody typed. The real rollback is a database restore. The table must exist because 0015's downgrade drops a trigger on it. """ 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 gone has nothing to fold into. The foreign key should # prevent it; skipping avoids a half-migrated database. 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(): # Exactly as 0006 created it, index name included: 0015's downgrade names both # the table and its trigger. 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"])