Sync 4: push endpoint POST /api/sync/push (LWW + history snapshot)
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build & push image (push) Successful in 47s

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
This commit is contained in:
2026-07-22 23:09:23 -04:00
co-authored by Claude Opus 4.8
parent 8e40ea1188
commit 68abaa0f3f
4 changed files with 353 additions and 4 deletions
+29
View File
@@ -0,0 +1,29 @@
"""labels.updated_at (M8 sync hub, step 4 — LWW field for the label catalog)
Revision ID: 0017
Revises: 0016
Create Date: 2026-07-23
Labels need a last-edit timestamp so sync push can resolve label rename/recolor
conflicts by last-write-wins (client edit-time vs this). Backfilled from created_at.
"""
from alembic import op
import sqlalchemy as sa
revision = "0017"
down_revision = "0016"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"labels",
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
# Seed existing rows' updated_at from their created_at (best available edit time).
op.execute("UPDATE labels SET updated_at = created_at")
def downgrade() -> None:
op.drop_column("labels", "updated_at")
+5
View File
@@ -21,6 +21,11 @@ class Label(Base):
name: Mapped[str] = mapped_column(Text(), nullable=False) name: Mapped[str] = mapped_column(Text(), nullable=False)
color: Mapped[str] = mapped_column(Text(), nullable=False, default="default", server_default="default") 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()) 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 # 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. # label rename/recolor/merge/delete propagates to native clients independently of notes.
sync_revision: Mapped[int | None] = mapped_column(BigInteger(), nullable=True) sync_revision: Mapped[int | None] = mapped_column(BigInteger(), nullable=True)
+283 -3
View File
@@ -11,19 +11,36 @@ from where it left off (since=0 = full initial sync).
""" """
from __future__ import annotations from __future__ import annotations
import uuid
from datetime import datetime, timezone
from quart import Blueprint, g, jsonify, request from quart import Blueprint, g, jsonify, request
from sqlalchemy import select from sqlalchemy import delete as sa_delete
from sqlalchemy import func, select
from .auth import login_required from .auth import login_required
from .config import Config
from .db import session_scope from .db import session_scope
from .models.label import Label from .models.label import Label, NoteLabel
from .models.note import Note from .models.note import Note
from .notes import _serialize_notes from .models.note_attachment import NoteAttachment
from .models.note_item import NoteItem
from .models.note_link import NoteLink
from .models.note_revision import NoteRevision
from .notes import (
_reconcile_tags,
_rename_inbound_links,
_rewrite_links,
_serialize_notes,
derive_display_title,
normalize_color,
)
bp = Blueprint("sync", __name__, url_prefix="/api/sync") bp = Blueprint("sync", __name__, url_prefix="/api/sync")
DEFAULT_LIMIT = 500 DEFAULT_LIMIT = 500
MAX_LIMIT = 1000 MAX_LIMIT = 1000
MAX_PUSH = 1000 # per-batch change cap
def _parse_since(raw: str | None) -> int: def _parse_since(raw: str | None) -> int:
@@ -120,3 +137,266 @@ async def changes():
"has_more": has_more, "has_more": has_more,
} }
) )
# --- Push: apply client mutations (LWW by client edit-time, non-destructive) ---
def _parse_client_dt(raw: object) -> datetime | None:
if not isinstance(raw, str) or not raw:
return None
try:
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
except ValueError:
return None
def client_wins(client_edited_at: datetime | None, server_edited_at: datetime | None) -> bool:
"""Last-write-wins: the client's version is applied iff its edit-time is at least
the server's. A missing client time never overwrites a real server edit; a missing
server time (new/unknown row) always yields to a present client edit."""
if client_edited_at is None:
return server_edited_at is None
if server_edited_at is None:
return True
return client_edited_at >= server_edited_at
def _assign_note_fields(note: Note, ch: dict) -> None:
"""Overwrite a note's scalar fields from a client's FULL-state change (sync is
whole-note, not a partial patch — the client sends its authoritative version)."""
title = ch.get("title")
note.title = (title or "").strip() or None if isinstance(title, str) else None
note.body = ch["body"] if isinstance(ch.get("body"), str) else ""
note.color = normalize_color(ch.get("color"))
note.kind = ch["kind"] if ch.get("kind") in ("text", "list") else "text"
note.pinned = bool(ch.get("pinned"))
note.archived = bool(ch.get("archived"))
if ch.get("trashed"):
if note.deleted_at is None:
note.deleted_at = datetime.now(timezone.utc)
else:
note.deleted_at = None
note.remind_at = _parse_client_dt(ch.get("remind_at"))
if isinstance(ch.get("position"), int):
note.position = ch["position"]
async def _apply_note_items(db, note: Note, ch: dict) -> None:
"""Replace the note's checklist items with the client's (items sync inline)."""
if note.kind != "list":
await db.execute(sa_delete(NoteItem).where(NoteItem.note_id == note.id))
return
items = ch.get("items")
if not isinstance(items, list):
return
await db.execute(sa_delete(NoteItem).where(NoteItem.note_id == note.id))
for pos, it in enumerate(items):
if not isinstance(it, dict):
continue
text = (it.get("text") or "").strip()
if text:
db.add(NoteItem(note_id=note.id, text=text, checked=bool(it.get("checked")), position=pos))
async def _apply_note_manual_labels(db, note: Note, ch: dict) -> None:
"""Set the note's MANUAL (picker) label memberships from client label_ids, leaving
tag-sourced (via_tag) rows to _reconcile_tags. Only labels the caller owns count."""
raw = ch.get("label_ids")
if not isinstance(raw, list):
return
wanted: set = set()
for r in raw:
try:
wanted.add(uuid.UUID(str(r)))
except (ValueError, TypeError):
continue
owned: set = set()
if wanted:
owned = set(
(await db.scalars(select(Label.id).where(Label.owner_id == g.user_id, Label.id.in_(wanted)))).all()
)
existing = (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()
attached = {r.label_id for r in existing}
for r in existing:
if not r.via_tag and r.label_id not in owned:
await db.delete(r)
for lid in owned:
if lid not in attached:
db.add(NoteLabel(note_id=note.id, label_id=lid, via_tag=False))
async def _purge_note(db, note: Note, edited_at: datetime | None) -> None:
"""Turn a note into a content-less tombstone: delete children (+ attachment files),
clear content, set purged_at. Kept so offline clients learn it's gone."""
atts = (await db.scalars(select(NoteAttachment).where(NoteAttachment.note_id == note.id))).all()
for a in atts:
try:
(Config.media_root() / a.path).unlink(missing_ok=True)
except OSError:
pass
await db.execute(sa_delete(NoteAttachment).where(NoteAttachment.note_id == note.id))
await db.execute(sa_delete(NoteItem).where(NoteItem.note_id == note.id))
await db.execute(sa_delete(NoteLabel).where(NoteLabel.note_id == note.id))
await db.execute(sa_delete(NoteLink).where(NoteLink.source_id == note.id))
note.title = None
note.body = ""
note.display_title = ""
note.deleted_at = None
note.remind_at = None
note.purged_at = datetime.now(timezone.utc)
if edited_at is not None:
note.updated_at = edited_at
async def _apply_note(db, ch: dict) -> dict:
raw_id = ch.get("id")
try:
nid = uuid.UUID(str(raw_id))
except (ValueError, TypeError):
return {"id": raw_id, "entity": "note", "status": "rejected", "error": "invalid id"}
op = ch.get("op", "upsert")
edited_at = _parse_client_dt(ch.get("edited_at"))
note = await db.scalar(select(Note).where(Note.id == nid))
if note is not None and note.owner_id != g.user_id:
return {"id": str(nid), "entity": "note", "status": "rejected", "error": "not yours"}
if op == "delete":
if note is None:
return {"id": str(nid), "entity": "note", "status": "noop"}
if not client_wins(edited_at, note.updated_at):
return {"id": str(nid), "entity": "note", "status": "kept", "sync_revision": note.sync_revision}
await _purge_note(db, note, edited_at)
await db.flush()
await db.refresh(note, ["sync_revision"])
return {"id": str(nid), "entity": "note", "status": "applied", "sync_revision": note.sync_revision}
creating = note is None
if creating:
note = Note(id=nid, owner_id=g.user_id, body="", display_title="")
created = _parse_client_dt(ch.get("created_at"))
if created is not None:
note.created_at = created
db.add(note)
elif not client_wins(edited_at, note.updated_at):
return {"id": str(nid), "entity": "note", "status": "kept", "sync_revision": note.sync_revision}
elif note.purged_at is not None:
note.purged_at = None # client re-created/edited → clear the tombstone
old_title, old_body, old_display = note.title, note.body, note.display_title
_assign_note_fields(note, ch)
note.display_title = derive_display_title(note.title, note.body)
if edited_at is not None:
note.updated_at = edited_at
# Non-destructive LWW: snapshot the overwritten server title+body into history.
if not creating and (note.title != old_title or note.body != old_body):
db.add(NoteRevision(note_id=note.id, title=old_title, body=old_body))
await db.flush() # assign note.id before items/labels/links
await _apply_note_items(db, note, ch)
await _rewrite_links(db, note)
await _reconcile_tags(db, note)
await _apply_note_manual_labels(db, note, ch)
new_display = note.display_title
if old_display and new_display and old_display.strip().lower() != new_display.strip().lower():
await _rename_inbound_links(db, note, old_display, new_display)
await db.flush()
await db.refresh(note, ["sync_revision"])
return {
"id": str(nid),
"entity": "note",
"status": "created" if creating else "applied",
"sync_revision": note.sync_revision,
}
async def _apply_label(db, ch: dict) -> dict:
raw_id = ch.get("id")
try:
lid = uuid.UUID(str(raw_id))
except (ValueError, TypeError):
return {"id": raw_id, "entity": "label", "status": "rejected", "error": "invalid id"}
op = ch.get("op", "upsert")
edited_at = _parse_client_dt(ch.get("edited_at"))
label = await db.scalar(select(Label).where(Label.id == lid))
if label is not None and label.owner_id != g.user_id:
return {"id": str(lid), "entity": "label", "status": "rejected", "error": "not yours"}
if op == "delete":
if label is None:
return {"id": str(lid), "entity": "label", "status": "noop"}
if not client_wins(edited_at, label.updated_at):
return {"id": str(lid), "entity": "label", "status": "kept", "sync_revision": label.sync_revision}
await db.execute(sa_delete(NoteLabel).where(NoteLabel.label_id == label.id))
label.purged_at = datetime.now(timezone.utc)
if edited_at is not None:
label.updated_at = edited_at
await db.flush()
await db.refresh(label, ["sync_revision"])
return {"id": str(lid), "entity": "label", "status": "applied", "sync_revision": label.sync_revision}
name = (ch.get("name") or "").strip()
creating = label is None
if not creating and not client_wins(edited_at, label.updated_at):
return {"id": str(lid), "entity": "label", "status": "kept", "sync_revision": label.sync_revision}
# Names are unique per owner — a same-name clash on a DIFFERENT id can't be an insert.
if name:
clash = await db.scalar(
select(Label.id).where(
Label.owner_id == g.user_id, func.lower(Label.name) == name.lower(), Label.id != lid
)
)
if clash is not None:
return {"id": str(lid), "entity": "label", "status": "rejected", "error": "name in use"}
if creating:
if not name:
return {"id": str(lid), "entity": "label", "status": "rejected", "error": "name required"}
label = Label(id=lid, owner_id=g.user_id, name=name, color=normalize_color(ch.get("color")))
db.add(label)
else:
if label.purged_at is not None:
label.purged_at = None
if name:
label.name = name
label.color = normalize_color(ch.get("color"))
if edited_at is not None:
label.updated_at = edited_at
await db.flush()
await db.refresh(label, ["sync_revision"])
return {
"id": str(lid),
"entity": "label",
"status": "created" if creating else "applied",
"sync_revision": label.sync_revision,
}
@bp.post("/push")
@login_required
async def push():
"""Apply a batch of client changes. Additive + owner-scoped; LWW by client
edit-time with a version-history snapshot on any overwrite (nothing is lost)."""
body = await request.get_json(silent=True) or {}
changes = body.get("changes")
if not isinstance(changes, list):
return jsonify({"error": "changes must be a list"}), 400
if len(changes) > MAX_PUSH:
return jsonify({"error": f"too many changes in one push (max {MAX_PUSH})"}), 400
results = []
async with session_scope() as db:
for ch in changes:
if not isinstance(ch, dict):
results.append({"status": "rejected", "error": "not an object"})
continue
entity = ch.get("entity")
if entity == "note":
results.append(await _apply_note(db, ch))
elif entity == "label":
results.append(await _apply_label(db, ch))
else:
results.append({"id": ch.get("id"), "status": "rejected", "error": "unknown entity"})
await db.commit()
return jsonify({"results": results})
+36 -1
View File
@@ -1,7 +1,17 @@
from datetime import datetime, timezone
import pytest import pytest
from thoughtsync.app import create_app from thoughtsync.app import create_app
from thoughtsync.sync import DEFAULT_LIMIT, MAX_LIMIT, _clamp_limit, _page_cursor, _parse_since from thoughtsync.sync import (
DEFAULT_LIMIT,
MAX_LIMIT,
_clamp_limit,
_page_cursor,
_parse_client_dt,
_parse_since,
client_wins,
)
@pytest.fixture @pytest.fixture
@@ -15,6 +25,31 @@ async def test_changes_requires_auth(app):
assert resp.status_code == 401 assert resp.status_code == 401
async def test_push_requires_auth(app):
client = app.test_client()
resp = await client.post("/api/sync/push", json={"changes": []})
assert resp.status_code == 401
def test_client_wins():
older = datetime(2026, 7, 20, tzinfo=timezone.utc)
newer = datetime(2026, 7, 22, tzinfo=timezone.utc)
assert client_wins(newer, older) is True # newer client edit wins
assert client_wins(older, newer) is False # older client edit loses (server kept)
assert client_wins(older, older) is True # tie → client applies (idempotent)
assert client_wins(None, older) is False # unknown client time can't overwrite a real edit
assert client_wins(older, None) is True # new/unknown server side yields
assert client_wins(None, None) is True
def test_parse_client_dt():
assert _parse_client_dt("2026-07-22T00:00:00Z").year == 2026
assert _parse_client_dt("2026-07-22T00:00:00+00:00").tzinfo is not None
assert _parse_client_dt("garbage") is None
assert _parse_client_dt(None) is None
assert _parse_client_dt(123) is None
def test_parse_since(): def test_parse_since():
assert _parse_since(None) == 0 assert _parse_since(None) == 0
assert _parse_since("42") == 42 assert _parse_since("42") == 42