Skip to content

feat(merge): protect user data across re-merges — orphan audit + ID remap (refs #242) - #248

Merged
benders merged 2 commits into
mainfrom
feature/242-merge-hardening
Jul 11, 2026
Merged

feat(merge): protect user data across re-merges — orphan audit + ID remap (refs #242)#248
benders merged 2 commits into
mainfrom
feature/242-merge-hardening

Conversation

@benders

@benders benders commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Refs #242 (Phases 1–2; Phase 3 — worker-thread merge — deferred to a follow-up PR per owner decision, so this does not close the issue).

mergeLibraries() destructively rebuilds all unified_* tables, and unified IDs are deterministic metadata hashes — so any metadata edit or dedup-key change silently reshuffles IDs and strands user_stars / playlist_tracks / play_events rows. This PR makes user data survive every re-merge, which unblocks #158/#160/#13 (all change ID derivation and were unsafe to merge before this).

Commit Phase
823f803 1 — orphan audit: auditOrphans() counts + samples rows whose unified target vanished (stars by kind, playlist_tracks, play_events); runMergePipeline() orchestrator wraps every merge call site (sync.ts, auto-sync.ts ×2, admin peer-data reset); report persisted as JSON in new sync_operations.details column (additive migration).
c572b24 2 — ID remap: snapshotIdentity() before the rebuild, applyRemap() after — old→new pairs derived from stable per-instance identities (track_sources.instance_track_id, unified_release_sources.instance_album_id → release group, unified_artist_sources.instance_artist_id), applied set-based via a TEMP pairs table. Pipeline is snapshot → merge → remap → audit in one transaction.

Design notes

  • Plan correction found during review: the issue assumed playlist_tracks gets orphaned like the other two tables, but it had an ON DELETE CASCADE FK to unified_tracks — with foreign_keys = ON, every merge was deleting playlist rows outright (invisible today only because playlist endpoints are stubs). Phase 2 drops the FK via a table-recreate migration, aligning it with the documented no-FK / orphans-dropped-at-read convention of user_stars/play_events.
  • Swap-cycle safety: the first-cut user_stars remap (UPDATE OR IGNORE + delete-losers) lost both stars when remap pairs form a cycle — exactly what a tag fix that swaps two tracks' titles produces (A→B, B→A). Final implementation stages remapped rows in a TEMP table, deletes the touched rows, then INSERT OR IGNOREs the staged finals; regression test included. The plain UPDATEs on playlist_tracks/play_events are single-pass-safe and unchanged.
  • Splits (one old ID → several new IDs): majority-instance-row winner is remapped, ties broken lexicographically, remainder counted as splitsLogged. Collisions (two old IDs → one new ID): one star per user survives, losers counted as collisionsDropped.
  • merge.ts itself is untouched (PK-conflict logging preserved); merge.test.ts passes unmodified, proving unified table contents are unchanged.
  • New pitfalls row: never call mergeLibraries() directly outside tests — runMergePipeline() is the only sanctioned entry point.

Verification

  • pnpm verify green: hub 691/691 (58 files), frontend 108/108 (16 files), typecheck + lint:boundary clean.
  • pnpm lint zero findings in both hub/ and frontend/.
  • Federation compat suite: 84/84 passed against the Docker hub-a/b/c stack (merged-catalog shape is on the gate list).
  • New tests: orphan-audit.test.ts (6), id-remap.test.ts (6 — metadata edit, full-library key-shape change via salted generateTrackId, split, collision, swap-cycle, playlist survival), playlist-tracks-migration.test.ts (2 — old-shape DB migrates with rows preserved, fresh DB no-ops).

Acceptance criteria from #242 (Phases 1–2 scope)

  • ✅ A dedup-key change (simulated via salted generateTrackId) preserves 100% of stars/playlists/play_events.
  • ✅ Orphan counts visible in logs + sync_operations.details after every merge.
  • /rest/ping latency during large merges — Phase 3 (worker thread), follow-up PR.

🤖 Generated with Claude Code

https://claude.ai/code/session_011Q3riZ78G26ohMgPDh8F7u

claude added 2 commits July 10, 2026 22:46
…r (refs #242)

Phase 1 of #242. New auditOrphans() counts user_stars/playlist_tracks/
play_events rows whose unified target vanished in the destructive re-merge,
and runMergePipeline() wraps mergeLibraries()+audit in one transaction at
every merge call site (sync.ts, auto-sync.ts, admin peer-data reset).
Report persists as JSON in the new sync_operations.details column
(additive migration); non-zero totals log a warning. Report-only —
Phase 2 adds the ID remap that prevents the orphaning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Q3riZ78G26ohMgPDh8F7u
… (refs #242)

Phase 2 of #242. snapshotIdentity() captures instance-row -> unified-ID
mappings before the destructive rebuild; applyRemap() diffs them after and
set-based-UPDATEs user_stars / playlist_tracks / play_events onto the new
IDs, so metadata edits and dedup-key changes no longer strand user data.
Splits keep the majority-instance-row winner (splitsLogged); user_stars
collisions resolve via stage/delete/reinsert — swap-cycle-safe (a tag fix
that swaps two tracks' titles remaps both stars instead of deleting them).

Drops the ON DELETE CASCADE FK on playlist_tracks.unified_track_id
(table-recreate migration): it made every merge delete playlist rows
outright. The table now follows the same no-FK / orphans-dropped-at-read
convention as user_stars and play_events.

runMergePipeline is now snapshot -> merge -> remap -> audit in one
transaction and returns { orphans, remap }, both persisted to
sync_operations.details. New pitfalls row: never call mergeLibraries()
directly outside tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Q3riZ78G26ohMgPDh8F7u
@benders
benders marked this pull request as ready for review July 11, 2026 14:56
@benders
benders merged commit d704344 into main Jul 11, 2026
2 checks passed
@benders
benders deleted the feature/242-merge-hardening branch July 11, 2026 14:56
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