The core conflict-resolution step. Applies a batch of client changes,
additive + owner-scoped, with last-write-wins by client edit-time — and a
version-history snapshot on every overwrite so nothing is ever lost.
client_wins(client_edited_at, server_edited_at): apply iff client >= server;
a missing client time never overwrites a real server edit; a missing server
time (new row) yields. Notes compare against updated_at; labels gain an
updated_at (migration 0017, backfilled from created_at) as their LWW field.
Notes:
- upsert with a client-supplied id: create if absent, else LWW-apply the
full note state (title/body/color/kind/pins/trash/remind/position/items/
manual label_ids) with the same ripple as a web edit — derive_display_title,
_rewrite_links, _reconcile_tags (#tags), _rename_inbound_links. Overwriting
an existing title/body snapshots the old version into note_revisions first.
A resurrected tombstone clears purged_at.
- delete: purge tombstone (drop children + attachment files, clear content,
set purged_at), LWW-guarded so a newer server edit survives a stale delete.
Labels: upsert (create/rename/recolor) + delete (detach from notes, tombstone),
LWW-guarded; per-owner name-uniqueness clash on a different id is rejected
rather than raising.
Response: per-item {status: created|applied|kept|noop|rejected, sync_revision};
the client pulls afterward to converge. Whole-note semantics (client sends the
full state, not a partial patch).
Tests (DB-free): client_wins across all edit-time combinations; _parse_client_dt;
push auth-guard. Apply behavior + triggers operator-verified on deploy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
50 lines
2.4 KiB
Python
50 lines
2.4 KiB
Python
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import datetime
|
|
|
|
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Text, UniqueConstraint, func
|
|
from sqlalchemy.dialects.postgresql import UUID
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from . import Base
|
|
|
|
|
|
class Label(Base):
|
|
__tablename__ = "labels"
|
|
__table_args__ = (UniqueConstraint("owner_id", "name", name="uq_labels_owner_name"),)
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
owner_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
|
)
|
|
name: Mapped[str] = mapped_column(Text(), nullable=False)
|
|
color: Mapped[str] = mapped_column(Text(), nullable=False, default="default", server_default="default")
|
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
|
# Last-edit time — the LWW comparison field for sync (a rename/recolor bumps it).
|
|
# Sync-push overrides it with the winning client edit-time; web edits set server-now.
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
|
)
|
|
# Sync (M8): monotonic per-row revision (from sync_revision_seq via DB trigger) so a
|
|
# label rename/recolor/merge/delete propagates to native clients independently of notes.
|
|
sync_revision: Mapped[int | None] = mapped_column(BigInteger(), nullable=True)
|
|
# Hard-delete tombstone (content-less) so a deleted label is removed on clients.
|
|
purged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
|
|
|
|
class NoteLabel(Base):
|
|
"""Join between a note and a label (both owned by the same user)."""
|
|
|
|
__tablename__ = "note_labels"
|
|
|
|
note_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True), ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True
|
|
)
|
|
label_id: Mapped[uuid.UUID] = mapped_column(
|
|
UUID(as_uuid=True), ForeignKey("labels.id", ondelete="CASCADE"), primary_key=True
|
|
)
|
|
# True when this attachment came from a #tag in the note body (kept in sync with
|
|
# the body); False = added manually via the label picker (independent of #tags).
|
|
via_tag: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
|