tags: renaming onto an existing tag merges them, and the older row survives
Android / Build, or is the channel already serving this? (push) Successful in 4s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m35s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m46s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 5m37s

The three surfaces did not agree on what renaming a tag onto a name another
one already holds should do, and none of the three answers was good.

I described this wrongly first time and the correction matters. The local
store does NOT silently create a duplicate: `idx_labels_name` is unique on
`lower(name)`, so the bare UPDATE in `rename_label` failed, and the user got
a raw SQLite "UNIQUE constraint failed" as their error message. The server
meanwhile answered 409 "a tag with that name already exists" — and only on
an EXACT match, because its constraint is on the raw name while every
client's index is on `lower(name)`.

That last part is the sharper bug. The server would happily hold "Groceries"
beside "groceries"; no synced client can store both. Creating that pair on
the web armed a pull that fails later, on a phone, in a path with no UI.

Operator's call: a rename onto an existing name means merge — typing an
existing tag's name onto this one says they are the same thing.

  * `store::rename_label` and the server's PATCH now implement one rule.
    THE OLDER ROW SURVIVES and takes the new spelling. Age rather than "the
    one that already held the name", so that renaming A→B and B→A land on
    the same survivor; otherwise the outcome depends on which way round
    someone typed it, and two devices tidying the same pair disagree about
    which id still exists. Ties go to the incumbent, so it stays
    deterministic.

  * The core reuses `merge_labels` rather than reimplementing the move. That
    is the only place that knows to mark every affected NOTE dirty before
    the delete cascades the membership rows away, which is what makes a
    merge reach the server at all.

  * The server's rename and its `/merge` route now share one `_merge_into`
    helper, for the same reason.

  * Both server lookups became case-INSENSITIVE, matching every client. The
    create path is included: it was the one actually minting the unstorable
    pair, so fixing only the rename would have left the door open.

  * The web asks before merging, naming both note counts. A merge cannot be
    undone by repeating it and is now reachable by a typo in a text field —
    the same reasoning as the delete confirmation in #2116. The confirmation
    lives in the shared store, so the desktop gets it too; the FFI does not
    ask, because that belongs to the surface with a person in front of it.

  * The web store detects the merge from the LIST, not the response: the
    survivor may be the row we asked to rename, so an unchanged id proves
    nothing.

Tests: three integration tests over a real database (both rename directions
land on the older row; a case-varied create returns the existing tag) and
two through the Android FFI, which is the binding the phone will use.

Also fixes a straggler from 8c7553d — the delete confirmation still said
"the label".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3MMqUtzX1TJgA1oypvm1c
This commit is contained in:
2026-08-31 16:46:15 -04:00
co-authored by Claude Opus 5
parent 8c7553d619
commit 193dfb9e94
5 changed files with 307 additions and 32 deletions
+60 -21
View File
@@ -27,6 +27,36 @@ async def _label_note_count(db, label_id) -> int:
)
async def _merge_into(db, source: Label, target: Label) -> None:
"""Move every note tagged with `source` onto `target`, then delete `source`.
Shared by the explicit `/merge` route and by a rename that lands on a name
another tag already holds — those are the same operation, and having one body
is what stops them drifting into two answers for one question.
Note-body `#tags` are NOT rewritten, so a note whose body still literally
contains the source `#tag` will re-mint that tag on its next edit. Retiring a
tag means editing it out of the text; a known, documented nuance.
"""
# Notes already carrying the target: a note can't hold the same label twice
# (composite PK), so the source attachment there is just dropped as a dup.
target_notes = set(
(await db.scalars(select(NoteLabel.note_id).where(NoteLabel.label_id == target.id))).all()
)
source_rows = (await db.scalars(select(NoteLabel).where(NoteLabel.label_id == source.id))).all()
by_note = {r.note_id: r.via_tag for r in source_rows}
# Delete the source attachments first, then re-insert under the target — moving
# by delete+insert avoids mutating a composite primary-key column in place.
for row in source_rows:
await db.delete(row)
await db.flush()
for note_id, via_tag in by_note.items():
if note_id not in target_notes:
db.add(NoteLabel(note_id=note_id, label_id=target.id, via_tag=via_tag))
await db.delete(source)
await db.flush()
async def _get_owned_label(db, label_id: str) -> Label | None:
lid = parse_uuid(label_id)
if lid is None:
@@ -60,8 +90,13 @@ async def create_label():
if not name:
return json_error("tag name is required", 400)
async with session_scope() as db:
# Idempotent: creating an existing label just returns it.
existing = await db.scalar(select(Label).where(Label.owner_id == g.user_id, Label.name == name))
# Idempotent: creating an existing tag just returns it. Case-INSENSITIVE,
# like the clients' `find_or_create_label` — a case-sensitive match here was
# the path that MINTED the "Groceries" beside "groceries" pair that no synced
# client can hold, since their `labels` index is unique on `lower(name)`.
existing = await db.scalar(
select(Label).where(Label.owner_id == g.user_id, func.lower(Label.name) == name.lower())
)
if existing is not None:
return jsonify(_serialize_label(existing)), 200
label = Label(owner_id=g.user_id, name=name, color=normalize_color(data.get("color")))
@@ -87,11 +122,31 @@ async def update_label(label_id: str):
if label is None:
return not_found()
if has_name:
# Case-INSENSITIVE, matching the clients' `find_or_create_label` and the
# local store's `lower(name)` unique index. The Postgres constraint here
# is on the raw name, so the database would happily hold "Groceries"
# beside "groceries" — but no synced client can store both, so letting
# one be made is letting a pull fail later on a phone.
clash = await db.scalar(
select(Label).where(Label.owner_id == g.user_id, Label.name == name, Label.id != label.id)
select(Label).where(
Label.owner_id == g.user_id,
func.lower(Label.name) == name.lower(),
Label.id != label.id,
)
)
if clash is not None:
return json_error("a tag with that name already exists", 409)
# Renaming onto an existing tag MERGES the two rather than failing:
# typing an existing tag's name onto this one says they are the same
# thing. The OLDER row survives and takes the new spelling — age is
# the one property that does not depend on which of the two the
# caller happened to be renaming, so A→B and B→A agree. Ties go to
# the incumbent. Mirrors `store::rename_label` exactly.
if clash.created_at <= label.created_at:
survivor, doomed = clash, label
else:
survivor, doomed = label, clash
await _merge_into(db, doomed, survivor)
label = survivor
label.name = name
if has_color:
label.color = normalize_color(data.get("color"))
@@ -129,23 +184,7 @@ async def merge_label(label_id: str):
return not_found()
if source.id == target.id:
return json_error("cannot merge a tag into itself", 400)
# Notes already carrying the target: a note can't hold the same label twice
# (composite PK), so the source attachment there is just dropped as a dup.
target_notes = set(
(await db.scalars(select(NoteLabel.note_id).where(NoteLabel.label_id == target.id))).all()
)
source_rows = (await db.scalars(select(NoteLabel).where(NoteLabel.label_id == source.id))).all()
by_note = {r.note_id: r.via_tag for r in source_rows}
# Delete the source attachments first, then re-insert under the target — moving
# by delete+insert avoids mutating a composite primary-key column in place.
for row in source_rows:
await db.delete(row)
await db.flush()
for note_id, via_tag in by_note.items():
if note_id not in target_notes:
db.add(NoteLabel(note_id=note_id, label_id=target.id, via_tag=via_tag))
await db.delete(source)
await db.flush()
await _merge_into(db, source, target)
count = await _label_note_count(db, target.id)
await db.commit()
return jsonify(_serialize_label(target, count))