[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)
Asymmetric cascading between
_saveTx()andtrash()(collection.js#L348-L360andcollection.js#L600-L685):- When
this._changedData.deletedistrue,_saveTx()callsawait 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 gathersthis.idplus all descendant collection IDs (this.getDescendents(false, null, false)) and inserts all of them directly intodeletedCollectionsvia 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); } );
- When
Two state-consistency flaws occur here:
- Flaw A (
syncedflag not dirtied on descendants):this.trash()inserts descendantcollectionIDs directly intodeletedCollectionswithout updatingcollections.synced = 0orclientDateModifiedon the descendant rows incollections(onlythisparent collection goes through_saveTx()). Thus, in SQLite:deletedCollectionshas rows for all 43 child collections (dateDeleted = '2026-10-30 01:23:13').collectionsstill hassynced = 1for 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 = falseat2026-10-30 01:26:12, bumping the parent collectionversionto13179),_saveTx()only executesDELETE FROM deletedCollections WHERE collectionID=?for the parent collection (ID=51), leaving all 43 child collections stranded indeletedCollectionswithsynced = 1and versions8348..12118.
- Flaw A (
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:
- In
Zotero.Collection.prototype.trash(), descendant collections moved to trash should also havesynced = 0andclientDateModifiedupdated incollections(or be saved via_saveTx), and/or - When a parent collection transitions from
deleted: truetodeleted: false(via undo, manual restore, or remote sync overwrite), descendant collections that were cascaded intodeletedCollectionsat the same timestamp (or whose remote state is not deleted) should also be removed fromdeletedCollectionsand reloaded vialoadDataType('primaryData', true).
- In
Upgrade Storage
In the meantime, don't empty the trash, or the hidden subcollections will be permanently deleted.
https://www.zotero.org/support/reporting_problems#2_provide_steps_to_reproduce
我在笔记本和台式机之间同步使用 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 个三级子分类全都不显示。
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.