M311 step 2 — the migration that lifts the notes already written
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 17s
CI & Build / Build & push image (push) Successful in 19s

Step 1 made new saves lift; this does the ones already on disk, so a note
stops showing its tag twice without having to be opened.

Same rule, and a FROZEN copy of it — `split_body_tags` is deliberately not
imported, on 0027's principle that a migration has to keep producing what it
produced the day it ran. If the app's rule is ever loosened, this file must
not loosen with it and start eating prose it previously left alone.
`_display_title` is inlined for the same reason, and recomputed only for a
note whose body actually moved: a note named after its `#todo` line needs a
new name.

The label rows graduate in the same transaction, and that is not cosmetic. A
`via_tag` row claims "backed by text still in the body", and reconcile
detaches any row it cannot find a `#tag` for — so leaving them true would
lose every lifted tag on the note's next save. Flipping them to false is also
what makes the chip's × appear, which is now the only way to remove a tag
whose text is gone.

`updated_at` is left alone so a client holding an unpushed edit still wins
under LWW. The `sync_revision` trigger does fire, which is wanted here: unlike
0027 the clients do NOT yet apply this rule locally, so the server's copy is
the only correct one until step 3.

The downgrade is empty and says why. It cannot restore the deleted lines —
nothing distinguishes one this migration removed from one that was never
there — and flipping the rows back would be actively harmful, since the text
that flag claims backs them is gone and the next save would then detach the
label for real.

Tested on the ten cases that matter, three of which are prose that must come
back byte-identical. The test pins the frozen copy against fixed expectations
rather than against the app's rule — they are allowed to diverge later, which
is the whole point of freezing one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 19:54:26 -04:00
co-authored by Claude Opus 5
parent 606e345580
commit 9810a75564
2 changed files with 212 additions and 0 deletions
+44
View File
@@ -220,6 +220,50 @@ def test_split_body_tags_keeps_a_tag_derived_when_prose_still_carries_it():
assert body == "remember the #todo list"
def test_migration_0028_lifts_exactly_what_the_app_lifts_today():
"""The 0028 data migration rewrites note bodies, and that is not undoable.
It carries its OWN frozen copy of the rule rather than importing
`split_body_tags`, on 0027's principle that a migration must keep producing what
it produced the day it ran. This does not assert the two agree — they are allowed
to diverge later, which is the entire point of freezing one. It pins the frozen
copy against fixed expectations, so nobody can "tidy" it into eating prose.
"""
import importlib.util
from pathlib import Path
path = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0028_lift_standalone_tags.py"
spec = importlib.util.spec_from_file_location("migration_0028", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
# Lifted: the tag was the whole line.
assert mod._split("#todo\nreorganize the homepage") == (["todo"], "reorganize the homepage")
assert mod._split("needs a tauri app\n#todo") == (["todo"], "needs a tauri app")
assert mod._split("#todo #work\nreal text") == (["todo", "work"], "real text")
assert mod._split("foo\n\n#todo\n\nbar") == (["todo"], "foo\n\nbar")
# Untouched: prose. Getting any of these wrong destroys somebody's words.
for prose in ("remember to call #mom tomorrow", "buy milk #grocery", "#2024\nreal"):
assert mod._split(prose) == ([], prose), prose
# Untouched: a tag inside a fence is a shell comment in somebody's snippet.
fenced = "code:\n```\n#!/bin/sh\n#deploy\n```\ndone"
assert mod._split(fenced) == ([], fenced)
# Untouched: a note that is nothing but tags would be blanked.
assert mod._split("#todo") == ([], "#todo")
# Not flipped: the tag is still written in prose, so its text still backs it and
# it must stay derived — flipping it would be claiming otherwise.
assert mod._split("#todo\nremember the #todo list") == ([], "remember the #todo list")
# The name has to move with the body, or a note is titled after a deleted line.
assert mod._display_title("reorganize the homepage") == "reorganize the homepage"
assert mod._display_title("- [ ] milk\n- [ ] eggs") == "milk"
assert mod._display_title("") == ""
def test_parse_tags():
assert parse_tags("buy milk #groceries and #to-do now") == ["groceries", "to-do"]
# case-insensitive dedup, first spelling wins