[Bug] Descendant subcollections remain stuck in deletedCollections (with synced=1) after parent coll

Summary

When a parent collection containing child subcollections is moved to Trash (collection.deleted = true) and subsequently restored or overwritten by sync (deleted = false on the parent collection), all descendant subcollections remain permanently stranded in the local deletedCollections table with collections.synced = 1.

Because collections.synced is never set to 0 on the descendant collections inside Zotero.Collection.prototype.trash(), and restoring/syncing the parent collection only runs DELETE FROM deletedCollections WHERE collectionID=? on the parent ID itself: 1. Local Zotero hides all descendant subcollections in the UI (treating them as trashed). 2. Local Zotero does not push deleted: true for the descendants to the Zotero server because their synced column in collections is still 1. 3. Clicking the Sync button performs an incremental sync (since=<local_library_version>). Since the descendant collections remain intact and unmodified on the server with older version numbers (< local_library_version), the server never re-sends them. 4. Consequently, the subcollections exist intact on the cloud server, but permanently disappear from the local collection tree on the desktop client regardless of how many times the user clicks Sync.


Root Cause in Source Code (chrome/content/zotero/xpcom/data/collection.js)

  1. Asymmetric cascading between _saveTx() and trash() (collection.js#L348-L360 and collection.js#L600-L685):

    • When this._changedData.deleted is true, _saveTx() calls await this.trash({ ...env, isNew: isNew }): javascript if (this._changedData.deleted !== undefined) { if (this._changedData.deleted) { await this.trash({ ...env, isNew: isNew }); } else { let sql = "DELETE FROM deletedCollections WHERE collectionID=?"; await Zotero.DB.queryAsync(sql, collectionID); }
    • Inside Zotero.Collection.prototype.trash(), Zotero gathers this.id plus all descendant collection IDs (this.getDescendents(false, null, false)) and inserts all of them directly into deletedCollections via raw SQL (collection.js#L665-L673): javascript await Zotero.Utilities.Internal.forEachChunkAsync( collections, Zotero.DB.MAX_BOUND_PARAMETERS, async function (chunk) { // Send collection to trash var placeholders = chunk.map(() => '(?)').join(','); await Zotero.DB.queryAsync('INSERT OR IGNORE INTO deletedCollections (collectionID) VALUES ' + placeholders, chunk); } );
  2. Two state-consistency flaws occur here:

    • Flaw A (synced flag not dirtied on descendants): this.trash() inserts descendant collectionIDs directly into deletedCollections without updating collections.synced = 0 or clientDateModified on the descendant rows in collections (only this parent collection goes through _saveTx()). Thus, in SQLite:
      • deletedCollections has rows for all 43 child collections (dateDeleted = '2026-10-30 01:23:13').
      • collections still has synced = 1 for all 43 child collections.
    • Flaw B (Un-trashing / remote sync of parent does not clean up or reconcile descendants): When the parent collection is subsequently restored or updated by sync (deleted = false at 2026-10-30 01:26:12, bumping the parent collection version to 13179), _saveTx() only executes DELETE FROM deletedCollections WHERE collectionID=? for the parent collection (ID=51), leaving all 43 child collections stranded in deletedCollections with synced = 1 and versions 8348..12118.

Observed Database Evidence (Zotero 7 / Windows)

On a client synced to library version 13966: - Cloud API (GET /users/<id>/collections): - Total active collections: 69 - Parent collection (version=13179) has meta.numCollections = 43. - Local SQLite (zotero.sqlite): - libraries: version = 13966, storageVersion = 13966, Unsynced items = 0, syncDeleteLog = 0 rows. - collections: 69 rows total, all 69 rows have synced = 1. - deletedCollections: 43 rows (all 43 child collections of collectionID = 51), all with dateDeleted = '2026-10-30 01:23:13', while parent collectionID = 51 has clientDateModified = '2026-10-30 01:26:12', version = 13179, and is not in deletedCollections. - All collectionItems mappings for those 43 child collections remained intact in SQLite, and newly synced items continued to update collectionItems for those collection IDs in the background, even though the 43 collections were hidden in the UI. - Note: If the user clicks Empty Trash locally in this state, it permanently erases all 43 subcollections and pushes their deletion to syncDeleteLog, wiping them from the cloud library as well.


Workaround & Suggested Fix

  • Local Workaround: Closing Zotero and deleting the orphaned rows from deletedCollections (DELETE FROM deletedCollections WHERE collectionID IN (...)) immediately restored all 43 subcollections and their items without side effects.
  • Suggested Fix in Zotero:
    1. In Zotero.Collection.prototype.trash(), descendant collections moved to trash should also have synced = 0 and clientDateModified updated in collections (or be saved via _saveTx), and/or
    2. When a parent collection transitions from deleted: true to deleted: false (via undo, manual restore, or remote sync overwrite), descendant collections that were cascaded into deletedCollections at the same timestamp (or whose remote state is not deleted) should also be removed from deletedCollections and reloaded via loadDataType('primaryData', true).
  • Please don't post AI analyses here. Just follow our standard bug-reporting guidelines, with exact steps to reproduce. There's a bug here that we'll fix, but it's not even clear what you did in Zotero to trigger it. We can debug problems in our own code.

    In the meantime, don't empty the trash, or the hidden subcollections will be permanently deleted.
  • if without the help of AI, i even don't know how to report or describe the bug. Thanks for the help of AI
  • dstillman Zotero Team
    edited today at 3:10pm
    I mean, you experienced some problem in Zotero while using it, right? So tell us what it was, in your own words (and in your native language, if you prefer). This is basic bug reporting for any software.

    https://www.zotero.org/support/reporting_problems#2_provide_steps_to_reproduce
  • edited today at 3:16pm

    我在笔记本和台式机之间同步使用 Zotero(64位 Windows,10.0.6 / 10.0.7)。我的文库里有“一级分类 Obesity → 二级分类 Muscle Preserve → 43 个三级子分类”。10 月 30 日在台式机上,上层分类 Obesity / Muscle Preserve 被误删进了回收站,约 3 分钟后把 Obesity 和 Muscle Preserve 恢复了回来。之后我在笔记本上继续往子分类里加文献并同步到了云端(网页版在线文库里 43 个子分类和文献都在),但今天回到台式机打开 Zotero 点右上角的同步按钮后,左侧分类树里只有 Obesity → Muscle Preserve,展开后里面的 43 个三级子分类全都不显示。

  • dstillman Zotero Team
    edited today at 3:26pm
    Did you do all of these steps (e.g., the restore) in the desktop app, or were some elsewhere (web library, AI tool, etc.)? Never mind, we can reproduce using only the desktop app on two machines.
  • thanks for the really efficient response. Given my non-technical background, more details i could not remember either. by the way, zotero is really good, been my workhorse for literature management over years.
Sign In or Register to comment.