Skip to content

feat: Shopping list conflict resolution for offline/online sync#1019

Open
jmylchreest wants to merge 2 commits into
TomBursch:mainfrom
jmylchreest:feature/shopping-list-conflict-resolution
Open

feat: Shopping list conflict resolution for offline/online sync#1019
jmylchreest wants to merge 2 commits into
TomBursch:mainfrom
jmylchreest:feature/shopping-list-conflict-resolution

Conversation

@jmylchreest
Copy link
Copy Markdown
Contributor

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:

  • Remove items that were re-added by another client while the first was offline
  • Overwrite description changes made by other clients with stale values
  • Create duplicate entries or lose audit history

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's updated_at to determine whether the operation is stale.

Resolution Rules

Operation Item on list? Condition Action
Remove No No-op
Remove Yes No timestamp (legacy client) Accept (backwards compatible)
Remove Yes client_ts > updated_at Accept — delete item, History gets client_ts
Remove Yes client_ts ≤ updated_at Reject — item was modified after remove was queued
Update desc Yes No timestamp (legacy client) Accept (backwards compatible)
Update desc Yes client_ts > updated_at Accept — update desc, set updated_at = client_ts
Update desc Yes client_ts ≤ updated_at Reject — newer change already exists
Update desc No No timestamp (legacy client) Accept — upsert preserved
Update desc No History DROPPED created_at < client_ts Update History record's description
Update desc No History DROPPED created_at ≥ client_ts No-op
Add Already on list No-op (idempotent)
Add Not on list Always succeeds

Key Design Decisions

  • Fully backwards compatible: Clients that don't send client_timestamp get the existing behavior unchanged. No MIN_BACKEND_VERSION bump required.
  • Last-writer-wins with causal ordering: Uses client timestamps rather than server arrival time, so the operation that the user performed most recently wins regardless of network delays.
  • Input validation: Timestamps are validated (must be >0 and within 5 minutes of server time) to prevent abuse and clock skew issues.
  • Audit trail preserved: Upsert operations correctly create History.create_added() records. Removed item descriptions are preserved in History DROPPED records.

Changes

Backend

  • New: backend/app/util/transaction_resolver.py — Core resolver with resolve_remove(), resolve_update_description(), set_updated_at(), epoch_ms_to_datetime(), _ensure_aware() helper (handles SQLite naive datetime normalization)
  • Modified: backend/app/models/history.py — Added find_most_recent_dropped() classmethod for History lookup
  • Modified: backend/app/controller/shoppinglist/schemas.py — Added client_timestamp field with bounds validation, Meta: unknown = EXCLUDE on all schemas
  • Modified: backend/app/controller/shoppinglist/shoppinglist_controller.py — Integrated resolver into updateItemDescription() and removeShoppinglistItemFunc()
  • Modified: backend/app/config.pyBACKEND_VERSION 120 → 121

Frontend

  • Modified: kitchenowl/lib/services/api/shoppinglist.dartputItem() sends client_timestamp when provided
  • Modified: kitchenowl/lib/services/transactions/shoppinglist.dartTransactionShoppingListUpdateItem.runOnline() passes timestamp

Tests

  • New: backend/tests/api/test_api_shoppinglist_conflict.py — 25 integration tests covering all resolution paths, backwards compatibility, timestamp ordering, input validation, and edge cases

Testing

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 tests
  • TestBackwardsCompatibility — 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)
  • 1 bulk remove unknown-fields test

Known Limitations

  • TOCTOU race: The check-then-act pattern (read 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-level UPDATE ... WHERE updated_at < ? would eliminate this but requires broader architectural changes.
  • Add operations don't send timestamps: By design — adds are always idempotent and don't need conflict resolution.

@jmylchreest
Copy link
Copy Markdown
Contributor Author

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
@TomBursch TomBursch force-pushed the feature/shopping-list-conflict-resolution branch from ee755d6 to f478a93 Compare March 7, 2026 11:32
@TomBursch
Copy link
Copy Markdown
Owner

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.

Hi, first, thanks for the PR and the effort you've put into this!
Breaking changes are fine, I'll just bump up the required frontend/backend versions if necessary.
I'll do some testing and a thorough review soon.
One quick thing, please don't bump the backend version; I'll do that with the release.

@jmylchreest
Copy link
Copy Markdown
Contributor Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants