A deleted tag is a tombstone on every path, and the web skips tombstones
The web deleted a tag's row outright (delete, and merge's source), so the change feed never carried it and linked devices kept the tag. A device's delete left a tombstone that the web still listed, matched by name on create and rename, minted #tags onto, and accepted in a picker. - labeling.tombstone_label is the one way a tag is deleted: drop its links, set purged_at. REST delete, merge and sync's op=delete all use it. - labeling.live(owner) is the one definition of a tag that exists; every catalog read uses it (list, lookup, create/rename matching, #tag minting, picker ids, export, and sync's name-clash check). - 0040: (owner_id, name) is unique among live tags only, so a tombstone gives its name back and #grocery can be made again, on the web or from a device. Fixes #5382. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -646,6 +646,91 @@ async def test_creating_a_tag_that_differs_only_in_case_returns_the_existing_one
|
||||
assert len(listing) == 1
|
||||
|
||||
|
||||
# --- A deleted tag is a tombstone on every path (#5382) ---------------------------
|
||||
#
|
||||
# The web deleted the row outright, so the change feed never carried the delete and
|
||||
# devices kept the tag. A device's delete left a tombstone the web still listed and
|
||||
# still matched by name. One definition now: `labeling.tombstone_label`, and every
|
||||
# read of the catalog goes through `labeling.live`.
|
||||
|
||||
|
||||
async def _label_in_feed(app_client, lid: str) -> dict | None:
|
||||
feed = await (await app_client.get("/api/sync/changes?since=0")).get_json()
|
||||
return next((lb for lb in feed["labels"] if lb["id"] == lid), None)
|
||||
|
||||
|
||||
async def _push_label(app_client, lid: str, op: str, edited_at: str, name: str = "grocery") -> dict:
|
||||
change = {"entity": "label", "id": lid, "op": op, "name": name, "edited_at": edited_at}
|
||||
resp = await app_client.post("/api/sync/push", json={"changes": [change]})
|
||||
return (await resp.get_json())["results"][0]
|
||||
|
||||
|
||||
async def test_a_tag_deleted_on_the_web_reaches_devices_as_a_tombstone(app_client, db):
|
||||
await _signed_in(app_client, "webdelete")
|
||||
tag = await (await app_client.post("/api/labels", json={"name": "grocery"})).get_json()
|
||||
note = await (await app_client.post("/api/notes", json={"body": "milk"})).get_json()
|
||||
await app_client.put(f"/api/notes/{note['id']}/labels", json={"label_ids": [tag["id"]]})
|
||||
|
||||
assert (await app_client.delete(f"/api/labels/{tag['id']}")).status_code == 200
|
||||
|
||||
row = await _label_in_feed(app_client, tag["id"])
|
||||
assert row is not None and row["purged_at"] is not None, "the feed carries the delete"
|
||||
assert (await (await app_client.get(f"/api/notes/{note['id']}")).get_json())["labels"] == []
|
||||
assert (await (await app_client.get("/api/labels")).get_json())["labels"] == []
|
||||
assert (await app_client.delete(f"/api/labels/{tag['id']}")).status_code == 404, "gone, not hidden"
|
||||
|
||||
|
||||
async def test_a_merge_tombstones_the_tag_it_folds_away(app_client, db):
|
||||
await _signed_in(app_client, "mergedelete")
|
||||
source = await (await app_client.post("/api/labels", json={"name": "groceries"})).get_json()
|
||||
target = await (await app_client.post("/api/labels", json={"name": "shopping"})).get_json()
|
||||
|
||||
resp = await app_client.post(f"/api/labels/{source['id']}/merge", json={"into": target["id"]})
|
||||
assert resp.status_code == 200
|
||||
|
||||
row = await _label_in_feed(app_client, source["id"])
|
||||
assert row is not None and row["purged_at"] is not None
|
||||
listing = (await (await app_client.get("/api/labels")).get_json())["labels"]
|
||||
assert [lb["id"] for lb in listing] == [target["id"]]
|
||||
|
||||
|
||||
async def test_a_tag_deleted_on_a_device_is_gone_from_the_web_and_its_name_is_free(app_client, db):
|
||||
await _signed_in(app_client, "devicedelete")
|
||||
lid = str(uuid.uuid4())
|
||||
assert (await _push_label(app_client, lid, "upsert", "2026-01-01T00:00:00Z"))["status"] == "created"
|
||||
assert (await _push_label(app_client, lid, "delete", "2026-01-02T00:00:00Z"))["status"] == "applied"
|
||||
|
||||
assert (await (await app_client.get("/api/labels")).get_json())["labels"] == []
|
||||
|
||||
# The web makes the name again: a NEW live tag, not the tombstone handed back.
|
||||
again = await app_client.post("/api/labels", json={"name": "Grocery"})
|
||||
assert again.status_code == 201
|
||||
assert (await again.get_json())["id"] != lid
|
||||
|
||||
# A body #tag finds that live one, not the tombstone.
|
||||
note = await (await app_client.post("/api/notes", json={"body": "milk #grocery"})).get_json()
|
||||
note = await (await app_client.get(f"/api/notes/{note['id']}")).get_json()
|
||||
assert [lb["id"] for lb in note["labels"]] == [(await again.get_json())["id"]]
|
||||
|
||||
# A picker naming the tombstone attaches nothing.
|
||||
await app_client.put(f"/api/notes/{note['id']}/labels", json={"label_ids": [lid]})
|
||||
labels = (await (await app_client.get(f"/api/notes/{note['id']}")).get_json())["labels"]
|
||||
assert lid not in [lb["id"] for lb in labels]
|
||||
|
||||
|
||||
async def test_a_device_can_make_a_deleted_tags_name_again(app_client, db):
|
||||
"""The tombstone gives its name back (0040), so another device's fresh tag of
|
||||
the same name is created rather than refused as "name in use"."""
|
||||
await _signed_in(app_client, "devicerename")
|
||||
old, new = str(uuid.uuid4()), str(uuid.uuid4())
|
||||
await _push_label(app_client, old, "upsert", "2026-01-01T00:00:00Z")
|
||||
await _push_label(app_client, old, "delete", "2026-01-02T00:00:00Z")
|
||||
|
||||
assert (await _push_label(app_client, new, "upsert", "2026-01-03T00:00:00Z"))["status"] == "created"
|
||||
listing = (await (await app_client.get("/api/labels")).get_json())["labels"]
|
||||
assert [lb["id"] for lb in listing] == [new]
|
||||
|
||||
|
||||
# --- One save path for a note's text (#5164) ---------------------------------
|
||||
#
|
||||
# A body edit is a sequence: keep a revision, rename the note, lift #tags, commit,
|
||||
|
||||
Reference in New Issue
Block a user