Files
STTracker/docs/plans/001-phase1-sdd/reports/task-8-report.md
T
Lucas WintherandClaude Opus 5.5 be04fab098 docs: phase 1 handoff, execution ledger and task reports
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>
2026-11-02 04:53:02 +01:00

137 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
- `37a0b0b` feat(core): source record snapshots with stable hash and gzipped versions
- `1975e97` feat(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 of `Bun.gzipSync` of `{normalized, raw}`).
- `packages/core/src/sync/apply.ts`: `applyRecord` handles one non-listen record.
- Same hash: only `last_seen` moves.
- New record: `matchWork` in 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 its `engagement_sources` row. It writes an `added` `auto_applied` event.
- Changed record: diffs against the previous version, decides each field against `field_sources`, and applies or files pending `changed`/`conflict` events, never filing the same pending event twice (anti-flip-flop). It also handles `deleted: true` (pending `removed`) and a new `mergedIntoKey` (pending `merged`).
- Mapped records are never re-matched, and deleted targets are never revived.
- Also exports `markRemoved`, the field mappers, `finishedWithin24h` and `sameStartInstant`.
- `packages/core/src/sync/run.ts`: `runSync(deps, plugin, connectionId, mode)`, plus the `Connection`, `SyncDeps`, `SyncMode` and `RunSummary` types.
- Checks that the plugin matches the connection.
- Books the run in `source_runs` (status, counts, log, error) and updates the connection's `last_run_at` and `status`: `error` on failure, `error`→`active` on success.
- Batches listens (500 at a time).
- Marks absent records removed only after a `full` pull that finished with zero record errors.
- `packages/core/src/sync/changes.ts`:
- `listChanges`: status defaults to `pending`; filter by `connectionId`; pages newest-first by UUIDv7 id with `nextCursor`.
- `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`) that `adminMergeWorks` already writes.
- `packages/core/src/sync/listens.ts`: `ingestListens(db, userId, source, records, actor?)`. Insert-or-ignore on (user, source, source_key). Matches only through `mbids.recording`: an existing work, or a new shared `track` work with the `musicbrainz_recording` id, 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
- **`changed` payload `from`.** 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 `upsertEntry` re-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 `removed` event as `ignored`.
- **Events and counts.**
- Auto-applied field changes write one `changed` `auto_applied` event per field.
- `counts.changed` counts records whose hash changed (excluding deletions).
- `counts.removed` counts records marked removed, even unmapped ones, which get no event.
- **`ingestListens` actor.** It takes an optional 5th parameter, `actor`, for `work_history`. The default is `plugin:<source>:ingest`.
## Concerns (in docs/TODO.md)
1. `list` records (Trakt, Hardcover) are snapshotted but **not applied**. The brief gives no rules for them, so I did not invent any; this needs a ruling.
2. Accepting `removed` on an engagement de-duplicated across sources soft-deletes it even if another source still reports it.
3. Stale pending changed/conflict events for superseded values stay pending.
4. Concurrent runs of the same connection are not locked (Task 9).
5. `ingestListens` makes 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**
- `6efb97e` fix(core): batch listen ingestion per 500
- `0604c32` fix(core): re-point source records when a per-user move merges entries
- `a003c3e` fix(core): sync review round 1: lists, supersede, dedupe conflicts, retries
**Findings**
1. **Dedupe.**
- Empty fields that no source has set are filled.
- Every other incoming field goes through `decideFields` against the duplicate, so a disagreeing value files a `conflict` or a pending `changed`.
- 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.
2. **Merged-away records.** `markRemoved` sets `deleted_at` but neither counts nor files a removal when `merged_into_key` is set or a pending `merged` event exists. When a record is reported deleted, `merged_into_key` is kept so the merge stays visible.
3. **ID-less retries.** Right after `matchWork`, the `source_records` row is committed with `current_hash ""` and `target_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.
4. **Move-merges.** `moveEntry` (entries.ts) re-points `source_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 in `changes.ts` was removed.
5. **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.
6. **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`, description `Imported from <plugin id>`); items are matched outside the transaction and mapped through `target_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 pending `changed`; 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 calls `softDeleteList`.
8. **Multi-source removal.** Accepting `removed` on an engagement deletes this connection's `engagement_sources` row. The engagement is soft-deleted only when no source rows remain.
9. **Superseding.** A newer pending value for the same (record, field) resolves the older event as `ignored` with `payload.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 resolved `ignored` with `reason: "source_agrees"`.
**Minors**
- `Object.hasOwn(STATUS_FOR, action)`.
- Accept or keep_mine on a gone target resolves the event as `ignored` with `payload.reason = "target_gone"`. A deleted entry is followed through the user's work alias to a live survivor where one exists. `mergeTargetWork` also 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 `items` turns 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.