CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 13s
Android / Build the server image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / integration (push) Successful in 1m30s
CI & Build / Build & push image (push) Successful in 32s
Rule 32 "Comments describe the product to a stranger" sweep over src/, alembic/ and tests/. Comments and docstrings now say what the code does and the reason as a present-tense constraint, briefly. Removed: - issue, task, note, rule, milestone and family-idea references - "used to" / before-and-after narration - quotes of decisions and incident stories from the deploying instance - paragraph-length arguments, where a line or two carries the fact Kept every hazard as a present-tense fact, e.g. timing oracles, the proxy hop direction, and migrations that must stay frozen. Also: - Three test names lose "still"/"used to"/"pre_m304" phrasing. - pyproject's description drops "FabledSword family". The history these comments carried stays in Scribe and in git log. A comment-stripping diff (Scribe snippet #6226 "codeonly.py — prove a diff changed only comments") shows no code changed apart from the renamed tests and the description string. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
94 lines
3.5 KiB
Python
94 lines
3.5 KiB
Python
"""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"])
|