Skip to content

refactor(sync): AbstractLwwUpserter + AbstractCursorReader + partial-update preservation#11

Draft
lchogan wants to merge 1 commit into
mainfrom
refactor/sync-upserter-base
Draft

refactor(sync): AbstractLwwUpserter + AbstractCursorReader + partial-update preservation#11
lchogan wants to merge 1 commit into
mainfrom
refactor/sync-upserter-base

Conversation

@lchogan

@lchogan lchogan commented Apr 22, 2026

Copy link
Copy Markdown
Owner

Summary

Cross-cutting follow-up to Phase 1 (merged in #9), addressing three concerns surfaced during code review:

  1. Partial-update preservation. $row['field'] ?? null no longer overwrites existing values when the client omits an optional key. A new preserve() helper on AbstractLwwUpserter returns the existing attribute value when a key is absent.
  2. TOCTOU on stale check. LWW existence-check + write is now wrapped in DB::transaction with lockForUpdate() on the target row. Two concurrent pushes of the same id can no longer both pass the stale guard.
  3. Duplication. Five LWW upserters (Topic, Deck, SubTopic, Card, Session) now extend AbstractLwwUpserter. Five readers (Topic, Deck, SubTopic, Card, Session) now extend AbstractCursorReader. Each subclass declares only: model class, ownership check (upserter) or scope predicate (reader), and field mapping / row projection.

Not migrated (different semantics, documented in their docblocks):

  • CardSubTopicUpserter — two-part ownership check is unique
  • ReviewUpserter — insert-only, no LWW update path
  • CardSubTopicReader — nested whereHas traversal
  • ReviewReader — append-only read with a different row shape

Held-open status

Per agreement with the user, this PR stays open as draft through Phase 2 and merges after Phase 2 is complete. New Phase 2 upserters should extend these base classes directly (see project_sync_upserter_refactor.md memory).

Test plan

  • Full backend suite: 58/58 Pest (+2 partial-update preservation tests vs phase/1)
  • PHPStan level 9 clean
  • Pint clean
  • Re-run CI on this branch

Supersedes stale #10 (auto-closed when phase/1-data-sync was deleted after merge).

🤖 Generated with Claude Code

…update preservation

Addresses three cross-cutting concerns surfaced in Tasks 1.7-1.14 code review:

1. Partial-update preservation: preserve() helper returns the existing
   model attribute when a key is absent from the push payload, so an
   omitted optional field (e.g. color_hint, description, ended_at_ms) no
   longer overwrites the stored value with null.

2. TOCTOU on stale LWW check: existence-check-and-write is now wrapped in
   DB::transaction with lockForUpdate(), serialising concurrent pushes of
   the same id so LWW semantics are guaranteed at the database level.

3. Rule-of-four duplication: AbstractLwwUpserter (template-method for
   upsert guard/write) and AbstractCursorReader (template-method for
   cursor-paginated read) eliminate the identical scaffolding repeated
   across 5 upserters and 5 readers. Subclasses now declare only
   modelClass(), checkOwnership()/scopeForUser(), and applyFields()/
   projectRow(). CardSubTopicUpserter, ReviewUpserter, CardSubTopicReader,
   and ReviewReader are intentionally left standalone; each has a docblock
   explaining why (two-part ownership / insert-only / nested-whereHas).

Tests: 56 → 58 (+2 partial-update preservation tests for Deck.description
and Topic.color_hint). PHPStan level-9 clean. Pint clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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.

1 participant