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
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user