Note: All AI contributions will be carefully reviewed by the project maintainers before being merged.
- This file documents discoverable project behavior for coding agents.
- AI-instruction scan performed with glob
**/{.github/copilot-instructions.md,AGENT.md,AGENTS.md,CLAUDE.md,.cursorrules,.windsurfrules,.clinerules,.cursor/rules/**,.windsurf/rules/**,.clinerules/**,README.md}. - Result: only
README.mdmatched (no existing agent-specific rules files were found).
- floccus is a cross-platform bookmarks sync engine with two runtimes: browser extension and Capacitor mobile app.
- Entrypoints are minimal:
src/entries/background-script.js(browser controller),src/entries/options.js(web UI),src/entries/native.js(native UI),src/entries/test.js(in-extension tests). - Runtime abstraction is via
src/lib/Controller.ts: browser UI talks to service worker/runtime messages; native uses direct controller implementation. - Sync orchestration is centered in
src/lib/Account.ts:- creates adapter + local tree + storage
- runs strategy (
default/merge/unidirectional) - persists cache, mappings, and continuation state
- applies failsafes and error normalization
- Core sync algorithm lives in
src/lib/strategies/Default.ts(multi-stage diff/reconcile/execute pipeline, resumable from an incrementally persisted continuation — see "Continuation persistence and resume").
- Flow: UI action/event ->
BrowserController/NativeController->Account.sync()-> strategy -> local tree + server adapter. - The native UI's own reads are a separate path:
src/ui/store/native/actions.jskeeps only the account's folders in the Vuex store (state.folderTree, no bookmarks in it, because the hierarchy is needed synchronously while rendering) and answers everything else withNativeTreeQuery--LOAD_CHILDREN,LOAD_TAGS,SEARCH_ITEMS,FIND_BOOKMARK_BY_URLreturn their result to the component instead of committing it. Every write action re-commits a freshly built folder tree, which is what tellsTree.vueto re-query its children, tags and search results. - Storage is per-account and platform-specific:
- browser:
src/lib/browser/BrowserAccountStorage.js—browser.storage.localfor account data, the sync cache (bookmarks[<id>].cache), the mappings (bookmarks[<id>].mappings) and the logs; the continuation lives in IndexedDB (BrowserContinuationStore, see below), withbookmarks[<id>].continuationonly as fallback/legacy location. - native:
src/lib/native/NativeAccountStorage.js—@capacitor/preferencesonly for account data (accounts),lastFoldersand the legacy continuation key; the local tree, the mappings, the sync cache, the continuation and the logs are SQLite rows (see "Native SQLite storage").
- browser:
- Both storages offer
setEntry(write) andchangeEntry(read-modify-write), each holding the same per-keyAsyncLock. UsechangeEntryonly when the new value is derived from the old one (accounts,lastFolders); a caller that replaces an entry wholesale — the browser cache blob, the continuation blob — must usesetEntry, becausechangeEntryreads and parses the previous value before discarding it, and during a sync those entries can be megabytes. The two write the same representation, so either can read the other (getEntryalso still parses legacy stringified values). - Adapter implementations are server boundary points under
src/lib/adapters/(Nextcloud, WebDAV, Git, Dropbox, Google Drive, Linkwarden, Karakeep, Fake).
- Since #2357 the native local tree and the sync mappings live in a shared SQLite database (
@capacitor-community/sqlite) instead of JSON blobs in@capacitor/preferences; the sync cache, the continuation and the logs have followed. src/lib/native/NativeDatabase.tsowns the single connection, the schema (account_meta,folders,bookmarks,cache_folders,cache_bookmarks,continuations,continuation_actions,mappings,logs) and anAsyncLockthat serializes every query/batch — the plugin accepts concurrent calls, but their transactions would interleave.CREATE TABLE IF NOT EXISTSdoes not add columns to an existing table, so later columns are backfilled byaddMissingColumns.src/lib/native/NativeTreeStore.tskeeps one account's rows in lockstep with the in-memory tree thatNativeTree(still aCachingAdapter) holds, so a change costs one row write instead of re-serializing the whole tree. Writes are queued and flushed in a microtask (enqueue/flushNow);NativeTree#save()therefore only persists hashes and awaitsstore.flush(), andNativeTree#load()hydrates the tree from rows.- Sibling order lives in the
positioncolumn:CachingAdapteronly ever appends to a folder's children, so new rows get an ever increasing position (nextPosition); onlyorderFolderand bulk imports renumber a folder's children. src/lib/native/NativeTreeQuery.tsis the read-only side of the same rows and what the native UI browses:getFolderTree,getChildren,getTags,findBookmarkByUrlandsearchare a query each, so opening a folder or searching never materializes the tree. It talks toNativeDatabasedirectly, so writes still sitting inNativeTreeStore's microtask queue are invisible to it -- a caller that just changed something has toawait NativeTree#save()first (the store actions do).- Consequently
NativeTreehydrates lazily:NativeAccount.get()/create()only construct it, andensureLoaded()at the top of every tree-touching method reads the rows the first time something actually needs the tree in memory (a sync, a UI edit).Account.getAllAccounts()on startup therefore reads no tree at all. folders.search_text/bookmarks.search_texthold the item's title (plus url and tags for bookmarks) lowercased in JS -- SQLite'slower()andLIKEonly fold ASCII, so a query forapfelwould otherwise missApfelthe moment a letter is non-ASCII. Every write path fills the column in (folderSearchText/bookmarkSearchTextinNativeTreeStore); rows from before the column existed are filled in once per account byNativeTreeQuery#backfillSearchText, recorded inaccount_meta.search_backfilled. SQL narrows a search to the rows containing every tag and term somewhere, and the predicates are then applied in JS, which is also what ranks the results.- A search query is a mix of tags and free text in any order, parsed by
parseSearchQuery(exported fromNativeTreeQuery, soTree.vuereads the same query the search does). Every#taghas to be on the item and narrows the results down further; every free-text term has to turn up in the title, the url or one of the tags, each term judged on its own — so#recipes pastaand a plainrecipes pastaboth find the bookmark taggedrecipeswithpastain its title. Only bookmarks carry tags, so a query naming one returns no folders. Tags and terms containing spaces are quoted (#"read later"), which is whatformatSearchTokenproduces when the tag bar writes a chip into the query — the chips accumulate, each one adding or removing its own#tagand leaving the rest of the query alone. Ranking is by how many of the query's tags the item carries exactly, then by title match quality. src/lib/native/NativeMappingsStore.tsgets the full in-memory mappings on every persist, diffs them against what it knows to be stored, and writes only the difference. Both ids are TEXT plus a*_numericflag, because a remote id like'007'must not come back as7;LocalToServeris authoritative andServerToLocalis rebuilt on load.src/lib/native/NativeCacheStore.tsholds the sync cache incache_folders/cache_bookmarks(see "Sync cache persistence").hash_valuestores the wholehashValuemap as JSON — unlikefolders.hash/hash_settings, since the cache never hashes anything itself.src/lib/native/NativeContinuationStore.tsholds the continuation incontinuations(one row per account: strategy,created_at, the structure JSON) andcontinuation_actions(one row per(diff_id, seq)); see "Continuation persistence and resume".src/lib/native/NativeLogStore.tsholds the debug log inlogs(seq, message)— the only table not keyed by account — trimmed to the newestLOG_RETENTION(1000,src/lib/Logger.js) lines in the same transaction as each append.Loggerbuffers lines and appends only the new ones every 3s (Storage.appendLogs); on the browser,BrowserAccountStorage.appendLogsdoes a read-concat-trim-write of thelogskey under the same lock key assetEntry.NativeControllerclears the stored log on start.- The stores migrate once per account from the old preferences keys (
bookmarks[<id>].tree,bookmarks[<id>].highestId,bookmarks[<id>].mappings,bookmarks[<id>].cache) and then remove them; the*_migratedflags inaccount_metarecord that this happened. The legacybookmarks[<id>].continuationis read as a fallback and removed after the first row write; the oldlogspreferences key is dropped, not migrated. - Rows of a deleted account would otherwise stay in the shared database forever, so
NativeAccountStorage#deleteAccountDataexplicitly clears the tree, cache, continuation and mapping rows. src/lib/native/ScreenWakeLock.ts(@capacitor-community/keep-awake) keeps the screen on while a native sync runs, because the WebView is throttled once it goes dark. It is reference counted (acquire/releasearoundaccount.sync()inNativeController#syncAccount, several accounts may sync at once) and re-asserts itself onvisibilitychange, since iOS drops it in the background.
src/lib/interfaces/CacheStore.ts(ICacheStore, obtained viaIAccountStorage#getCacheStore()) is whatCacheTreepersists through: nativeNativeCacheStore(rows), browserBrowserCacheStore(still one JSON blob,bookmarks[<id>].cache; its recorders are no-ops andsave()writestoStorageJSON), andNullCacheStore(everything a no-op; the default fornew CacheTree()/new CachingTreeWrapper(inner)in unit tests). Production always passesstorage.getCacheStore().- Every
ICacheStoremethod exceptload/save/clearonly records a change; nothing reaches storage beforesave(). That keeps the cache in step with the mappings and continuation persisted on the same progress tick — a cache running ahead of the mappings would, after an interrupt, claim items the mappings don't know. CacheTree(aCachingAdapter) forwards each mutation to the store after applying it in memory;createBookmarkAs/createFolderAscreate items under the id the live tree allocated,importSubtreemirrors a bulk import, andonHashesInvalidatedforwards tostore.invalidateHashes.setTree(called at the start of each sync) is a diff on native: only changed rows are upserted and vanished ids deleted.NativeCacheStore#savesends the queued statements as one batch; if that throws, the statements are put back in front of the queue and the error rethrown. Dropping them would leave the rows permanently wrong, because later saves only write what is queued and the next sync reads the rows beforesetTreecould correct them.- Dirty tracking:
CachingAdaptercounts mutations (endMutation/mutated,getMutationCount). Any new mutation path must count, orisCacheDirty()stays false and the change is never persisted.Account#persistProgresssaves the cache only when dirty, readinggetCacheRevision()beforesaveCache()and callingmarkCachePersisted(revision)after, so a change landing during the write leaves it dirty. - Unaccepted bookmarks are not filtered on write:
Account#syncappliesfilterUnacceptedBookmarks(CacheTree.ts) right after loading the cache (the browser'stoStorageJSONstrips them when writing the blob).
- A continuation is no longer one JSON blob.
src/lib/Continuation.tssplits it into one row per action, keyed by(diffId, seq), plus one structure record (strategy,createdAt,meta,members,diffIds).SyncProcess#toContinuationUpdateAsyncbuilds anIContinuationUpdateholding only the actions changed since the last acknowledged write; rows of diffs no longer listed indiffIdsare deleted by the store.assembleContinuationrebuilds theISerializedSyncProcessshapefromJSONexpects. - Stores: browser
src/lib/browser/BrowserContinuationStore.ts(IndexedDBfloccus_continuations, object storesmetaandactions; falls back to thebookmarks[<id>].continuationblob viacontinuationUpdateToJSONonly when IndexedDB is unavailable or a write failed — a failing write switches to the blob and rethrows, so the update stays unacknowledged and the next one is full), nativeNativeContinuationStore(no fallback). On the browser,getCurrentContinuationprefers the blob only if itscreatedAtis newer than the rows'. Diff(src/lib/Diff.ts) tracks what to persist per action (seqs, changed/removed/in-flight sets).commit/retractare the only structural mutators; any other in-place change to an action that stays in its diff needsmarkChanged(action)(today:removeItemFromReorders), or the stale row survives.getPendingChangesAsynciterates copies ofactions/seqs, because an executor'sgetActions()maycompact()the diff mid-serialization.markPersisted— viamarkContinuationPersisted— runs only after a successful write; anything unacknowledged is written again next time.- Diff ids carry a per-process random prefix, so a resumed run's diffs never adopt the previous run's rows; its first update writes everything and the stale rows are dropped.
Account#persistProgresswrites the continuation only for non-atomic servers (atomic adapters rely on cache + mappings), serially under the module-levelcontinuationLock— an older update landing last would put executed actions back into their plan.Account#clearContinuationtakes the same lock (else a fire-and-forget tick could land after the clear) and storesnull, never a bare{ createdAt }, sinceAccount#synchands any non-null entry toSyncProcess.fromJSON.createdAtis stamped by the stores on every update;Account#syncdrops continuations older than half an hour by it. A continuation without one compares asNaN > x, i.e. never stale.- Progress ticks are throttled by
progressInterval(items)(Default.ts: 0.2ms per item, clamped to 1.5–10s;retuneProgressInterval()rebuilds the throttle once the trees are loaded, so readthis.throttledProgressCbat call time).progressCallbackpersists nothing before the first executed action. UnderisTestthe throttled tick is a no-op; the continuation is then only persisted at an interrupt or by tests callingaccount.progressCallback. - Which members are persisted is decided by
getMembersToPersist(): scan results until stage-1 plans exist, stage-1 plans until stage 2, stage-2 plans until both stage-3 plans exist (stage 3 is built lazily), stage-3 plans/done plans/prelim reorders/actionsPlanneduntil bothlocalReordersandserverReordersexist, and the reorders always. The stage-3 condition must not be based onactionsDone(reset every run, and lagging behind bulk imports' REORDERs): a member that drops out for one tick loses its rows, and when it comes back only its later changes are written.UnidirectionalpersistsscanResult/revertPlan/revertDonePlanuntilrevertReordersexists. - Resume in
Account#sync: a continuation that can't be read (corrupt row, IndexedDB gone) or can't be hydrated is treated as none — failing the sync would re-init()the account and wipe cache and mappings. It is only resumed ifcontinuationMatchesStrategyholds:slaveresumesunidirectionaltowards LOCAL,overwritetowards SERVER, a forced sync (strategy === null) anything, other strategiesdefault/merge. If both reorder lists were restored,sync()skips straight toexecuteReorderingStage(), because re-planning would run a sync of its own whose reorders then lose to the stored ones. - A restored continuation gives each member its own copy of a diff that was shared (e.g.
planStage3Server.CREATEandserverPlanStage2.CREATE), and only one copy gets drained. SoDefaultapplies the failsafes only when the stage-2 plans were built in this run (plannedInThisRun); a re-check would count executed removals against the shrunken tree.Unidirectionalre-checks on resume, which is harmless there: it keeps no second copy ofrevertPlan, whose executors drain it in place, so a resumed check sees only the remaining changes against a tree they have already been applied to — a lower ratio than the one that passed. - On failure,
keepsContinuation(E026 interrupts, network errors,LocalFolderNotFoundError, the failsafe errors) triggerspersistFinalProgress()(non-atomic servers, only once actions were done): the last throttled tick may be up to 10s behind, and re-executing those actions on resume duplicates items. Any other error clears the continuation and re-init()s.
folders.hash/folders.hash_settingspersist each folder's subtree hash, so a sync only re-hashes the subtrees that changed.IHashSettingsare negotiated per sync; a hash stored under different settings is ignored (hashCacheKeyinsrc/lib/Tree.ts). The sync cache carries its hashes too —CacheTreeandCachingTreeWrapperclone(true)/copy(true), and the native cache stores them incache_folders.hash_value.- Because hashes survive across syncs now, every mutation has to invalidate them.
CachingAdapter#invalidateHashes(folderId)walksFolder#invalidateHashUpwardsto the root and calls theonHashesInvalidatedhook, whichNativeTreeandCacheTreeoverride to drop the stored hashes. Any new mutation path insrc/lib/adapters/Caching.ts— and any place that rewriteschildrendirectly (Scanner,filterOut*inDefault.ts,loadServerTreeinMerge/Unidirectional) — must do the same, or the scanner concludes that nothing changed. Folder#updateIndex/#removeFromIndexmaintain the index incrementally along the path from the root to the item rather than rebuilding it (a rebuild is O(items × depth) on every change); anything they can't make sense of falls back to a fullcreateIndex(), which is always correct.updateIndexalways rebuilds the item's own index first, because adapters rewrite ids after inserting an item.- Callers that replace a folder's
childrenwholesale mustremoveFromIndexthe old children first — the folders above still list them (seeCaching#bulkImportFolder,NextcloudBookmarks#loadFolderChildren).NextcloudBookmarks#bulkImportFolderis the opposite case: the endpoint adds to the folder and answers with only what it imported, so the adapter appends those children to its tree (skipping ones the folder already lists) rather than replacing them -- otherwise a chunked import leaves only the last chunk in the in-memory tree. FLOCCUS_VERIFY_INDEX=trueenablesFolder#assertIndexConsistent, which cross-checks the incrementally maintained index against a full rebuild after everyCachingAdaptermutation. It is a no-op otherwise.- Async tree serialization (
toJSONAsync) and hashing yield to the event loop by module-global counters (SERIALIZE_ITERATIONS,HASH_ITERATIONSinTree.ts, every 1000 items) and walk children sequentially; don't reintroduce per-call counters or unboundedParallel.map.
- Install/build:
npm install,npm run build. - Dev watch loop:
npm run watch(also syncs Capacitor assets; seegulpfile.js). - Release artifacts:
npm run build-release-> zip/xpi/crx inbuilds/. - Static checks:
npm run lint,npm run typecheck. - Selenium integration tests:
npm test(expects Selenium server + env vars; runner intest/selenium-runner.js). CI (.github/workflows/tests.yml, alsoandroid-appium.yml) runs against Nextcloud 35. - Node.js test harness:
npm run build:test-nodebundlessrc/entries/test-node.jstodist/node-tests/fake-tests.jsviawebpack.node-tests.js. - Node.js test execution:
npm run test:node:fakeruns the bundled Mocha suite without a browser/WebDriver. Defaults areFLOCCUS_TEST_ACCOUNTS=fake,FLOCCUS_TEST_BROWSER=node, andCI=true; useful knobs includeFLOCCUS_TEST(grep),FLOCCUS_TEST_INVERT=true,FLOCCUS_TEST_ACCOUNTS=...,FLOCCUS_TEST_SEED=...,FLOCCUS_VERIFY_INDEX=true, andFLOCCUS_NODE_INCLUDE_BENCHMARK=true(npm run test:node:fake:benchmark). - Fake account types available to the harness:
fake(atomic, cached),fake-noCache(atomic; the tests calldisableCachePersistence(account)fromsrc/test/utils.js, which swaps in aNullCacheStore, and stubsetMappingsthemselves, so every sync starts without persisted state — stubbingsetCachealone no longer disables the cache) andfake-nc-bookmarks(FakeNcBookmarksAdapter,isAtomic() === false, ids that embed the parent,bulkImportAppendsChildrenwith an answer of only the imported items — the stand-in for nextcloud-bookmarks, so it also exercises the chunked bulk import). Thenodejs-fake-testCI workflow runs a matrix offakeandfake-nc-bookmarks. - Tests supply their own local folder:
Account.create({...ACCOUNT_DATA, ...(await createTestLocalRoot())}). Production code never creates one —BrowserAccount#initthrowsLocalFolderNotFoundError(E038) for a profile without a local folder. expectTreeEqual(tree1, tree2, ignoreEmptyFolders, checkOrder = true, checkTags = false): passBoolean(account.server.orderFolder)ascheckOrderso adapters without ordering (Linkwarden, Karakeep, ...) aren't held to an order.checkOrder === falsesorts both trees' children in place.- The node harness shims Capacitor plugins via webpack aliases in
webpack.node-tests.js;@capacitor-community/sqliteresolves tosrc/test/node-shims/capacitor-sqlite.js, an in-memorysql.jsdatabase that lives for the length of the process.sql.jsis kept as a webpack external so it can locate its own wasm file at runtime. IndexedDB comes fromfake-indexeddbviasrc/test/node-shims/indexeddb.js, which the tests import themselves (not an alias). src/test/native_storage.test.js(insrc/test/node-suite.js) exercises the native stores directly:NativeTreeStore/NativeTreeQuery/NativeMappingsStore(tree round-trips, folder hash persistence/invalidation, browsing, search ranking/predicates/LIKE escaping/non-ASCII folding/search_textbackfill, mapping id types), the preferences entries (setEntryround-trips, replaces rather than merges, agrees withchangeEntry), the legacy continuation entry (NativeAccountStorage continuation),NativeAccountStorage incremental continuations(only changed actions are written, in-flight/unacknowledged rows,markChanged, shared diffs, no adoption of a previous run's rows, legacy blob resume) andNative SQLite sync cache(writes only what changed, subtree removal, moves, bulk import, hashes, the preferences migration, a failed save is written again).src/test/browser_continuation_store.test.jscoversBrowserContinuationStore: round trip, incremental updates, key-range isolation between diffs and accounts, clearing, reopening after the database was deleted, and a realDefaultSyncProcessdriven throughtoContinuationUpdateAsync→update→markContinuationPersisted.src/test/continuation_resume.test.jsruns only withFLOCCUS_TEST_ACCOUNTS=fake-nc-bookmarks(continuations are only persisted for non-atomic servers): no re-execution after a failure, resuming at the reorder stage without re-planning (default/overwrite, plain/forced/explicit strategy), not resuming for the opposite direction, syncing normally over an unreadable continuation, and no failsafe trip over removals already executed.src/test/caching_tree_wrapper.test.jscoversCachingTreeWrapper's bulk import (capability only exposed when the wrapped tree has it, cache mirrors the import under the live tree's ids, a subtree well over the nextcloud chunk size still goes in one call, the cache stays out of later changes) and cache dirty tracking (persisting the cache).src/test/diff.test.jscoversDiff(retract,peekActions,markChanged, persistence across compaction) and async tree serialization;src/test/progress_interval.test.jscoversprogressInterval.- Appium/native Android harness:
npm run test:appiumrunstest/appium-runner.js, which waits for an Appium server, creates an AndroidUiAutomator2session, switches into the app'sWEBVIEW, opens the native#/testroute, and streams Mocha logs until aFINISHEDmarker is emitted. - Appium prerequisites: the Android app/APK must already be built and installed, and an Appium server with the
uiautomator2driver must be running. Common env vars areAPPIUM_SERVER,APPIUM_DEVICE_NAME, eitherAPPIUM_APPor (APPIUM_APP_PACKAGE+APPIUM_APP_ACTIVITY), plus the same test-selection env used by the browser harness (FLOCCUS_TEST,FLOCCUS_TEST_SEED,APP_VERSION,TEST_HOST, adapter-specific credentials/tokens such as Google/Dropbox/Linkwarden/Karakeep). - Browser-local test mode is destructive to bookmarks unless using a dedicated profile (see
README.mdtest section).
- Mixed JS/TS/Vue2 codebase (
allowJs: trueintsconfig.json); keep edits consistent with surrounding file language. - Lint style is strict and legacy-standard-like: single quotes, no semicolons, 2-space indent (
.eslintrc.json). - Adapters are registered centrally in
src/lib/Account.tsviaAdapterFactory.register(...)(dynamic imports). - Sync reliability relies on continuation persistence and mapping GC; avoid "simplifying" this flow without preserving resume semantics.
IS_BROWSERcompile-time flag (webpack define) is the platform switch; do not branch on ad-hoc runtime checks when an existingIS_BROWSERpath exists.
- Browser manifests differ (
manifest.firefox.jsonis MV2 background page;manifest.json/manifest.chrome.jsonare MV3 service worker). gulpfile.jscontains a guard to preventbrowser-apileakage into native chunk (webpackCheck).- Nextcloud adapter (
src/lib/adapters/NextcloudBookmarks.ts) is the most feature-rich reference for locking, sparse tree loading, ordering, and request handling. - If adding/changing adapters, implement
interfaces/Resource.tscapabilities (getCapabilities,isAtomic, optionalorderFolder/bulkImportFolder/loadFolderChildren) and verify strategy interactions. bulkImportFoldercomes in two flavours and the difference decides howDefault#executeCreatecalls it.NextcloudBookmarksadds the given children to the folder but refuses more than 75 bookmarks per request, so a large subtree is imported in chunks (of 70 bookmarks; subfolders become their own CREATEs);CachingAdapter— and thusNativeTreeand theCachingTreeWrapperaround it — replaces the folder's children, so chunking it would keep nothing but the last chunk.bulkImportAppendsChildrenonBulkImportResourceis what says which, and only the appending kind is ever chunked.- The sub-scanner after each bulk import creates the mappings, and
Scanner#addMappingonly maps a LOCAL/SERVER pair — a scan of two items from the same location records nothing, silently. Scan the source-side tree (action.oldItem, or a chunk restamped to its location) against what the import returned. CachingTreeWrapperis what the strategy gets as the local tree (Account#sync), so a capability the wrapper doesn't forward is a capability the sync can't use — it detects bulk import with'bulkImportFolder' in resource, and the wrapper therefore only defines that method (in its constructor) when the tree it wraps has one. It also forwardsisUsingBrowserTabs, which picks the failsafe thresholds.- Failsafes:
applyDeletionFailsafe/applyAdditionFailsafeare async and must be awaited;getFailsafeThresholds()is laxer for tab sync (#2318). SyncProcess#loadChildren(sparse server trees) must recurse into folders already markedloadedtoo: the sparse listing marks one layer at a time, so returning early leaves grandchildren unloaded.BrowserTree#updateBookmark/#updateFolderskipbrowser.bookmarks.movewhen the parent doesn't change — a move without an index appends to the end of the folder (#2361); positions are the REORDERs' job.DropboxAdapter#requestretries 5xx only for callers passingretriable(the API is all POST, so the verb says nothing about whether a repeat is harmless).GitAdaptermay only delete its own lightning-fs IndexedDB databases (isDisposableFsDatabase): deleting another one (e.g.floccus_continuations) blocks, a blocked delete stays pending, and from then onindexedDB.databases()never resolves for the origin.BrowserContinuationStorecloses its connection onversionchangefor the same reason.- Profile import/export strips volatile account data (
VOLATILE_ACCOUNT_DATAinAccount.ts) and no longer disables imported profiles;ImportExport.vuesends the user to the first profile that still needs authorization or a local folder. - i18n strings live in
_locales/en/messages.json; UI text should use i18n helpers rather than hardcoded strings.
Scanner(src/lib/Scanner.ts) diffscacheTreeRoot(always local-located) against a live tree using amergeablepredicate chosen per scanner: the maingetDiffsscanners acceptMappings.mappablepairs, orcanMergeWith(weak: bookmarks by URL, server scanner only; folders by title only when syncing tabs). The sub-scanners (concurrent creations inreconcileDiffs, both bulk-import paths,Merge) acceptcanMergeWith && !Mappings.wouldEvictUnrelatedMapping(snapshot, a, b).Scanner#addMappingevicts a conflicting entry when it adds one, so a pairing can't strand a mapping. But an item that vanishes without a synced deletion can, and the sub-scanners then refuse to re-bind its counterpart. The pre-scan prunes clean that up, and they only run on a fresh plan, never on a resumed one:Default#dropDeadMappingsdrops mappings whose local item is in neither the local tree nor the cache. Keep the cache check: an item missing from the tree but still in the cache was deleted locally, and this sync needs the mapping to delete it on the server.Default#repairServerMappingsre-points a mapping whose server item is gone to a uniquecanMergeWithitem in the mapped parent, or drops it. It exists for nc-style ids (<id>;<folderId>), which change when a folder is re-created without any hash noticing. It walks folders before bookmarks and parents before children (SyncProcess.indexTree/mappedIdsInTreeOrder), and is skipped for sparse servers ('loadFolderChildren' in this.server).Unidirectional#dropMappingsOfVanishedSlaveItemsdrops mappings whose slave-side item is gone.
- After a successful sync,
Mappings#gc(Account.ts) drops mappings of local items missing from the post-sync cache, and — only for atomic servers — of server items missing from the server tree; a sparse tree would drop unloaded items. Mappings#getSnapshot()returns a cached, shared snapshot that mutators invalidate (invalidateSnapshot) rather than modify, so a caller keeps seeing the state it asked for. Treat it as read-only (usegetMutableSnapshot()to write), callgetSnapshot()again to see later changes, and make any new mutator invalidate.reconcileDiffsinDefault.tsbuilds the per-target plan; it must never plan anUPDATE/MOVEagainst an item that's absent from the freshly-fetched target tree (executes as E002UnknownBookmarkUpdateError/ E004UnknownMoveTargetError). Concurrent-removal detection viaREMOVEactions +Diff.findChainis best-effort; a target-tree existence check (targetTree.findItem(type, mapId(...))) is the robust guard.- Stage-3 MOVEs and the final REORDERs are mapped with
Diff#map(..., skipErroneousActions = true): an action whose id or parent can't be mapped is dropped (Failed to map id: ... dropping action from plan) instead of throwing E048MappingFailureError, whose reset-and-resync recovery duplicates items on non-atomic servers. The drop is silent to the user, so a missing mapping now shows up as a change that never reaches the other side — look for that log line. Stage-2 mapping still throws. Scanner#createsFolderLooplets a folder move into a folder that used to be its descendant if that folder has moved out in the new tree (nestingSurvives). Refusing the pairing would turn the move into REMOVE + CREATE and delete everything moved into it.Diff#getActions()returns a copy the caller owns (use it to mutate the list or to retract while iterating);peekActions()returns the diff's own array, which is valid only for reading and only until the nextcommit/retract— keep actions from it, never the array.
- The
fake-nc-bookmarksbenchmark interrupt test simulates nextcloud-bookmarks: both accounts are wired to one shared serverbookmarksCacheand the adapter reportsisAtomic() === false;setInterrupt()aborts syncs mid-flight (recoverable errors are E026/E027 only — seesyncAccountWithInterruptsinsrc/test/utils.js). Thefake/fake-noCacheaccounts copy the server db at sync boundaries and are atomic. - Logs are noisy and misleading: the fuzzers (
randomTreeManipulationWithDeletion) wrap their ownNativeTreemutations in try/catch andconsole.logthe errors, so mostE001/E002/E004lines (stack viaNativeTree.updateBookmark) are expected noise. The real failure is the lineSyncing failed with ...(stack throughFakeAdapter+SyncProcess). - CI job logs interleave real-time stdout with a buffered
Loggerdump at the end, andutil.inspecttruncates trees/actions ([Bookmark],[Array]) — scan-result/plan contents are not fully recoverable from logs; trace by item id and theMapping <server|local> planmarkers instead. - A benchmark that ends with a tree mismatch but no sync error is often a silently dropped action: grep the sync's log for
Failed to map id, then trace that id back through the sync that should have created its mapping.