feat: Shopping list conflict resolution for offline/online sync#1019
feat: Shopping list conflict resolution for offline/online sync#1019jmylchreest wants to merge 2 commits into
Conversation
|
I should have mentioned, I kept this as a breaking change, but it would be possible to detect the version and only send the timestamp in the request conditionally form the client if desired. I figured this was cleaner. |
Backend transaction resolver evaluates each mutation (remove, update description, add) against current server state + timestamps to determine if a stale offline operation is still relevant. When an operation wins (client_timestamp > updated_at), updated_at is set to client_timestamp so subsequent stale operations are ordered correctly. Backend changes: - New transaction_resolver module with Resolution enum and per-op guards - History.find_most_recent_dropped() for update-description fallback - UpdateDescription schema gains optional client_timestamp field - Both UpdateDescription and RemoveItem schemas use Meta: unknown=EXCLUDE - BACKEND_VERSION bumped 120 -> 121 Frontend changes: - MIN_BACKEND_VERSION bumped 110 -> 121 (hard gate) - putItem() accepts optional timestamp, always sends client_timestamp - TransactionShoppingListUpdateItem passes timestamp to putItem() 16 new tests covering all resolution table cases, 0 regressions.
…ixes - Add epoch_ms bounds validation (rejects <=0 and >5min future timestamps) - Add History.create_added() call on upsert path to maintain audit trail - Add Meta: unknown=EXCLUDE to RemoveItems outer schema for backwards compat - Revert MIN_BACKEND_VERSION to 110 (less aggressive client gate) - Remove unused import and fix test assertions (400 not 422) - Add 8 timestamp validation tests and 1 bulk-remove unknown-fields test
ee755d6 to
f478a93
Compare
Hi, first, thanks for the PR and the effort you've put into this! |
|
No no, thank you for the awesome project! Taking another look at some of the tests theres probably some cleanup I can do there but please just give me any feedback and I'm happy to reflect it on my branch. There might be a better way, I've been learning the code-base as I go. |
Summary
Adds server-side conflict resolution for shopping list operations when clients sync after being offline. This prevents data loss and unexpected behavior when multiple clients (or the same client after reconnecting) send stale mutations that conflict with the current server state.
Problem
When a client goes offline, it queues shopping list operations (add, remove, update description) locally. Upon reconnecting, these operations are replayed against the server. Without conflict resolution, stale operations can:
Design
The client sends an optional
client_timestamp(epoch milliseconds) representing when the user originally performed the action. The server compares this against the item'supdated_atto determine whether the operation is stale.Resolution Rules
client_ts > updated_atclient_tsclient_ts ≤ updated_atclient_ts > updated_atupdated_at = client_tsclient_ts ≤ updated_atcreated_at < client_tscreated_at ≥ client_tsKey Design Decisions
client_timestampget the existing behavior unchanged. NoMIN_BACKEND_VERSIONbump required.History.create_added()records. Removed item descriptions are preserved in History DROPPED records.Changes
Backend
backend/app/util/transaction_resolver.py— Core resolver withresolve_remove(),resolve_update_description(),set_updated_at(),epoch_ms_to_datetime(),_ensure_aware()helper (handles SQLite naive datetime normalization)backend/app/models/history.py— Addedfind_most_recent_dropped()classmethod for History lookupbackend/app/controller/shoppinglist/schemas.py— Addedclient_timestampfield with bounds validation,Meta: unknown = EXCLUDEon all schemasbackend/app/controller/shoppinglist/shoppinglist_controller.py— Integrated resolver intoupdateItemDescription()andremoveShoppinglistItemFunc()backend/app/config.py—BACKEND_VERSION120 → 121Frontend
kitchenowl/lib/services/api/shoppinglist.dart—putItem()sendsclient_timestampwhen providedkitchenowl/lib/services/transactions/shoppinglist.dart—TransactionShoppingListUpdateItem.runOnline()passes timestampTests
backend/tests/api/test_api_shoppinglist_conflict.py— 25 integration tests covering all resolution paths, backwards compatibility, timestamp ordering, input validation, and edge casesTesting
All 107 backend tests pass (
uv run pytest). The new test file covers:TestRemoveConflictResolution— 4 tests (not-on-list noop, legacy accept, fresh accept, stale reject)TestUpdateDescriptionConflictResolution— 6 tests (legacy accept, fresh accept, stale reject, upsert variants)TestAddIdempotency— 2 testsTestBackwardsCompatibility— 3 tests (unknown fields excluded on all endpoints)TestTimestampOrdering— 2 tests (client timestamp propagation, remove ordering)TestTimestampValidation— 7 tests (zero, negative, far-future rejection; valid timestamps accepted)Known Limitations
updated_at, compare, then write) is not atomic. This is consistent with the existing codebase architecture and is low-risk given the single-household usage pattern. A database-levelUPDATE ... WHERE updated_at < ?would eliminate this but requires broader architectural changes.