Captures the owner's requirements and decisions from the planning conversation, current branch/worktree state, open tasks 17-19, pending owner decisions and the controller's rulings so a new agent can resume. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
12 KiB
Task 8 report: sync engine, snapshots, diff, apply, Changes inbox
Status: DONE_WITH_CONCERNS (the concerns are scope gaps, listed below; no known defects)
Branch: task/8 (worktree /home/user/STTracker/.worktrees/t8, based on feat/phase1 33631ae)
Commits
37a0b0bfeat(core): source record snapshots with stable hash and gzipped versions1975e97feat(core): sync engine and changes inbox (includes the docs)
What was built
packages/core/src/sync/snapshot.ts:normalizeRecord,stableStringify,sameValue,hashRecord(sha256 of stable JSON of{kind, work, entry|engagement|listen|list, deleted, mergedIntoKey}, raw excluded),encodeVersion/decodeVersion(base64 ofBun.gzipSyncof{normalized, raw}).packages/core/src/sync/apply.ts:applyRecordhandles one non-listen record.- Same hash: only
last_seenmoves. - New record:
matchWorkin its own transaction, then one apply transaction. It creates or patches the entry (with a root-ancestor entry), or ensures the entry and then de-duplicates or adds the engagement plus itsengagement_sourcesrow. It writes anaddedauto_appliedevent. - Changed record: diffs against the previous version, decides each field against
field_sources, and applies or files pendingchanged/conflictevents, never filing the same pending event twice (anti-flip-flop). It also handlesdeleted: true(pendingremoved) and a newmergedIntoKey(pendingmerged). - Mapped records are never re-matched, and deleted targets are never revived.
- Also exports
markRemoved, the field mappers,finishedWithin24handsameStartInstant.
- Same hash: only
packages/core/src/sync/run.ts:runSync(deps, plugin, connectionId, mode), plus theConnection,SyncDeps,SyncModeandRunSummarytypes.- Checks that the plugin matches the connection.
- Books the run in
source_runs(status, counts, log, error) and updates the connection'slast_run_atandstatus:erroron failure,error→activeon success. - Batches listens (500 at a time).
- Marks absent records removed only after a
fullpull that finished with zero record errors.
packages/core/src/sync/changes.ts:listChanges: status defaults topending; filter byconnectionId; pages newest-first by UUIDv7 id withnextCursor.resolveChange: claims the event with a conditional update. It handles accept, keep_mine and ignore for changed/conflict/removed/merged events, including the admin-merge events (toWorkId) thatadminMergeWorksalready writes.
packages/core/src/sync/listens.ts:ingestListens(db, userId, source, records, actor?). Insert-or-ignore on (user, source, source_key). Matches only throughmbids.recording: an existing work, or a new sharedtrackwork with themusicbrainz_recordingid, retried on a unique-key race. It never renames an existing work.- Docs:
docs/ARCHITECTURE.md: core map and the real sync data flow.docs/DECISIONS.md: new entry at the top covering hash composition, the per-field decision, anti-flip-flop, no-revive, root ancestor, never re-matching, dedupe, removal and merge accept.docs/TODO.md: 5 known gaps.
Files changed
packages/core/src/sync/{snapshot,apply,run,changes,listens}.ts; packages/core/test/sync/{helpers.ts,run.test.ts,listens.test.ts,snapshot.test.ts}; docs/{ARCHITECTURE,DECISIONS,TODO}.md.
TDD evidence
RED (before any src/sync existed): bun test packages/core/test/sync
error: Cannot find module '../../src/sync/listens.ts' ...
0 pass
3 fail
3 errors
GREEN, SQLite: bun test packages/core/test/sync → 20 pass, 0 fail.
GREEN, both DBs: TEST_POSTGRES_URL=postgres://postgres:postgres@127.0.0.1:55432/postgres bun test packages/core/test/sync → 37 pass, 0 fail (17 DB tests × 2 dialects + 3 pure snapshot tests).
Full suite: SQLite 230 pass, 0 fail; both DBs 347 pass, 0 fail.
The implementation passed on its first run, so I checked the tests by mutation. I broke each rule in turn, re-ran, and restored the code:
| Mutation | Test that failed |
|---|---|
| apply to "user"-owned fields | user-edited rating; two connections disagree |
| drop the anti-flip-flop check | two connections disagree |
| remove after a failed pull | failed pull marks nothing removed |
| disable dedupe | same play from two sources |
| skip the same-hash short-circuit | second identical pull |
| revive a deleted entry on a new record | nothing at first; I extended the no-revive test (new entry + engagement records on a deleted entry's work), and it now fails under this mutation |
Tests cover all mandatory Review-focus cases plus: root-ancestor entry (including an existing deleted root left untouched); a changes pull never removing by absence; deleted: true → removed, then ignore; no revive; an id-less (CSV) record updated through its mapping with no new draft; resolveChange scoped to the user and rejecting non-pending events; listChanges paging and connection filter; plugin/connection mismatch refused; listens through runSync (counted, not snapshotted, never removed).
Typecheck
bun run typecheck → $ tsc --noEmit -p tsconfig.json with no errors.
Choices worth reviewing
changedpayloadfrom. It is the current STTracker value (what accept replaces), not the previous source value.- Version payload. It stores
{normalized, raw}so the next change can be diffed field by field. The schema has no other place for the previous normalised record. - A new record on an existing live entry. It goes through the same per-field decision. Otherwise a first sync of a new connection would overwrite the user's fields, because
upsertEntryre-stamps every key it is sent. - Dedupe.
- Day-precision dates match when ≤ 1 day apart; two datetimes match when ≤ 24 h apart as instants.
- It never matches an engagement already sourced by the same connection.
- On a match, it fills only fields that are empty and that no source has set.
- Record errors. Any record error (for example, an invalid rating) fails the run and skips removal; the other records are still applied. A persistently bad record therefore keeps the connection in
error. That is visible, but strict. - Reappearance. A removed record that comes back retires its still-pending
removedevent asignored. - Events and counts.
- Auto-applied field changes write one
changedauto_appliedevent per field. counts.changedcounts records whose hash changed (excluding deletions).counts.removedcounts records marked removed, even unmapped ones, which get no event.
- Auto-applied field changes write one
ingestListensactor. It takes an optional 5th parameter,actor, forwork_history. The default isplugin:<source>:ingest.
Concerns (in docs/TODO.md)
listrecords (Trakt, Hardcover) are snapshotted but not applied. The brief gives no rules for them, so I did not invent any; this needs a ruling.- Accepting
removedon an engagement de-duplicated across sources soft-deletes it even if another source still reports it. - Stale pending changed/conflict events for superseded values stay pending.
- Concurrent runs of the same connection are not locked (Task 9).
ingestListensmakes one lookup and one insert per listen.
Fix round 1
Merge. git merge feat/phase1 produced conflicts only in docs: ARCHITECTURE (the core map row), DECISIONS (both new entries kept, sync entry first) and TODO (both kept; my now-fixed lines dropped). Committed as b2e6e19.
Commits
6efb97efix(core): batch listen ingestion per 5000604c32fix(core): re-point source records when a per-user move merges entriesa003c3efix(core): sync review round 1: lists, supersede, dedupe conflicts, retries
Findings
- Dedupe.
- Empty fields that no source has set are filled.
- Every other incoming field goes through
decideFieldsagainst the duplicate, so a disagreeing value files aconflictor a pendingchanged. - Exception: the date that identified the play (
matchedOn) is skipped, because its difference is within the dedupe tolerance by definition. Otherwise every Trakt-datetime vs CSV-date pair would conflict.
- Merged-away records.
markRemovedsetsdeleted_atbut neither counts nor files a removal whenmerged_into_keyis set or a pendingmergedevent exists. When a record is reported deleted,merged_into_keyis kept so the merge stays visible. - ID-less retries. Right after
matchWork, thesource_recordsrow is committed withcurrent_hash ""andtarget_entity "work"/target_id = workId. A retry sees the pending hash and reuses that work. List items without IDs first reuse the user's own live private work with the same media type and title. - Move-merges.
moveEntry(entries.ts) re-pointssource_records(target_entity "entry") from the merged-away entry to the survivor in the same transaction. Engagements keep their ids when re-parented. The now-redundant re-point inchanges.tswas removed. - keep_mine test. After keep_mine, reruns file nothing. A new value from B (65) gives a pending
changed {from: 80, to: 65}with no new conflict, and the rating stays 80. - Listens. Per batch of 500: one
source_key IN (…)lookup, one resolve per distinct recording (outside the transaction), then one transaction with a multi-row insert-or-ignore. The per-row pre-check is gone. The test ingests 1200 listens plus an in-call duplicate, then re-ingests idempotently.
Controller rulings 7. Lists.
- A new record creates a list (kind
list, descriptionImported from <plugin id>); items are matched outside the transaction and mapped throughtarget_entity "list". - On change, name and description go through the per-field rules. Ownership comes from
user_history: name and description belong to whoever last changed them, and items belong to"user"once any user edit exists. A user-owned field gets a pendingchanged; items carry the whole incoming item set. - Otherwise items are replaced with the new
lists.replaceListItems(one history row). That is a small addition to lists.ts. - A removed record gets a pending
removed; accepting it callssoftDeleteList.
- Multi-source removal. Accepting
removedon an engagement deletes this connection'sengagement_sourcesrow. The engagement is soft-deleted only when no source rows remain. - Superseding. A newer pending value for the same (record, field) resolves the older event as
ignoredwithpayload.supersededBy = <new id>. The same value is still never filed twice. One addition: if the source comes round to the current STTracker value, its pending events for that field are resolvedignoredwithreason: "source_agrees".
Minors
Object.hasOwn(STATUS_FOR, action).- Accept or keep_mine on a gone target resolves the event as
ignoredwithpayload.reason = "target_gone". A deleted entry is followed through the user's work alias to a live survivor where one exists.mergeTargetWorkalso accepts a pending-hash record (target_entity "work"). - Counts:
committed()gathers each record's (and each removal's) counts and log in scratch state and adds them to the run only after commit. - New test: a record-level error fails the run, skips removal, and does not count the rolled-back record.
TDD evidence
- RED: the new and changed tests against the pre-fix
src/(git stash push -- packages/core/src) gave 9 fails: conflict/keep_mine/supersede, dedupe conflict, merged-away no removal, ID-less retry, record-error counts, move-merge, multi-source removal, target_gone, lists. - The 1200-listen test passed on the old code too: it guards correctness, and the change is the query shape.
- GREEN:
bun test packages/core: 142 pass, 0 fail.- The same with
TEST_POSTGRES_URL=…: 273 pass, 0 fail. - Whole repo
bun test: 251 pass, 0 fail. bun run typecheck: clean.
Docs
- DECISIONS: the sync entry was updated for superseding, committed matches and move re-pointing, dedupe conflicts, merged/multi-source removal, counts, listen batching, lists and gone targets.
- ARCHITECTURE: data-flow step 3.
- TODO: the remaining sync item is the per-connection run lock (Task 9).
Remaining concerns
- Ownership of list
itemsturns to "user" after any user edit, including a rename. That follows the ruling literally. - Superseded and "source_agrees" retirements change event status automatically, but never touch user data.