Skip to content

Space item deletes don't propagate via sync, causing duplicates and resurrection #3059

@jamiepine

Description

@jamiepine

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:

  1. 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.
  2. 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:

  1. Device A created the library → create_default_space ran → 6 default items seeded in DB (deterministic UUIDs, no peer_log entries)
  2. Device B joins → create_default_space runs locally → same 6 items seeded with same deterministic UUIDs (upserts, no peer_log entries)
  3. Sync backfillget_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.
  4. 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

  1. Alice deletes item → row removed from Alice's DB, no peer_log entry written
  2. Bob's DB still has the item (never received the delete)
  3. On next get_full_shared_state() backfill or catch-up, Bob's query_for_sync scans the DB directly and returns the item
  4. Alice's apply_shared_change upserts it back — item reappears
  5. 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

  1. Set up two devices sharing a library via sync
  2. Observe the sidebar — default items (Overview, Recents, Favorites, etc.) appear duplicated
  3. Delete a duplicate on one device
  4. 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions