server: the body is the checklist here too, and note_items is dropped
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Failing after 8s
CI & Build / integration (push) Successful in 20s
CI & Build / Build & push image (push) Skipped
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Failing after 8s
CI & Build / integration (push) Successful in 20s
CI & Build / Build & push image (push) Skipped
M304 steps 3 and the server half of 4. The client half landed in 668f7fa; these belong in one deploy, and the protocol floor below is what enforces that. notes/checklist.py is the Python half of a grammar that now exists three times — here, core/src/local/derive.rs, and (next) frontend/src/notes/markdown.ts. That triplication is the deliberate cost: the alternative is a round trip to the server before a phone can draw a checkbox. Each copy names the other two, and each is tested against the same table of cases, including the near-misses that must stay prose: `-[ ] x`, `- []`, `- [ ]x`, a `[ ]` mid-sentence. Routes: add/update/delete items stop touching rows and rewrite note.body, all through one _rewrite_body that runs the same sequence the PATCH route runs for a body change — because it IS a body change. Revisions, #tag reconciliation, the name, and link unfurls therefore happen in one place rather than three routes each remembering to. The reorder route is gone (rule 22). Reordering a checklist is moving a line, and no client ever called it — the only reference in the tree was a test asserting the route existed. The API still returns `items`, DERIVED from the body on the way out. That is not a second source of truth and it cannot disagree with the body it came from; it keeps the web client working across the rest of this milestone and saves any consumer that only wants to draw checkboxes from carrying a parser. Export drops its separate items block, in both formats. The body already ends with those exact lines, so writing them again would double every checklist in an export and then double it again on re-import. Import still ACCEPTS items, because a Keep takeout has a list and not a blob; it folds them in before the Note is built, so display_title and _reconcile_tags both see the finished text. Protocol 3 on both sides now. A v2 client is refused rather than half-served — which matters more than I first said: _apply_note_items returned early on an absent `items` key, so an un-bumped v3 client against a v2 server would not have LOST the rows, it would have kept them and then had the migration fold them a second time. Duplicated lists rather than missing ones. The floor prevents both. Migration 0027 folds every existing row into its note's body and drops the table. It inlines its own copy of the fold on purpose — a migration has to keep producing what it produced the day it ran — and a test pins that copy against the app's until they are allowed to diverge. updated_at is deliberately untouched: a client holding an unpushed edit keeps the newer timestamp, so last-write-wins keeps its work instead of the migration silently winning. The downgrade is honest rather than faithful. It recreates an empty note_items and leaves the bodies alone, because once items are lines nothing distinguishes one this migration wrote from one somebody typed, and a downgrade that guessed would eat hand-written lists. Recreating the table is still necessary: 0015's downgrade drops a trigger ON note_items, and IF EXISTS covers the trigger, not the table.
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
"""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"])
|
||||
Reference in New Issue
Block a user