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

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:
2026-08-24 08:03:37 -04:00
parent 32dafca148
commit 761c3b5e82
13 changed files with 566 additions and 254 deletions
+9 -48
View File
@@ -24,7 +24,6 @@ from .db import session_scope
from .labeling import reconcile_manual_labels, resolve_owned_label_ids
from .models.label import Label, NoteLabel
from .models.note import Note
from .models.note_item import NoteItem
from .models.note_revision import NoteRevision
from .revisions import should_snapshot
from .notes import (
@@ -63,8 +62,8 @@ MAX_PUSH = 1000 # per-batch change cap
#
# One bump for the pair: they landed in the same protocol generation, and nothing ever
# ran against a half-applied v2.
SYNC_PROTOCOL_VERSION = 2
MIN_CLIENT_PROTOCOL_VERSION = 2
SYNC_PROTOCOL_VERSION = 3
MIN_CLIENT_PROTOCOL_VERSION = 3
# Named capabilities beyond the base protocol. An ADDITIVE change earns a name
# here rather than a min-version bump, so a newer client meeting an older server
@@ -213,49 +212,12 @@ def _assign_note_fields(note: Note, ch: dict) -> None:
note.position = ch["position"]
def _first_item_text(ch: dict) -> str:
"""The first non-blank checklist item in a pushed change, or "".
Read straight from the payload rather than the database because the note's name is
computed BEFORE `_apply_note_items` has written anything — and a note whose body is
empty is named by its first item (M13 step 3).
"""
items = ch.get("items")
if not isinstance(items, list):
return ""
for it in items:
if isinstance(it, dict):
text = (it.get("text") or "").strip()
if text:
return text
return ""
async def _apply_note_items(db, note: Note, ch: dict) -> None:
"""Replace the note's checklist items with the client's (items sync inline).
Applies to ANY note. This used to delete every item when the note wasn't
`kind == "list"`, which was survivable only because nothing could produce a note
holding both a body and items. M13 makes that the normal shape — a checklist is
something a note HAS, not something a note IS — and against that shape the old
guard was a data-loss path: the first sync after adding a checklist to a note
would have wiped it.
Removed ahead of the UI that can create the state, deliberately, so there is no
window in which the two disagree.
"""
items = ch.get("items")
if not isinstance(items, list):
# Absent means "not telling us", not "empty". A client that omits the key
# leaves what the server has; only an explicit [] clears it.
return
await db.execute(sa_delete(NoteItem).where(NoteItem.note_id == note.id))
for pos, it in enumerate(items):
if not isinstance(it, dict):
continue
text = (it.get("text") or "").strip()
if text:
db.add(NoteItem(note_id=note.id, text=text, checked=bool(it.get("checked")), position=pos))
# `_first_item_text` and `_apply_note_items` lived here until M304. Both existed for
# one reason — a checklist was a table beside the body — and both are gone with it. A
# pushed change carries its items as `- [ ] ` lines inside `body`, so applying them is
# applying the body, and naming the note is reading its first line. A client that still
# sends an `items` array is a v2 client, and the version floor below turns it away
# before any of this runs.
async def _apply_note_manual_labels(db, note: Note, ch: dict) -> None:
@@ -315,7 +277,7 @@ async def _apply_note(db, ch: dict) -> dict:
old_body = note.body
_assign_note_fields(note, ch)
note.display_title = derive_display_title(note.body, _first_item_text(ch))
note.display_title = derive_display_title(note.body)
if edited_at is not None:
note.updated_at = edited_at
# Non-destructive LWW: snapshot the overwritten server body into history —
@@ -327,7 +289,6 @@ async def _apply_note(db, ch: dict) -> dict:
if not creating and await should_snapshot(db, note.id, old_body, note.body):
db.add(NoteRevision(note_id=note.id, body=old_body))
await db.flush() # assign note.id before items/labels/links
await _apply_note_items(db, note, ch)
await _reconcile_tags(db, note)
await _apply_note_manual_labels(db, note, ch)
await db.flush()