Summary
When two devices share a library via sync, the default space items (Overview, Recents, Favorites, File Kinds, Sources, Redundancy) duplicate on every sync. Each device ends up with two copies of every default sidebar item. Deleting duplicates doesn't help — they resurrect on the next sync cycle.
Two distinct bugs interact:
- Duplication — Both devices independently seed default items via
create_default_space, AND the sync backfill sends the peer's copies on top. Despite deterministic UUIDs and upserts being in place, the items still double up after sync.
- Resurrection — Delete actions never write to the sync log, so deletions are purely local and get overwritten by the peer's still-present copies on the next backfill.
Bug 1: Default items duplicate when syncing between devices
What happens
When device B joins a library that device A already has:
- Device A created the library →
create_default_space ran → 6 default items seeded in DB (deterministic UUIDs, no peer_log entries)
- Device B joins →
create_default_space runs locally → same 6 items seeded with same deterministic UUIDs (upserts, no peer_log entries)
- Sync backfill —
get_full_shared_state() (core/src/service/sync/peer.rs:2746) scans device A's DB via Syncable::query_for_sync and sends all space_item records to B. apply_shared_change processes them on B.
- Result: duplicate items appear in the sidebar
Why deterministic UUIDs + upsert should prevent this but don't
create_default_space (core/src/library/manager.rs:1260) generates UUIDs via:
let item_uuid = deterministic_library_default_uuid(library_id, "space_item", item_name);
And both create_default_space and apply_shared_change upsert on UUID conflict:
Entity::insert(active)
.on_conflict(
OnConflict::column(Column::Uuid)
.update_columns([Column::GroupId, Column::ItemType, Column::Order])
.to_owned(),
)
.exec(db)
.await?;
This should collapse duplicates. The fact that it doesn't suggests one of:
- A UUID representation mismatch between the two paths (SeaORM Uuid BLOB storage on the direct insert path vs JSON-deserialized Uuid on the sync path — different binary representation would bypass the UNIQUE constraint)
- A race condition where
create_default_space and backfill apply_shared_change both run INSERT before either's ON CONFLICT fires
- The
space_id FK integer differs between the locally-seeded and sync-received copies, causing the upsert to match on UUID but leave stale references
create_default_space runs multiple times (e.g., on every library open, not just creation)
Needs investigation: dump the space_items table on an affected device to check whether duplicate rows have the same UUID (binary comparison issue) or different UUIDs (seeding issue).
Additional duplication vector: manual add + deterministic seed
Even if the deterministic upsert works correctly in isolation, a second duplication path exists:
spaces.add_item (core/src/ops/spaces/add_item/action.rs:83) uses Uuid::new_v4() — random UUIDs
- If a user manually added Sources/Redundancy via sidebar drag-drop before they became defaults, those items have random UUIDs and are broadcast via sync
- After upgrading,
create_default_space seeds the same items with deterministic UUIDs
- Result: two rows per item (random UUID from add_item + deterministic UUID from create_default_space)
add_item does not check for existing items of the same type
Bug 2: Delete actions don't propagate via sync
All three space delete actions perform a local DB delete but never call library.sync_model(_, ChangeType::Delete):
| Action |
File |
Line |
Calls sync_model? |
spaces.delete_item |
core/src/ops/spaces/delete_item/action.rs |
43 |
No |
spaces.delete_group |
core/src/ops/spaces/delete_group/action.rs |
44 |
No |
spaces.delete |
core/src/ops/spaces/delete/action.rs |
44 |
No |
spaces.reorder_items |
core/src/ops/spaces/reorder/action.rs |
98 |
No |
spaces.reorder_groups |
core/src/ops/spaces/reorder/action.rs |
47 |
No |
Compare to create/update actions which all correctly broadcast:
| Action |
File |
Line |
Calls sync_model? |
spaces.add_item |
core/src/ops/spaces/add_item/action.rs |
115 |
Yes (Insert) |
spaces.add_group |
core/src/ops/spaces/add_group/action.rs |
80 |
Yes (Insert) |
spaces.create |
core/src/ops/spaces/create/action.rs |
77 |
Yes (Insert) |
spaces.update |
core/src/ops/spaces/update/action.rs |
75 |
Yes (Update) |
spaces.update_group |
core/src/ops/spaces/update_group/action.rs |
63 |
Yes (Update) |
Resurrection mechanism
- Alice deletes item → row removed from Alice's DB, no peer_log entry written
- Bob's DB still has the item (never received the delete)
- On next
get_full_shared_state() backfill or catch-up, Bob's query_for_sync scans the DB directly and returns the item
- Alice's
apply_shared_change upserts it back — item reappears
- Repeat infinitely
create_default_space also bypasses sync
create_default_space does direct DB upserts without sync_model (core/src/library/manager.rs:1274). This means:
- Default items exist only as DB rows, never as peer_log entries
- They propagate via full-state DB scan (
query_for_sync) during backfill, not through the normal HLC-based shared change flow
- If one device deletes a default item, there is no delete entry in the log to supersede the peer's DB-scanned copy
Reproduction
- Set up two devices sharing a library via sync
- Observe the sidebar — default items (Overview, Recents, Favorites, etc.) appear duplicated
- Delete a duplicate on one device
- Wait for sync — the deleted item reappears
Proposed Fix
1. Fix delete + reorder actions to broadcast via sync
// In delete_item/action.rs, BEFORE item_model.delete(db):
library
.sync_model(&item_model, crate::infra::sync::ChangeType::Delete)
.await
.map_err(|e| ActionError::Internal(format!("Failed to sync delete: {}", e)))?;
item_model.delete(db).await.map_err(ActionError::SeaOrm)?;
Same for delete_group, delete (space), and both reorder actions (with ChangeType::Update).
2. Prevent duplicate default items
Options (pick one):
- Gate
create_default_space: Only run on fresh library creation, not on sync-join. Let the backfill deliver defaults from the source device instead.
- Deduplicate in
add_item: For singleton item types (Overview, Recents, etc.), use deterministic_library_default_uuid instead of new_v4(), or reject if an item of that type already exists in the space.
- Make
create_default_space sync-aware: Call sync_model(ChangeType::Insert) for seeded items so they enter the peer_log and participate in normal HLC-based conflict resolution.
3. Cleanup migration
One-shot migration to deduplicate existing rows: for each (space_id, item_type) combination with multiple rows, keep one (preferring the deterministic UUID) and delete the rest.
4. Investigate UUID representation
Confirm whether the UNIQUE constraint on space_items.uuid actually catches conflicts across both code paths (direct SeaORM insert vs apply_shared_change JSON-deserialized insert). If UUIDs are stored differently (e.g., BLOB vs TEXT), the constraint silently allows duplicates.
Affected Files
core/src/ops/spaces/delete_item/action.rs
core/src/ops/spaces/delete_group/action.rs
core/src/ops/spaces/delete/action.rs
core/src/ops/spaces/reorder/action.rs
core/src/ops/spaces/add_item/action.rs
core/src/library/manager.rs (create_default_space)
core/src/service/sync/peer.rs (get_full_shared_state)
core/src/infra/db/entities/space_item.rs (apply_shared_change)
Summary
When two devices share a library via sync, the default space items (Overview, Recents, Favorites, File Kinds, Sources, Redundancy) duplicate on every sync. Each device ends up with two copies of every default sidebar item. Deleting duplicates doesn't help — they resurrect on the next sync cycle.
Two distinct bugs interact:
create_default_space, AND the sync backfill sends the peer's copies on top. Despite deterministic UUIDs and upserts being in place, the items still double up after sync.Bug 1: Default items duplicate when syncing between devices
What happens
When device B joins a library that device A already has:
create_default_spaceran → 6 default items seeded in DB (deterministic UUIDs, no peer_log entries)create_default_spaceruns locally → same 6 items seeded with same deterministic UUIDs (upserts, no peer_log entries)get_full_shared_state()(core/src/service/sync/peer.rs:2746) scans device A's DB viaSyncable::query_for_syncand sends allspace_itemrecords to B.apply_shared_changeprocesses them on B.Why deterministic UUIDs + upsert should prevent this but don't
create_default_space(core/src/library/manager.rs:1260) generates UUIDs via:And both
create_default_spaceandapply_shared_changeupsert on UUID conflict:This should collapse duplicates. The fact that it doesn't suggests one of:
create_default_spaceand backfillapply_shared_changeboth run INSERT before either's ON CONFLICT firesspace_idFK integer differs between the locally-seeded and sync-received copies, causing the upsert to match on UUID but leave stale referencescreate_default_spaceruns multiple times (e.g., on every library open, not just creation)Needs investigation: dump the
space_itemstable on an affected device to check whether duplicate rows have the same UUID (binary comparison issue) or different UUIDs (seeding issue).Additional duplication vector: manual add + deterministic seed
Even if the deterministic upsert works correctly in isolation, a second duplication path exists:
spaces.add_item(core/src/ops/spaces/add_item/action.rs:83) usesUuid::new_v4()— random UUIDscreate_default_spaceseeds the same items with deterministic UUIDsadd_itemdoes not check for existing items of the same typeBug 2: Delete actions don't propagate via sync
All three space delete actions perform a local DB delete but never call
library.sync_model(_, ChangeType::Delete):sync_model?spaces.delete_itemcore/src/ops/spaces/delete_item/action.rsspaces.delete_groupcore/src/ops/spaces/delete_group/action.rsspaces.deletecore/src/ops/spaces/delete/action.rsspaces.reorder_itemscore/src/ops/spaces/reorder/action.rsspaces.reorder_groupscore/src/ops/spaces/reorder/action.rsCompare to create/update actions which all correctly broadcast:
sync_model?spaces.add_itemcore/src/ops/spaces/add_item/action.rsInsert)spaces.add_groupcore/src/ops/spaces/add_group/action.rsInsert)spaces.createcore/src/ops/spaces/create/action.rsInsert)spaces.updatecore/src/ops/spaces/update/action.rsUpdate)spaces.update_groupcore/src/ops/spaces/update_group/action.rsUpdate)Resurrection mechanism
get_full_shared_state()backfill or catch-up, Bob'squery_for_syncscans the DB directly and returns the itemapply_shared_changeupserts it back — item reappearscreate_default_spacealso bypasses synccreate_default_spacedoes direct DB upserts withoutsync_model(core/src/library/manager.rs:1274). This means:query_for_sync) during backfill, not through the normal HLC-based shared change flowReproduction
Proposed Fix
1. Fix delete + reorder actions to broadcast via sync
Same for
delete_group,delete(space), and both reorder actions (withChangeType::Update).2. Prevent duplicate default items
Options (pick one):
create_default_space: Only run on fresh library creation, not on sync-join. Let the backfill deliver defaults from the source device instead.add_item: For singleton item types (Overview, Recents, etc.), usedeterministic_library_default_uuidinstead ofnew_v4(), or reject if an item of that type already exists in the space.create_default_spacesync-aware: Callsync_model(ChangeType::Insert)for seeded items so they enter the peer_log and participate in normal HLC-based conflict resolution.3. Cleanup migration
One-shot migration to deduplicate existing rows: for each
(space_id, item_type)combination with multiple rows, keep one (preferring the deterministic UUID) and delete the rest.4. Investigate UUID representation
Confirm whether the UNIQUE constraint on
space_items.uuidactually catches conflicts across both code paths (direct SeaORM insert vsapply_shared_changeJSON-deserialized insert). If UUIDs are stored differently (e.g., BLOB vs TEXT), the constraint silently allows duplicates.Affected Files
core/src/ops/spaces/delete_item/action.rscore/src/ops/spaces/delete_group/action.rscore/src/ops/spaces/delete/action.rscore/src/ops/spaces/reorder/action.rscore/src/ops/spaces/add_item/action.rscore/src/library/manager.rs(create_default_space)core/src/service/sync/peer.rs(get_full_shared_state)core/src/infra/db/entities/space_item.rs(apply_shared_change)