Sync 4: push endpoint POST /api/sync/push (LWW + history snapshot)
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:
+36
-1
@@ -1,7 +1,17 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
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
|
||||
@@ -15,6 +25,31 @@ async def test_changes_requires_auth(app):
|
||||
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():
|
||||
assert _parse_since(None) == 0
|
||||
assert _parse_since("42") == 42
|
||||
|
||||
Reference in New Issue
Block a user