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:
@@ -0,0 +1,41 @@
|
||||
"""labels: a tag's name is unique among LIVE tags only
|
||||
|
||||
Revision ID: 0040
|
||||
Revises: 0039
|
||||
Create Date: 2026-10-08
|
||||
|
||||
Deleting a tag now leaves a tombstone row (`purged_at` set) so the change feed can
|
||||
tell linked devices about it (#5382). Before, the web deleted the row outright and
|
||||
devices never heard. A tombstone must not hold its name: creating `#grocery` again
|
||||
after deleting it has to make a live tag, on the web and from a device alike. So
|
||||
`(owner_id, name)` stays unique only where `purged_at IS NULL`.
|
||||
|
||||
## Downgrade
|
||||
|
||||
Restores the plain unique constraint. Tombstones are deleted first, since one may
|
||||
share its name with a live tag; devices that have not pulled since keep the tag.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0040"
|
||||
down_revision = "0039"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_constraint("uq_labels_owner_name", "labels", type_="unique")
|
||||
op.create_index(
|
||||
"uq_labels_owner_name_live",
|
||||
"labels",
|
||||
["owner_id", "name"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("purged_at IS NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("uq_labels_owner_name_live", table_name="labels")
|
||||
op.execute("DELETE FROM labels WHERE purged_at IS NOT NULL")
|
||||
op.create_unique_constraint("uq_labels_owner_name", "labels", ["owner_id", "name"])
|
||||
Reference in New Issue
Block a user