summaryrefslogtreecommitdiffstats
path: root/toolkit/components/places/SyncedBookmarksMirror.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/places/SyncedBookmarksMirror.sys.mjs')
-rw-r--r--toolkit/components/places/SyncedBookmarksMirror.sys.mjs2473
1 files changed, 2473 insertions, 0 deletions
diff --git a/toolkit/components/places/SyncedBookmarksMirror.sys.mjs b/toolkit/components/places/SyncedBookmarksMirror.sys.mjs
new file mode 100644
index 0000000000..2e197d2964
--- /dev/null
+++ b/toolkit/components/places/SyncedBookmarksMirror.sys.mjs
@@ -0,0 +1,2473 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * This file implements a mirror and two-way merger for synced bookmarks. The
+ * mirror matches the complete tree stored on the Sync server, and stages new
+ * bookmarks changed on the server since the last sync. The merger walks the
+ * local tree in Places and the mirrored remote tree, produces a new merged
+ * tree, then updates the local tree to reflect the merged tree.
+ *
+ * Let's start with an overview of the different classes, and how they fit
+ * together.
+ *
+ * - `SyncedBookmarksMirror` sets up the database, validates and upserts new
+ * incoming records, attaches to Places, and applies the changed records.
+ * During application, we fetch the local and remote bookmark trees, merge
+ * them, and update Places to match. Merging and application happen in a
+ * single transaction, so applying the merged tree won't collide with local
+ * changes. A failure at this point aborts the merge and leaves Places
+ * unchanged.
+ *
+ * - A `BookmarkTree` is a fully rooted tree that also notes deletions. A
+ * `BookmarkNode` represents a local item in Places, or a remote item in the
+ * mirror.
+ *
+ * - A `MergedBookmarkNode` holds a local node, a remote node, and a
+ * `MergeState` that indicates which node to prefer when updating Places and
+ * the server to match the merged tree.
+ *
+ * - `BookmarkObserverRecorder` records all changes made to Places during the
+ * merge, then dispatches `PlacesObservers` notifications. Places uses
+ * these notifications to update the UI and internal caches. We can't dispatch
+ * during the merge because observers won't see the changes until the merge
+ * transaction commits and the database is consistent again.
+ *
+ * - After application, we flag all applied incoming items as merged, create
+ * Sync records for the locally new and updated items in Places, and upload
+ * the records to the server. At this point, all outgoing items are flagged as
+ * changed in Places, so the next sync can resume cleanly if the upload is
+ * interrupted or fails.
+ *
+ * - Once upload succeeds, we update the mirror with the uploaded records, so
+ * that the mirror matches the server again. An interruption or error here
+ * will leave the uploaded items flagged as changed in Places, so we'll merge
+ * them again on the next sync. This is redundant work, but shouldn't cause
+ * issues.
+ */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ Async: "resource://services-common/async.sys.mjs",
+ Log: "resource://gre/modules/Log.sys.mjs",
+ PlacesSyncUtils: "resource://gre/modules/PlacesSyncUtils.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "MirrorLog", () =>
+ lazy.Log.repository.getLogger("Sync.Engine.Bookmarks.Mirror")
+);
+
+const SyncedBookmarksMerger = Components.Constructor(
+ "@mozilla.org/browser/synced-bookmarks-merger;1",
+ "mozISyncedBookmarksMerger"
+);
+
+// These can be removed once they're exposed in a central location (bug
+// 1375896).
+const DB_URL_LENGTH_MAX = 65536;
+const DB_TITLE_LENGTH_MAX = 4096;
+
+// The current mirror database schema version. Bump for migrations, then add
+// migration code to `migrateMirrorSchema`.
+const MIRROR_SCHEMA_VERSION = 8;
+
+// Use a shared jankYielder in these functions
+XPCOMUtils.defineLazyGetter(lazy, "yieldState", () => lazy.Async.yieldState());
+
+/** Adapts a `Log.sys.mjs` logger to a `mozIServicesLogSink`. */
+class LogAdapter {
+ constructor(log) {
+ this.log = log;
+ }
+
+ get maxLevel() {
+ let level = this.log.level;
+ if (level <= lazy.Log.Level.All) {
+ return Ci.mozIServicesLogSink.LEVEL_TRACE;
+ }
+ if (level <= lazy.Log.Level.Info) {
+ return Ci.mozIServicesLogSink.LEVEL_DEBUG;
+ }
+ if (level <= lazy.Log.Level.Warn) {
+ return Ci.mozIServicesLogSink.LEVEL_WARN;
+ }
+ if (level <= lazy.Log.Level.Error) {
+ return Ci.mozIServicesLogSink.LEVEL_ERROR;
+ }
+ return Ci.mozIServicesLogSink.LEVEL_OFF;
+ }
+
+ trace(message) {
+ this.log.trace(message);
+ }
+
+ debug(message) {
+ this.log.debug(message);
+ }
+
+ warn(message) {
+ this.log.warn(message);
+ }
+
+ error(message) {
+ this.log.error(message);
+ }
+}
+
+/**
+ * A helper to track the progress of a merge for telemetry and shutdown hang
+ * reporting.
+ */
+class ProgressTracker {
+ constructor(recordStepTelemetry) {
+ this.recordStepTelemetry = recordStepTelemetry;
+ this.steps = [];
+ }
+
+ /**
+ * Records a merge step, updating the shutdown blocker state.
+ *
+ * @param {String} name A step name from `ProgressTracker.STEPS`. This is
+ * included in shutdown hang crash reports, along with the timestamp
+ * the step was recorded.
+ * @param {Number} [took] The time taken, in milliseconds.
+ * @param {Array} [counts] An array of additional counts to report in the
+ * shutdown blocker state.
+ */
+ step(name, took = -1, counts = null) {
+ let info = { step: name, at: Date.now() };
+ if (took > -1) {
+ info.took = took;
+ }
+ if (counts) {
+ info.counts = counts;
+ }
+ this.steps.push(info);
+ }
+
+ /**
+ * Records a merge step with timings and counts for telemetry.
+ *
+ * @param {String} name The step name.
+ * @param {Number} took The time taken, in milliseconds.
+ * @param {Array} [counts] An array of additional `{ name, count }` tuples to
+ * record in telemetry for this step.
+ */
+ stepWithTelemetry(name, took, counts = null) {
+ this.step(name, took, counts);
+ this.recordStepTelemetry(name, took, counts);
+ }
+
+ /**
+ * Records a merge step with the time taken and item count.
+ *
+ * @param {String} name The step name.
+ * @param {Number} took The time taken, in milliseconds.
+ * @param {Number} count The number of items handled in this step.
+ */
+ stepWithItemCount(name, took, count) {
+ this.stepWithTelemetry(name, took, [{ name: "items", count }]);
+ }
+
+ /**
+ * Clears all recorded merge steps.
+ */
+ reset() {
+ this.steps = [];
+ }
+
+ /**
+ * Returns the shutdown blocker state. This is included in shutdown hang
+ * crash reports, in the `AsyncShutdownTimeout` annotation.
+ *
+ * @see `fetchState` in `AsyncShutdown` for more details.
+ * @return {Object} A stringifiable object with the recorded steps.
+ */
+ fetchState() {
+ return { steps: this.steps };
+ }
+}
+
+/** Merge steps for which we record progress. */
+ProgressTracker.STEPS = {
+ FETCH_LOCAL_TREE: "fetchLocalTree",
+ FETCH_REMOTE_TREE: "fetchRemoteTree",
+ MERGE: "merge",
+ APPLY: "apply",
+ NOTIFY_OBSERVERS: "notifyObservers",
+ FETCH_LOCAL_CHANGE_RECORDS: "fetchLocalChangeRecords",
+ FINALIZE: "finalize",
+};
+
+/**
+ * A mirror maintains a copy of the complete tree as stored on the Sync server.
+ * It is persistent.
+ *
+ * The mirror schema is a hybrid of how Sync and Places represent bookmarks.
+ * The `items` table contains item attributes (title, kind, URL, etc.), while
+ * the `structure` table stores parent-child relationships and position.
+ * This is similar to how iOS encodes "value" and "structure" state,
+ * though we handle these differently when merging. See `BookmarkMerger` for
+ * details.
+ *
+ * There's no guarantee that the remote state is consistent. We might be missing
+ * parents or children, or a bookmark and its parent might disagree about where
+ * it belongs. This means we need a strategy to handle missing parents and
+ * children.
+ *
+ * We treat the `children` of the last parent we see as canonical, and ignore
+ * the child's `parentid` entirely. We also ignore missing children, and
+ * temporarily reparent bookmarks with missing parents to "unfiled". When we
+ * eventually see the missing items, either during a later sync or as part of
+ * repair, we'll fill in the mirror's gaps and fix up the local tree.
+ *
+ * During merging, we won't intentionally try to fix inconsistencies on the
+ * server, and opt to build as complete a tree as we can from the remote state,
+ * even if we diverge from what's in the mirror. See bug 1433512 for context.
+ *
+ * If a sync is interrupted, we resume downloading from the server collection
+ * last modified time, or the server last modified time of the most recent
+ * record if newer. New incoming records always replace existing records in the
+ * mirror.
+ *
+ * We delete the mirror database on client reset, including when the sync ID
+ * changes on the server, and when the user is node reassigned, disables the
+ * bookmarks engine, or signs out.
+ */
+export class SyncedBookmarksMirror {
+ constructor(
+ db,
+ wasCorrupt = false,
+ {
+ recordStepTelemetry,
+ recordValidationTelemetry,
+ finalizeAt = lazy.PlacesUtils.history.shutdownClient.jsclient,
+ } = {}
+ ) {
+ this.db = db;
+ this.wasCorrupt = wasCorrupt;
+ this.recordValidationTelemetry = recordValidationTelemetry;
+
+ this.merger = new SyncedBookmarksMerger();
+ this.merger.db = db.unsafeRawConnection.QueryInterface(
+ Ci.mozIStorageConnection
+ );
+ this.merger.logger = new LogAdapter(lazy.MirrorLog);
+
+ // Automatically close the database connection on shutdown. `progress`
+ // tracks state for shutdown hang reporting.
+ this.progress = new ProgressTracker(recordStepTelemetry);
+ this.finalizeController = new AbortController();
+ this.finalizeAt = finalizeAt;
+ this.finalizeBound = () => this.finalize({ alsoCleanup: false });
+ this.finalizeAt.addBlocker(
+ "SyncedBookmarksMirror: finalize",
+ this.finalizeBound,
+ { fetchState: () => this.progress }
+ );
+ }
+
+ /**
+ * Sets up the mirror database connection and upgrades the mirror to the
+ * newest schema version. Automatically recreates the mirror if it's corrupt;
+ * throws on failure.
+ *
+ * @param {String} options.path
+ * The path to the mirror database file, either absolute or relative
+ * to the profile path.
+ * @param {Function} options.recordStepTelemetry
+ * A function with the signature `(name: String, took: Number,
+ * counts: Array?)`, where `name` is the name of the merge step,
+ * `took` is the time taken in milliseconds, and `counts` is an
+ * array of named counts (`{ name, count }` tuples) with additional
+ * counts for the step to record in the telemetry ping.
+ * @param {Function} options.recordValidationTelemetry
+ * A function with the signature `(took: Number, checked: Number,
+ * problems: Array)`, where `took` is the time taken to run
+ * validation in milliseconds, `checked` is the number of items
+ * checked, and `problems` is an array of named problem counts.
+ * @param {AsyncShutdown.Barrier} [options.finalizeAt]
+ * A shutdown phase, barrier, or barrier client that should
+ * automatically finalize the mirror when triggered. Exposed for
+ * testing.
+ * @return {SyncedBookmarksMirror}
+ * A mirror ready for use.
+ */
+ static async open(options) {
+ let db = await lazy.PlacesUtils.promiseUnsafeWritableDBConnection();
+ if (!db) {
+ throw new TypeError("Can't open mirror without Places connection");
+ }
+ let path;
+ if (PathUtils.isAbsolute(options.path)) {
+ path = options.path;
+ } else {
+ path = PathUtils.join(PathUtils.profileDir, options.path);
+ }
+ let wasCorrupt = false;
+ try {
+ await attachAndInitMirrorDatabase(db, path);
+ } catch (ex) {
+ if (isDatabaseCorrupt(ex)) {
+ lazy.MirrorLog.warn(
+ "Error attaching mirror to Places; removing and " +
+ "recreating mirror",
+ ex
+ );
+ wasCorrupt = true;
+ await IOUtils.remove(path);
+ await attachAndInitMirrorDatabase(db, path);
+ } else {
+ lazy.MirrorLog.error(
+ "Unrecoverable error attaching mirror to Places",
+ ex
+ );
+ throw ex;
+ }
+ }
+ return new SyncedBookmarksMirror(db, wasCorrupt, options);
+ }
+
+ /**
+ * Returns the newer of the bookmarks collection last modified time, or the
+ * server modified time of the newest record. The bookmarks engine uses this
+ * timestamp as the "high water mark" for all downloaded records. Each sync
+ * downloads and stores records that are strictly newer than this time.
+ *
+ * @return {Number}
+ * The high water mark time, in seconds.
+ */
+ async getCollectionHighWaterMark() {
+ // The first case, where we have records with server modified times newer
+ // than the collection last modified time, occurs when a sync is interrupted
+ // before we call `setCollectionLastModified`. We subtract one second, the
+ // maximum time precision guaranteed by the server, so that we don't miss
+ // other records with the same time as the newest one we downloaded.
+ let rows = await this.db.executeCached(
+ `
+ SELECT MAX(
+ IFNULL((SELECT MAX(serverModified) - 1000 FROM items), 0),
+ IFNULL((SELECT CAST(value AS INTEGER) FROM meta
+ WHERE key = :modifiedKey), 0)
+ ) AS highWaterMark`,
+ { modifiedKey: SyncedBookmarksMirror.META_KEY.LAST_MODIFIED }
+ );
+ let highWaterMark = rows[0].getResultByName("highWaterMark");
+ return highWaterMark / 1000;
+ }
+
+ /**
+ * Updates the bookmarks collection last modified time. Note that this may
+ * be newer than the modified time of the most recent record.
+ *
+ * @param {Number|String} lastModifiedSeconds
+ * The collection last modified time, in seconds.
+ */
+ async setCollectionLastModified(lastModifiedSeconds) {
+ let lastModified = Math.floor(lastModifiedSeconds * 1000);
+ if (!Number.isInteger(lastModified)) {
+ throw new TypeError("Invalid collection last modified time");
+ }
+ await this.db.executeBeforeShutdown(
+ "SyncedBookmarksMirror: setCollectionLastModified",
+ db =>
+ db.executeCached(
+ `
+ REPLACE INTO meta(key, value)
+ VALUES(:modifiedKey, :lastModified)`,
+ {
+ modifiedKey: SyncedBookmarksMirror.META_KEY.LAST_MODIFIED,
+ lastModified,
+ }
+ )
+ );
+ }
+
+ /**
+ * Returns the bookmarks collection sync ID. This corresponds to
+ * `PlacesSyncUtils.bookmarks.getSyncId`.
+ *
+ * @return {String}
+ * The sync ID, or `""` if one isn't set.
+ */
+ async getSyncId() {
+ let rows = await this.db.executeCached(
+ `
+ SELECT value FROM meta WHERE key = :syncIdKey`,
+ { syncIdKey: SyncedBookmarksMirror.META_KEY.SYNC_ID }
+ );
+ return rows.length ? rows[0].getResultByName("value") : "";
+ }
+
+ /**
+ * Ensures that the sync ID in the mirror is up-to-date with the server and
+ * Places, and discards the mirror on mismatch.
+ *
+ * The bookmarks engine store the same sync ID in Places and the mirror to
+ * "tie" the two together. This allows Sync to do the right thing if the
+ * database files are copied between profiles connected to different accounts.
+ *
+ * See `PlacesSyncUtils.bookmarks.ensureCurrentSyncId` for an explanation of
+ * how Places handles sync ID mismatches.
+ *
+ * @param {String} newSyncId
+ * The server's sync ID.
+ */
+ async ensureCurrentSyncId(newSyncId) {
+ if (!newSyncId || typeof newSyncId != "string") {
+ throw new TypeError("Invalid new bookmarks sync ID");
+ }
+ let existingSyncId = await this.getSyncId();
+ if (existingSyncId == newSyncId) {
+ lazy.MirrorLog.trace("Sync ID up-to-date in mirror", { existingSyncId });
+ return;
+ }
+ lazy.MirrorLog.info(
+ "Sync ID changed from ${existingSyncId} to " +
+ "${newSyncId}; resetting mirror",
+ { existingSyncId, newSyncId }
+ );
+ await this.db.executeBeforeShutdown(
+ "SyncedBookmarksMirror: ensureCurrentSyncId",
+ db =>
+ db.executeTransaction(async function () {
+ await resetMirror(db);
+ await db.execute(
+ `
+ REPLACE INTO meta(key, value)
+ VALUES(:syncIdKey, :newSyncId)`,
+ { syncIdKey: SyncedBookmarksMirror.META_KEY.SYNC_ID, newSyncId }
+ );
+ })
+ );
+ }
+
+ /**
+ * Stores incoming or uploaded Sync records in the mirror. Rejects if any
+ * records are invalid.
+ *
+ * @param {PlacesItem[]} records
+ * Sync records to store in the mirror.
+ * @param {Boolean} [options.needsMerge]
+ * Indicates if the records were changed remotely since the last sync,
+ * and should be merged into the local tree. This option is set to
+ * `true` for incoming records, and `false` for successfully uploaded
+ * records. Tests can also pass `false` to set up an existing mirror.
+ * @param {AbortSignal} [options.signal]
+ * An abort signal that can be used to interrupt the operation. If
+ * omitted, storing incoming items can still be interrupted when the
+ * mirror is finalized.
+ */
+ async store(records, { needsMerge = true, signal = null } = {}) {
+ let options = {
+ needsMerge,
+ signal: anyAborted(this.finalizeController.signal, signal),
+ };
+ await this.db.executeBeforeShutdown("SyncedBookmarksMirror: store", db =>
+ db.executeTransaction(async () => {
+ for (let record of records) {
+ if (options.signal.aborted) {
+ throw new SyncedBookmarksMirror.InterruptedError(
+ "Interrupted while storing incoming items"
+ );
+ }
+ let guid = lazy.PlacesSyncUtils.bookmarks.recordIdToGuid(record.id);
+ if (guid == lazy.PlacesUtils.bookmarks.rootGuid) {
+ // The engine should hard DELETE Places roots from the server.
+ throw new TypeError("Can't store Places root");
+ }
+ if (lazy.MirrorLog.level <= lazy.Log.Level.Trace) {
+ lazy.MirrorLog.trace(
+ `Storing in mirror: ${record.cleartextToString()}`
+ );
+ }
+ switch (record.type) {
+ case "bookmark":
+ await this.storeRemoteBookmark(record, options);
+ continue;
+
+ case "query":
+ await this.storeRemoteQuery(record, options);
+ continue;
+
+ case "folder":
+ await this.storeRemoteFolder(record, options);
+ continue;
+
+ case "livemark":
+ await this.storeRemoteLivemark(record, options);
+ continue;
+
+ case "separator":
+ await this.storeRemoteSeparator(record, options);
+ continue;
+
+ default:
+ if (record.deleted) {
+ await this.storeRemoteTombstone(record, options);
+ continue;
+ }
+ }
+ lazy.MirrorLog.warn("Ignoring record with unknown type", record.type);
+ }
+ })
+ );
+ }
+
+ /**
+ * Builds a complete merged tree from the local and remote trees, resolves
+ * value and structure conflicts, dedupes local items, applies the merged
+ * tree back to Places, and notifies observers about the changes.
+ *
+ * Merging and application happen in a transaction, meaning code that uses the
+ * main Places connection, including the UI, will fail to write to the
+ * database until the transaction commits. Asynchronous consumers will retry
+ * on `SQLITE_BUSY`; synchronous consumers will fail after waiting for 100ms.
+ * See bug 1305563, comment 122 for details.
+ *
+ * @param {Number} [options.localTimeSeconds]
+ * The current local time, in seconds.
+ * @param {Number} [options.remoteTimeSeconds]
+ * The current server time, in seconds.
+ * @param {Boolean} [options.notifyInStableOrder]
+ * If `true`, fire observer notifications for items in the same folder
+ * in a stable order. This is disabled by default, to avoid the cost
+ * of sorting the notifications, but enabled in some tests to simplify
+ * their checks.
+ * @param {AbortSignal} [options.signal]
+ * An abort signal that can be used to interrupt a merge when its
+ * associated `AbortController` is aborted. If omitted, the merge can
+ * still be interrupted when the mirror is finalized.
+ * @return {Object.<String, BookmarkChangeRecord>}
+ * A changeset containing locally changed and reconciled records to
+ * upload to the server, and to store in the mirror once upload
+ * succeeds.
+ */
+ async apply({
+ localTimeSeconds,
+ remoteTimeSeconds,
+ notifyInStableOrder,
+ signal = null,
+ } = {}) {
+ // We intentionally don't use `executeBeforeShutdown` in this function,
+ // since merging can take a while for large trees, and we don't want to
+ // block shutdown. Since all new items are in the mirror, we'll just try
+ // to merge again on the next sync.
+
+ let finalizeOrInterruptSignal = anyAborted(
+ this.finalizeController.signal,
+ signal
+ );
+
+ let changeRecords;
+ try {
+ changeRecords = await this.tryApply(
+ finalizeOrInterruptSignal,
+ localTimeSeconds,
+ remoteTimeSeconds,
+ notifyInStableOrder
+ );
+ } finally {
+ this.progress.reset();
+ }
+
+ return changeRecords;
+ }
+
+ async tryApply(
+ signal,
+ localTimeSeconds,
+ remoteTimeSeconds,
+ notifyInStableOrder = false
+ ) {
+ let wasMerged = await withTiming("Merging bookmarks in Rust", () =>
+ this.merge(signal, localTimeSeconds, remoteTimeSeconds)
+ );
+
+ if (!wasMerged) {
+ lazy.MirrorLog.debug("No changes detected in both mirror and Places");
+ return {};
+ }
+
+ // At this point, the database is consistent, so we can notify observers and
+ // inflate records for outgoing items.
+
+ let observersToNotify = new BookmarkObserverRecorder(this.db, {
+ signal,
+ notifyInStableOrder,
+ });
+
+ await withTiming(
+ "Notifying Places observers",
+ async () => {
+ try {
+ // Note that we don't use a transaction when fetching info for
+ // observers, so it's possible we might notify with stale info if the
+ // main connection changes Places between the time we finish merging,
+ // and the time we notify observers.
+ await observersToNotify.notifyAll();
+ } catch (ex) {
+ // Places relies on observer notifications to update internal caches.
+ // If notifying observers failed, these caches may be inconsistent,
+ // so we invalidate them just in case.
+ lazy.PlacesUtils.invalidateCachedGuids();
+ await lazy.PlacesUtils.keywords.invalidateCachedKeywords();
+ lazy.MirrorLog.warn("Error notifying Places observers", ex);
+ } finally {
+ await this.db.executeTransaction(async () => {
+ await this.db.execute(`DELETE FROM itemsAdded`);
+ await this.db.execute(`DELETE FROM guidsChanged`);
+ await this.db.execute(`DELETE FROM itemsChanged`);
+ await this.db.execute(`DELETE FROM itemsRemoved`);
+ await this.db.execute(`DELETE FROM itemsMoved`);
+ });
+ }
+ },
+ time =>
+ this.progress.stepWithTelemetry(
+ ProgressTracker.STEPS.NOTIFY_OBSERVERS,
+ time
+ )
+ );
+
+ let { changeRecords } = await withTiming(
+ "Fetching records for local items to upload",
+ async () => {
+ try {
+ let result = await this.fetchLocalChangeRecords(signal);
+ return result;
+ } finally {
+ await this.db.execute(`DELETE FROM itemsToUpload`);
+ }
+ },
+ (time, result) =>
+ this.progress.stepWithItemCount(
+ ProgressTracker.STEPS.FETCH_LOCAL_CHANGE_RECORDS,
+ time,
+ result.count
+ )
+ );
+
+ return changeRecords;
+ }
+
+ merge(signal, localTimeSeconds = Date.now() / 1000, remoteTimeSeconds = 0) {
+ return new Promise((resolve, reject) => {
+ let op = null;
+ function onAbort() {
+ signal.removeEventListener("abort", onAbort);
+ op.cancel();
+ }
+ let callback = {
+ QueryInterface: ChromeUtils.generateQI([
+ "mozISyncedBookmarksMirrorProgressListener",
+ "mozISyncedBookmarksMirrorCallback",
+ ]),
+ // `mozISyncedBookmarksMirrorProgressListener` methods.
+ onFetchLocalTree: (took, itemCount, deleteCount, problemsBag) => {
+ let counts = [
+ {
+ name: "items",
+ count: itemCount,
+ },
+ {
+ name: "deletions",
+ count: deleteCount,
+ },
+ ];
+ this.progress.stepWithTelemetry(
+ ProgressTracker.STEPS.FETCH_LOCAL_TREE,
+ took,
+ counts
+ );
+ // We don't record local tree problems in validation telemetry.
+ },
+ onFetchRemoteTree: (took, itemCount, deleteCount, problemsBag) => {
+ let counts = [
+ {
+ name: "items",
+ count: itemCount,
+ },
+ {
+ name: "deletions",
+ count: deleteCount,
+ },
+ ];
+ this.progress.stepWithTelemetry(
+ ProgressTracker.STEPS.FETCH_REMOTE_TREE,
+ took,
+ counts
+ );
+ // Record validation telemetry for problems in the remote tree.
+ let problems = bagToNamedCounts(problemsBag, [
+ "orphans",
+ "misparentedRoots",
+ "multipleParents",
+ "nonFolderParents",
+ "parentChildDisagreements",
+ "missingChildren",
+ ]);
+ let checked = itemCount + deleteCount;
+ this.recordValidationTelemetry(took, checked, problems);
+ },
+ onMerge: (took, countsBag) => {
+ let counts = bagToNamedCounts(countsBag, [
+ "items",
+ "dupes",
+ "remoteRevives",
+ "localDeletes",
+ "localRevives",
+ "remoteDeletes",
+ ]);
+ this.progress.stepWithTelemetry(
+ ProgressTracker.STEPS.MERGE,
+ took,
+ counts
+ );
+ },
+ onApply: took => {
+ this.progress.stepWithTelemetry(ProgressTracker.STEPS.APPLY, took);
+ },
+ // `mozISyncedBookmarksMirrorCallback` methods.
+ handleSuccess(result) {
+ signal.removeEventListener("abort", onAbort);
+ resolve(result);
+ },
+ handleError(code, message) {
+ signal.removeEventListener("abort", onAbort);
+ switch (code) {
+ case Cr.NS_ERROR_STORAGE_BUSY:
+ reject(new SyncedBookmarksMirror.MergeConflictError(message));
+ break;
+
+ case Cr.NS_ERROR_ABORT:
+ reject(new SyncedBookmarksMirror.InterruptedError(message));
+ break;
+
+ default:
+ reject(new SyncedBookmarksMirror.MergeError(message));
+ }
+ },
+ };
+ op = this.merger.merge(localTimeSeconds, remoteTimeSeconds, callback);
+ if (signal.aborted) {
+ op.cancel();
+ } else {
+ signal.addEventListener("abort", onAbort);
+ }
+ });
+ }
+
+ /**
+ * Discards the mirror contents. This is called when the user is node
+ * reassigned, disables the bookmarks engine, or signs out.
+ */
+ async reset() {
+ await this.db.executeBeforeShutdown("SyncedBookmarksMirror: reset", db =>
+ db.executeTransaction(() => resetMirror(db))
+ );
+ }
+
+ /**
+ * Fetches the GUIDs of all items in the remote tree that need to be merged
+ * into the local tree.
+ *
+ * @return {String[]}
+ * Remotely changed GUIDs that need to be merged into Places.
+ */
+ async fetchUnmergedGuids() {
+ let rows = await this.db.execute(`
+ SELECT guid FROM items
+ WHERE needsMerge
+ ORDER BY guid`);
+ return rows.map(row => row.getResultByName("guid"));
+ }
+
+ async storeRemoteBookmark(record, { needsMerge, signal }) {
+ let guid = lazy.PlacesSyncUtils.bookmarks.recordIdToGuid(record.id);
+
+ let url = validateURL(record.bmkUri);
+ if (url) {
+ await this.maybeStoreRemoteURL(url);
+ }
+
+ let parentGuid = lazy.PlacesSyncUtils.bookmarks.recordIdToGuid(
+ record.parentid
+ );
+ let serverModified = determineServerModified(record);
+ let dateAdded = determineDateAdded(record);
+ let title = validateTitle(record.title);
+ let keyword = validateKeyword(record.keyword);
+ let validity = url
+ ? Ci.mozISyncedBookmarksMerger.VALIDITY_VALID
+ : Ci.mozISyncedBookmarksMerger.VALIDITY_REPLACE;
+
+ await this.db.executeCached(
+ `
+ REPLACE INTO items(guid, parentGuid, serverModified, needsMerge, kind,
+ dateAdded, title, keyword, validity,
+ urlId)
+ VALUES(:guid, :parentGuid, :serverModified, :needsMerge, :kind,
+ :dateAdded, NULLIF(:title, ''), :keyword, :validity,
+ (SELECT id FROM urls
+ WHERE hash = hash(:url) AND
+ url = :url))`,
+ {
+ guid,
+ parentGuid,
+ serverModified,
+ needsMerge,
+ kind: Ci.mozISyncedBookmarksMerger.KIND_BOOKMARK,
+ dateAdded,
+ title,
+ keyword,
+ url: url ? url.href : null,
+ validity,
+ }
+ );
+
+ let tags = record.tags;
+ if (tags && Array.isArray(tags)) {
+ for (let rawTag of tags) {
+ if (signal.aborted) {
+ throw new SyncedBookmarksMirror.InterruptedError(
+ "Interrupted while storing tags for incoming bookmark"
+ );
+ }
+ let tag = validateTag(rawTag);
+ if (!tag) {
+ continue;
+ }
+ await this.db.executeCached(
+ `
+ INSERT INTO tags(itemId, tag)
+ SELECT id, :tag FROM items
+ WHERE guid = :guid`,
+ { tag, guid }
+ );
+ }
+ }
+ }
+
+ async storeRemoteQuery(record, { needsMerge }) {
+ let guid = lazy.PlacesSyncUtils.bookmarks.recordIdToGuid(record.id);
+
+ let validity = Ci.mozISyncedBookmarksMerger.VALIDITY_VALID;
+
+ let url = validateURL(record.bmkUri);
+ if (url) {
+ // The query has a valid URL. Determine if we need to rewrite and reupload
+ // it.
+ let params = new URLSearchParams(url.href.slice(url.protocol.length));
+ let type = +params.get("type");
+ if (type == Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAG_CONTENTS) {
+ // Legacy tag queries with this type use a `place:` URL with a `folder`
+ // param that points to the tag folder ID. Rewrite the query to directly
+ // reference the tag stored in its `folderName`, then flag the rewritten
+ // query for reupload.
+ let tagFolderName = validateTag(record.folderName);
+ if (tagFolderName) {
+ try {
+ url.href = `place:tag=${tagFolderName}`;
+ validity = Ci.mozISyncedBookmarksMerger.VALIDITY_REUPLOAD;
+ } catch (ex) {
+ // The tag folder name isn't URL-encoded (bug 1449939), so we might
+ // produce an invalid URL. However, invalid URLs are already likely
+ // to cause other issues, and it's better to replace or delete the
+ // query than break syncing or the Firefox UI.
+ url = null;
+ }
+ } else {
+ // The tag folder name is invalid, so replace or delete the remote
+ // copy.
+ url = null;
+ }
+ } else {
+ let folder = params.get("folder");
+ if (folder && !params.has("excludeItems")) {
+ // We don't sync enough information to rewrite other queries with a
+ // `folder` param (bug 1377175). Referencing a nonexistent folder ID
+ // causes the query to return all items in the database, so we add
+ // `excludeItems=1` to stop it from doing so. We also flag the
+ // rewritten query for reupload.
+ try {
+ url.href = `${url.href}&excludeItems=1`;
+ validity = Ci.mozISyncedBookmarksMerger.VALIDITY_REUPLOAD;
+ } catch (ex) {
+ url = null;
+ }
+ }
+ }
+
+ // Other queries are implicitly valid, and don't need to be reuploaded
+ // or replaced.
+ }
+
+ if (url) {
+ await this.maybeStoreRemoteURL(url);
+ } else {
+ // If the query doesn't have a valid URL, we must replace the remote copy
+ // with either a valid local copy, or a tombstone if the query doesn't
+ // exist locally.
+ validity = Ci.mozISyncedBookmarksMerger.VALIDITY_REPLACE;
+ }
+
+ let parentGuid = lazy.PlacesSyncUtils.bookmarks.recordIdToGuid(
+ record.parentid
+ );
+ let serverModified = determineServerModified(record);
+ let dateAdded = determineDateAdded(record);
+ let title = validateTitle(record.title);
+
+ await this.db.executeCached(
+ `
+ REPLACE INTO items(guid, parentGuid, serverModified, needsMerge, kind,
+ dateAdded, title,
+ urlId,
+ validity)
+ VALUES(:guid, :parentGuid, :serverModified, :needsMerge, :kind,
+ :dateAdded, NULLIF(:title, ''),
+ (SELECT id FROM urls
+ WHERE hash = hash(:url) AND
+ url = :url),
+ :validity)`,
+ {
+ guid,
+ parentGuid,
+ serverModified,
+ needsMerge,
+ kind: Ci.mozISyncedBookmarksMerger.KIND_QUERY,
+ dateAdded,
+ title,
+ url: url ? url.href : null,
+ validity,
+ }
+ );
+ }
+
+ async storeRemoteFolder(record, { needsMerge, signal }) {
+ let guid = lazy.PlacesSyncUtils.bookmarks.recordIdToGuid(record.id);
+ let parentGuid = lazy.PlacesSyncUtils.bookmarks.recordIdToGuid(
+ record.parentid
+ );
+ let serverModified = determineServerModified(record);
+ let dateAdded = determineDateAdded(record);
+ let title = validateTitle(record.title);
+
+ await this.db.executeCached(
+ `
+ REPLACE INTO items(guid, parentGuid, serverModified, needsMerge, kind,
+ dateAdded, title)
+ VALUES(:guid, :parentGuid, :serverModified, :needsMerge, :kind,
+ :dateAdded, NULLIF(:title, ''))`,
+ {
+ guid,
+ parentGuid,
+ serverModified,
+ needsMerge,
+ kind: Ci.mozISyncedBookmarksMerger.KIND_FOLDER,
+ dateAdded,
+ title,
+ }
+ );
+
+ let children = record.children;
+ if (children && Array.isArray(children)) {
+ let offset = 0;
+ for (let chunk of lazy.PlacesUtils.chunkArray(
+ children,
+ this.db.variableLimit - 1
+ )) {
+ if (signal.aborted) {
+ throw new SyncedBookmarksMirror.InterruptedError(
+ "Interrupted while storing children for incoming folder"
+ );
+ }
+ // Builds a fragment like `(?2, ?1, 0), (?3, ?1, 1), ...`, where ?1 is
+ // the folder's GUID, [?2, ?3] are the first and second child GUIDs
+ // (SQLite binding parameters index from 1), and [0, 1] are the
+ // positions. This lets us store the folder's children using as few
+ // statements as possible.
+ let valuesFragment = Array.from(
+ { length: chunk.length },
+ (_, index) => `(?${index + 2}, ?1, ${offset + index})`
+ ).join(",");
+ await this.db.execute(
+ `
+ INSERT INTO structure(guid, parentGuid, position)
+ VALUES ${valuesFragment}`,
+ [guid, ...chunk.map(lazy.PlacesSyncUtils.bookmarks.recordIdToGuid)]
+ );
+ offset += chunk.length;
+ }
+ }
+ }
+
+ async storeRemoteLivemark(record, { needsMerge }) {
+ let guid = lazy.PlacesSyncUtils.bookmarks.recordIdToGuid(record.id);
+ let parentGuid = lazy.PlacesSyncUtils.bookmarks.recordIdToGuid(
+ record.parentid
+ );
+ let serverModified = determineServerModified(record);
+ let feedURL = validateURL(record.feedUri);
+ let dateAdded = determineDateAdded(record);
+ let title = validateTitle(record.title);
+ let siteURL = validateURL(record.siteUri);
+
+ let validity = feedURL
+ ? Ci.mozISyncedBookmarksMerger.VALIDITY_VALID
+ : Ci.mozISyncedBookmarksMerger.VALIDITY_REPLACE;
+
+ await this.db.executeCached(
+ `
+ REPLACE INTO items(guid, parentGuid, serverModified, needsMerge, kind,
+ dateAdded, title, feedURL, siteURL, validity)
+ VALUES(:guid, :parentGuid, :serverModified, :needsMerge, :kind,
+ :dateAdded, NULLIF(:title, ''), :feedURL, :siteURL, :validity)`,
+ {
+ guid,
+ parentGuid,
+ serverModified,
+ needsMerge,
+ kind: Ci.mozISyncedBookmarksMerger.KIND_LIVEMARK,
+ dateAdded,
+ title,
+ feedURL: feedURL ? feedURL.href : null,
+ siteURL: siteURL ? siteURL.href : null,
+ validity,
+ }
+ );
+ }
+
+ async storeRemoteSeparator(record, { needsMerge }) {
+ let guid = lazy.PlacesSyncUtils.bookmarks.recordIdToGuid(record.id);
+ let parentGuid = lazy.PlacesSyncUtils.bookmarks.recordIdToGuid(
+ record.parentid
+ );
+ let serverModified = determineServerModified(record);
+ let dateAdded = determineDateAdded(record);
+
+ await this.db.executeCached(
+ `
+ REPLACE INTO items(guid, parentGuid, serverModified, needsMerge, kind,
+ dateAdded)
+ VALUES(:guid, :parentGuid, :serverModified, :needsMerge, :kind,
+ :dateAdded)`,
+ {
+ guid,
+ parentGuid,
+ serverModified,
+ needsMerge,
+ kind: Ci.mozISyncedBookmarksMerger.KIND_SEPARATOR,
+ dateAdded,
+ }
+ );
+ }
+
+ async storeRemoteTombstone(record, { needsMerge }) {
+ let guid = lazy.PlacesSyncUtils.bookmarks.recordIdToGuid(record.id);
+ let serverModified = determineServerModified(record);
+
+ await this.db.executeCached(
+ `
+ REPLACE INTO items(guid, serverModified, needsMerge, isDeleted)
+ VALUES(:guid, :serverModified, :needsMerge, 1)`,
+ { guid, serverModified, needsMerge }
+ );
+ }
+
+ async maybeStoreRemoteURL(url) {
+ await this.db.executeCached(
+ `
+ INSERT OR IGNORE INTO urls(guid, url, hash, revHost)
+ VALUES(IFNULL((SELECT guid FROM urls
+ WHERE hash = hash(:url) AND
+ url = :url),
+ GENERATE_GUID()), :url, hash(:url), :revHost)`,
+ { url: url.href, revHost: lazy.PlacesUtils.getReversedHost(url) }
+ );
+ }
+
+ /**
+ * Inflates Sync records for all staged outgoing items.
+ *
+ * @param {AbortSignal} signal
+ * Stops fetching records when the associated `AbortController`
+ * is aborted.
+ * @return {Object}
+ * A `{ changeRecords, count }` tuple, where `changeRecords` is a
+ * changeset containing Sync record cleartexts for outgoing items and
+ * tombstones, keyed by their Sync record IDs, and `count` is the
+ * number of records.
+ */
+ async fetchLocalChangeRecords(signal) {
+ let changeRecords = {};
+ let childRecordIdsByLocalParentId = new Map();
+ let tagsByLocalId = new Map();
+
+ let childGuidRows = [];
+ await this.db.execute(
+ `SELECT parentId, guid FROM structureToUpload
+ ORDER BY parentId, position`,
+ null,
+ (row, cancel) => {
+ if (signal.aborted) {
+ cancel();
+ } else {
+ // `Sqlite.sys.mjs` callbacks swallow exceptions (bug 1387775), so we
+ // accumulate all rows in an array, and process them after.
+ childGuidRows.push(row);
+ }
+ }
+ );
+
+ await lazy.Async.yieldingForEach(
+ childGuidRows,
+ row => {
+ if (signal.aborted) {
+ throw new SyncedBookmarksMirror.InterruptedError(
+ "Interrupted while fetching structure to upload"
+ );
+ }
+ let localParentId = row.getResultByName("parentId");
+ let childRecordId = lazy.PlacesSyncUtils.bookmarks.guidToRecordId(
+ row.getResultByName("guid")
+ );
+ let childRecordIds = childRecordIdsByLocalParentId.get(localParentId);
+ if (childRecordIds) {
+ childRecordIds.push(childRecordId);
+ } else {
+ childRecordIdsByLocalParentId.set(localParentId, [childRecordId]);
+ }
+ },
+ lazy.yieldState
+ );
+
+ let tagRows = [];
+ await this.db.execute(
+ `SELECT id, tag FROM tagsToUpload`,
+ null,
+ (row, cancel) => {
+ if (signal.aborted) {
+ cancel();
+ } else {
+ tagRows.push(row);
+ }
+ }
+ );
+
+ await lazy.Async.yieldingForEach(
+ tagRows,
+ row => {
+ if (signal.aborted) {
+ throw new SyncedBookmarksMirror.InterruptedError(
+ "Interrupted while fetching tags to upload"
+ );
+ }
+ let localId = row.getResultByName("id");
+ let tag = row.getResultByName("tag");
+ let tags = tagsByLocalId.get(localId);
+ if (tags) {
+ tags.push(tag);
+ } else {
+ tagsByLocalId.set(localId, [tag]);
+ }
+ },
+ lazy.yieldState
+ );
+
+ let itemRows = [];
+ await this.db.execute(
+ `SELECT id, syncChangeCounter, guid, isDeleted, type, isQuery,
+ tagFolderName, keyword, url, IFNULL(title, '') AS title,
+ position, parentGuid,
+ IFNULL(parentTitle, '') AS parentTitle, dateAdded
+ FROM itemsToUpload`,
+ null,
+ (row, cancel) => {
+ if (signal.interrupted) {
+ cancel();
+ } else {
+ itemRows.push(row);
+ }
+ }
+ );
+
+ await lazy.Async.yieldingForEach(
+ itemRows,
+ row => {
+ if (signal.aborted) {
+ throw new SyncedBookmarksMirror.InterruptedError(
+ "Interrupted while fetching items to upload"
+ );
+ }
+ let syncChangeCounter = row.getResultByName("syncChangeCounter");
+
+ let guid = row.getResultByName("guid");
+ let recordId = lazy.PlacesSyncUtils.bookmarks.guidToRecordId(guid);
+
+ // Tombstones don't carry additional properties.
+ let isDeleted = row.getResultByName("isDeleted");
+ if (isDeleted) {
+ changeRecords[recordId] = new BookmarkChangeRecord(
+ syncChangeCounter,
+ {
+ id: recordId,
+ deleted: true,
+ }
+ );
+ return;
+ }
+
+ let parentGuid = row.getResultByName("parentGuid");
+ let parentRecordId =
+ lazy.PlacesSyncUtils.bookmarks.guidToRecordId(parentGuid);
+
+ let type = row.getResultByName("type");
+ switch (type) {
+ case lazy.PlacesUtils.bookmarks.TYPE_BOOKMARK: {
+ let isQuery = row.getResultByName("isQuery");
+ if (isQuery) {
+ let queryCleartext = {
+ id: recordId,
+ type: "query",
+ // We ignore `parentid` and use the parent's `children`, but older
+ // Desktops and Android use `parentid` as the canonical parent.
+ // iOS is stricter and requires both `children` and `parentid` to
+ // match.
+ parentid: parentRecordId,
+ // Older Desktops use `hasDupe` (along with `parentName` for
+ // deduping), if hasDupe is true, then they won't attempt deduping
+ // (since they believe that a duplicate for this record should
+ // exist). We set it to true to prevent them from applying their
+ // deduping logic.
+ hasDupe: true,
+ parentName: row.getResultByName("parentTitle"),
+ // Omit `dateAdded` from the record if it's not set locally.
+ dateAdded: row.getResultByName("dateAdded") || undefined,
+ bmkUri: row.getResultByName("url"),
+ title: row.getResultByName("title"),
+ // folderName should never be an empty string or null
+ folderName: row.getResultByName("tagFolderName") || undefined,
+ };
+ changeRecords[recordId] = new BookmarkChangeRecord(
+ syncChangeCounter,
+ queryCleartext
+ );
+ return;
+ }
+
+ let bookmarkCleartext = {
+ id: recordId,
+ type: "bookmark",
+ parentid: parentRecordId,
+ hasDupe: true,
+ parentName: row.getResultByName("parentTitle"),
+ dateAdded: row.getResultByName("dateAdded") || undefined,
+ bmkUri: row.getResultByName("url"),
+ title: row.getResultByName("title"),
+ };
+ let keyword = row.getResultByName("keyword");
+ if (keyword) {
+ bookmarkCleartext.keyword = keyword;
+ }
+ let localId = row.getResultByName("id");
+ let tags = tagsByLocalId.get(localId);
+ if (tags) {
+ bookmarkCleartext.tags = tags;
+ }
+ changeRecords[recordId] = new BookmarkChangeRecord(
+ syncChangeCounter,
+ bookmarkCleartext
+ );
+ return;
+ }
+
+ case lazy.PlacesUtils.bookmarks.TYPE_FOLDER: {
+ let folderCleartext = {
+ id: recordId,
+ type: "folder",
+ parentid: parentRecordId,
+ hasDupe: true,
+ parentName: row.getResultByName("parentTitle"),
+ dateAdded: row.getResultByName("dateAdded") || undefined,
+ title: row.getResultByName("title"),
+ };
+ let localId = row.getResultByName("id");
+ let childRecordIds = childRecordIdsByLocalParentId.get(localId);
+ folderCleartext.children = childRecordIds || [];
+ changeRecords[recordId] = new BookmarkChangeRecord(
+ syncChangeCounter,
+ folderCleartext
+ );
+ return;
+ }
+
+ case lazy.PlacesUtils.bookmarks.TYPE_SEPARATOR: {
+ let separatorCleartext = {
+ id: recordId,
+ type: "separator",
+ parentid: parentRecordId,
+ hasDupe: true,
+ parentName: row.getResultByName("parentTitle"),
+ dateAdded: row.getResultByName("dateAdded") || undefined,
+ // Older Desktops use `pos` for deduping.
+ pos: row.getResultByName("position"),
+ };
+ changeRecords[recordId] = new BookmarkChangeRecord(
+ syncChangeCounter,
+ separatorCleartext
+ );
+ return;
+ }
+
+ default:
+ throw new TypeError("Can't create record for unknown Places item");
+ }
+ },
+ lazy.yieldState
+ );
+
+ return { changeRecords, count: itemRows.length };
+ }
+
+ /**
+ * Closes the mirror database connection. This is called automatically on
+ * shutdown, but may also be called explicitly when the mirror is no longer
+ * needed.
+ *
+ * @param {Boolean} [options.alsoCleanup]
+ * If specified, drop all temp tables, views, and triggers,
+ * and detach from the mirror database before closing the
+ * connection. Defaults to `true`.
+ */
+ finalize({ alsoCleanup = true } = {}) {
+ if (!this.finalizePromise) {
+ this.finalizePromise = (async () => {
+ this.progress.step(ProgressTracker.STEPS.FINALIZE);
+ this.finalizeController.abort();
+ this.merger.reset();
+ if (alsoCleanup) {
+ // If the mirror is finalized explicitly, clean up temp entities and
+ // detach from the mirror database. We can skip this for automatic
+ // finalization, since the Places connection is already shutting
+ // down.
+ await cleanupMirrorDatabase(this.db);
+ }
+ await this.db.execute(`PRAGMA mirror.optimize(0x02)`);
+ await this.db.execute(`DETACH mirror`);
+ this.finalizeAt.removeBlocker(this.finalizeBound);
+ })();
+ }
+ return this.finalizePromise;
+ }
+}
+
+/** Key names for the key-value `meta` table. */
+SyncedBookmarksMirror.META_KEY = {
+ LAST_MODIFIED: "collection/lastModified",
+ SYNC_ID: "collection/syncId",
+};
+
+/**
+ * An error thrown when the merge was interrupted.
+ */
+class InterruptedError extends Error {
+ constructor(message) {
+ super(message);
+ this.name = "InterruptedError";
+ }
+}
+SyncedBookmarksMirror.InterruptedError = InterruptedError;
+
+/**
+ * An error thrown when the merge failed for an unexpected reason.
+ */
+class MergeError extends Error {
+ constructor(message) {
+ super(message);
+ this.name = "MergeError";
+ }
+}
+SyncedBookmarksMirror.MergeError = MergeError;
+
+/**
+ * An error thrown when the merge can't proceed because the local tree
+ * changed during the merge.
+ */
+class MergeConflictError extends Error {
+ constructor(message) {
+ super(message);
+ this.name = "MergeConflictError";
+ }
+}
+SyncedBookmarksMirror.MergeConflictError = MergeConflictError;
+
+/**
+ * An error thrown when the mirror database is corrupt, or can't be migrated to
+ * the latest schema version, and must be replaced.
+ */
+class DatabaseCorruptError extends Error {
+ constructor(message) {
+ super(message);
+ this.name = "DatabaseCorruptError";
+ }
+}
+
+// Indicates if the mirror should be replaced because the database file is
+// corrupt.
+function isDatabaseCorrupt(error) {
+ if (error instanceof DatabaseCorruptError) {
+ return true;
+ }
+ if (error.errors) {
+ return error.errors.some(
+ error =>
+ error instanceof Ci.mozIStorageError &&
+ (error.result == Ci.mozIStorageError.CORRUPT ||
+ error.result == Ci.mozIStorageError.NOTADB)
+ );
+ }
+ return false;
+}
+
+/**
+ * Attaches a cloned Places database connection to the mirror database,
+ * migrates the mirror schema to the latest version, and creates temporary
+ * tables, views, and triggers.
+ *
+ * @param {Sqlite.OpenedConnection} db
+ * The Places database connection.
+ * @param {String} path
+ * The full path to the mirror database file.
+ */
+async function attachAndInitMirrorDatabase(db, path) {
+ await db.execute(`ATTACH :path AS mirror`, { path });
+ try {
+ await db.executeTransaction(async function () {
+ let currentSchemaVersion = await db.getSchemaVersion("mirror");
+ if (currentSchemaVersion > 0) {
+ if (currentSchemaVersion < MIRROR_SCHEMA_VERSION) {
+ await migrateMirrorSchema(db, currentSchemaVersion);
+ }
+ } else {
+ await initializeMirrorDatabase(db);
+ }
+ // Downgrading from a newer profile to an older profile rolls back the
+ // schema version, but leaves all new columns in place. We'll run the
+ // migration logic again on the next upgrade.
+ await db.setSchemaVersion(MIRROR_SCHEMA_VERSION, "mirror");
+ await initializeTempMirrorEntities(db);
+ });
+ } catch (ex) {
+ await db.execute(`DETACH mirror`);
+ throw ex;
+ }
+}
+
+/**
+ * Migrates the mirror database schema to the latest version.
+ *
+ * @param {Sqlite.OpenedConnection} db
+ * The mirror database connection.
+ * @param {Number} currentSchemaVersion
+ * The current mirror database schema version.
+ */
+async function migrateMirrorSchema(db, currentSchemaVersion) {
+ if (currentSchemaVersion < 5) {
+ // The mirror was pref'd off by default for schema versions 1-4.
+ throw new DatabaseCorruptError(
+ `Can't migrate from schema version ${currentSchemaVersion}; too old`
+ );
+ }
+ if (currentSchemaVersion < 6) {
+ await db.execute(`CREATE INDEX IF NOT EXISTS mirror.itemURLs ON
+ items(urlId)`);
+ await db.execute(`CREATE INDEX IF NOT EXISTS mirror.itemKeywords ON
+ items(keyword) WHERE keyword NOT NULL`);
+ }
+ if (currentSchemaVersion < 7) {
+ await db.execute(`CREATE INDEX IF NOT EXISTS mirror.structurePositions ON
+ structure(parentGuid, position)`);
+ }
+ if (currentSchemaVersion < 8) {
+ // Not really a "schema" update, but addresses the defect from bug 1635859.
+ // In short, every bookmark with a corresponding entry in the mirror should
+ // have syncStatus = NORMAL.
+ await db.execute(`UPDATE moz_bookmarks AS b
+ SET syncStatus = ${lazy.PlacesUtils.bookmarks.SYNC_STATUS.NORMAL}
+ WHERE EXISTS (SELECT 1 FROM mirror.items
+ WHERE guid = b.guid)`);
+ }
+}
+
+/**
+ * Initializes a new mirror database, creating persistent tables, indexes, and
+ * roots.
+ *
+ * @param {Sqlite.OpenedConnection} db
+ * The mirror database connection.
+ */
+async function initializeMirrorDatabase(db) {
+ // Key-value metadata table. Stores the server collection last modified time
+ // and sync ID.
+ await db.execute(`CREATE TABLE mirror.meta(
+ key TEXT PRIMARY KEY,
+ value NOT NULL
+ ) WITHOUT ROWID`);
+
+ // Note: description and loadInSidebar are not used as of Firefox 63, but
+ // remain to avoid rebuilding the database if the user happens to downgrade.
+ await db.execute(`CREATE TABLE mirror.items(
+ id INTEGER PRIMARY KEY,
+ guid TEXT UNIQUE NOT NULL,
+ /* The "parentid" from the record. */
+ parentGuid TEXT,
+ /* The server modified time, in milliseconds. */
+ serverModified INTEGER NOT NULL DEFAULT 0,
+ needsMerge BOOLEAN NOT NULL DEFAULT 0,
+ validity INTEGER NOT NULL DEFAULT ${Ci.mozISyncedBookmarksMerger.VALIDITY_VALID},
+ isDeleted BOOLEAN NOT NULL DEFAULT 0,
+ kind INTEGER NOT NULL DEFAULT -1,
+ /* The creation date, in milliseconds. */
+ dateAdded INTEGER NOT NULL DEFAULT 0,
+ title TEXT,
+ urlId INTEGER REFERENCES urls(id)
+ ON DELETE SET NULL,
+ keyword TEXT,
+ description TEXT,
+ loadInSidebar BOOLEAN,
+ smartBookmarkName TEXT,
+ feedURL TEXT,
+ siteURL TEXT
+ )`);
+
+ await db.execute(`CREATE TABLE mirror.structure(
+ guid TEXT,
+ parentGuid TEXT REFERENCES items(guid)
+ ON DELETE CASCADE,
+ position INTEGER NOT NULL,
+ PRIMARY KEY(parentGuid, guid)
+ ) WITHOUT ROWID`);
+
+ await db.execute(`CREATE TABLE mirror.urls(
+ id INTEGER PRIMARY KEY,
+ guid TEXT NOT NULL,
+ url TEXT NOT NULL,
+ hash INTEGER NOT NULL,
+ revHost TEXT NOT NULL
+ )`);
+
+ await db.execute(`CREATE TABLE mirror.tags(
+ itemId INTEGER NOT NULL REFERENCES items(id)
+ ON DELETE CASCADE,
+ tag TEXT NOT NULL
+ )`);
+
+ await db.execute(
+ `CREATE INDEX mirror.structurePositions ON structure(parentGuid, position)`
+ );
+
+ await db.execute(`CREATE INDEX mirror.urlHashes ON urls(hash)`);
+
+ await db.execute(`CREATE INDEX mirror.itemURLs ON items(urlId)`);
+
+ await db.execute(`CREATE INDEX mirror.itemKeywords ON items(keyword)
+ WHERE keyword NOT NULL`);
+
+ await createMirrorRoots(db);
+}
+
+/**
+ * Drops all temp tables, views, and triggers used for merging, and detaches
+ * from the mirror database.
+ *
+ * @param {Sqlite.OpenedConnection} db
+ * The mirror database connection.
+ */
+async function cleanupMirrorDatabase(db) {
+ await db.executeTransaction(async function () {
+ await db.execute(`DROP TABLE changeGuidOps`);
+ await db.execute(`DROP TABLE itemsToApply`);
+ await db.execute(`DROP TABLE applyNewLocalStructureOps`);
+ await db.execute(`DROP VIEW localTags`);
+ await db.execute(`DROP TABLE itemsAdded`);
+ await db.execute(`DROP TABLE guidsChanged`);
+ await db.execute(`DROP TABLE itemsChanged`);
+ await db.execute(`DROP TABLE itemsMoved`);
+ await db.execute(`DROP TABLE itemsRemoved`);
+ await db.execute(`DROP TABLE itemsToUpload`);
+ await db.execute(`DROP TABLE structureToUpload`);
+ await db.execute(`DROP TABLE tagsToUpload`);
+ });
+}
+
+/**
+ * Sets up the syncable roots. All items in the mirror we apply will descend
+ * from these roots - however, malformed records from the server which create
+ * a different root *will* be created in the mirror - just not applied.
+ *
+ *
+ * @param {Sqlite.OpenedConnection} db
+ * The mirror database connection.
+ */
+async function createMirrorRoots(db) {
+ const syncableRoots = [
+ {
+ guid: lazy.PlacesUtils.bookmarks.rootGuid,
+ // The Places root is its own parent, to satisfy the foreign key and
+ // `NOT NULL` constraints on `structure`.
+ parentGuid: lazy.PlacesUtils.bookmarks.rootGuid,
+ position: -1,
+ needsMerge: false,
+ },
+ ...lazy.PlacesUtils.bookmarks.userContentRoots.map((guid, position) => {
+ return {
+ guid,
+ parentGuid: lazy.PlacesUtils.bookmarks.rootGuid,
+ position,
+ needsMerge: true,
+ };
+ }),
+ ];
+
+ for (let { guid, parentGuid, position, needsMerge } of syncableRoots) {
+ await db.executeCached(
+ `
+ INSERT INTO items(guid, parentGuid, kind, needsMerge)
+ VALUES(:guid, :parentGuid, :kind, :needsMerge)`,
+ {
+ guid,
+ parentGuid,
+ kind: Ci.mozISyncedBookmarksMerger.KIND_FOLDER,
+ needsMerge,
+ }
+ );
+
+ await db.executeCached(
+ `
+ INSERT INTO structure(guid, parentGuid, position)
+ VALUES(:guid, :parentGuid, :position)`,
+ { guid, parentGuid, position }
+ );
+ }
+}
+
+/**
+ * Creates temporary tables, views, and triggers to apply the mirror to Places.
+ *
+ * @param {Sqlite.OpenedConnection} db
+ * The mirror database connection.
+ */
+async function initializeTempMirrorEntities(db) {
+ await db.execute(`CREATE TEMP TABLE changeGuidOps(
+ localGuid TEXT PRIMARY KEY,
+ mergedGuid TEXT UNIQUE NOT NULL,
+ syncStatus INTEGER,
+ level INTEGER NOT NULL,
+ lastModifiedMicroseconds INTEGER NOT NULL
+ ) WITHOUT ROWID`);
+
+ await db.execute(`
+ CREATE TEMP TRIGGER changeGuids
+ AFTER DELETE ON changeGuidOps
+ BEGIN
+ /* Record item changed notifications for the updated GUIDs. */
+ INSERT INTO guidsChanged(itemId, oldGuid, level)
+ SELECT b.id, OLD.localGuid, OLD.level
+ FROM moz_bookmarks b
+ WHERE b.guid = OLD.localGuid;
+
+ UPDATE moz_bookmarks SET
+ guid = OLD.mergedGuid,
+ lastModified = OLD.lastModifiedMicroseconds,
+ syncStatus = IFNULL(OLD.syncStatus, syncStatus)
+ WHERE guid = OLD.localGuid;
+ END`);
+
+ await db.execute(`CREATE TEMP TABLE itemsToApply(
+ mergedGuid TEXT PRIMARY KEY,
+ localId INTEGER UNIQUE,
+ remoteId INTEGER UNIQUE NOT NULL,
+ remoteGuid TEXT UNIQUE NOT NULL,
+ newLevel INTEGER NOT NULL,
+ newType INTEGER NOT NULL,
+ localDateAddedMicroseconds INTEGER,
+ remoteDateAddedMicroseconds INTEGER NOT NULL,
+ lastModifiedMicroseconds INTEGER NOT NULL,
+ oldTitle TEXT,
+ newTitle TEXT,
+ oldPlaceId INTEGER,
+ newPlaceId INTEGER,
+ newKeyword TEXT
+ )`);
+
+ await db.execute(`CREATE INDEX existingItems ON itemsToApply(localId)
+ WHERE localId NOT NULL`);
+
+ await db.execute(`CREATE INDEX oldPlaceIds ON itemsToApply(oldPlaceId)
+ WHERE oldPlaceId NOT NULL`);
+
+ await db.execute(`CREATE INDEX newPlaceIds ON itemsToApply(newPlaceId)
+ WHERE newPlaceId NOT NULL`);
+
+ await db.execute(`CREATE INDEX newKeywords ON itemsToApply(newKeyword)
+ WHERE newKeyword NOT NULL`);
+
+ await db.execute(`CREATE TEMP TABLE applyNewLocalStructureOps(
+ mergedGuid TEXT PRIMARY KEY,
+ mergedParentGuid TEXT NOT NULL,
+ position INTEGER NOT NULL,
+ level INTEGER NOT NULL,
+ lastModifiedMicroseconds INTEGER NOT NULL
+ ) WITHOUT ROWID`);
+
+ await db.execute(`
+ CREATE TEMP TRIGGER applyNewLocalStructure
+ AFTER DELETE ON applyNewLocalStructureOps
+ BEGIN
+ INSERT INTO itemsMoved(itemId, oldParentId, oldParentGuid, oldPosition,
+ level)
+ SELECT b.id, p.id, p.guid, b.position, OLD.level
+ FROM moz_bookmarks b
+ JOIN moz_bookmarks p ON p.id = b.parent
+ WHERE b.guid = OLD.mergedGuid;
+
+ UPDATE moz_bookmarks SET
+ parent = (SELECT id FROM moz_bookmarks
+ WHERE guid = OLD.mergedParentGuid),
+ position = OLD.position,
+ lastModified = OLD.lastModifiedMicroseconds
+ WHERE guid = OLD.mergedGuid;
+ END`);
+
+ // A view of local bookmark tags. Tags, like keywords, are associated with
+ // URLs, so two bookmarks with the same URL should have the same tags. Unlike
+ // keywords, one tag may be associated with many different URLs. Tags are also
+ // different because they're implemented as bookmarks under the hood. Each tag
+ // is stored as a folder under the tags root, and tagged URLs are stored as
+ // untitled bookmarks under these folders. This complexity can be removed once
+ // bug 424160 lands.
+ await db.execute(`
+ CREATE TEMP VIEW localTags(tagEntryId, tagEntryGuid, tagFolderId,
+ tagFolderGuid, tagEntryPosition, tagEntryType,
+ tag, placeId, lastModifiedMicroseconds) AS
+ SELECT b.id, b.guid, p.id, p.guid, b.position, b.type,
+ p.title, b.fk, b.lastModified
+ FROM moz_bookmarks b
+ JOIN moz_bookmarks p ON p.id = b.parent
+ WHERE b.type = ${lazy.PlacesUtils.bookmarks.TYPE_BOOKMARK} AND
+ p.parent = (SELECT id FROM moz_bookmarks
+ WHERE guid = '${lazy.PlacesUtils.bookmarks.tagsGuid}')`);
+
+ // Untags a URL by removing its tag entry.
+ await db.execute(`
+ CREATE TEMP TRIGGER untagLocalPlace
+ INSTEAD OF DELETE ON localTags
+ BEGIN
+ /* Record an item removed notification for the tag entry. */
+ INSERT INTO itemsRemoved(itemId, parentId, position, type, placeId, guid,
+ parentGuid, title, isUntagging)
+ VALUES(OLD.tagEntryId, OLD.tagFolderId, OLD.tagEntryPosition,
+ OLD.tagEntryType, OLD.placeId, OLD.tagEntryGuid,
+ OLD.tagFolderGuid, OLD.tag, 1);
+
+ DELETE FROM moz_bookmarks WHERE id = OLD.tagEntryId;
+
+ /* Fix the positions of the sibling tag entries. */
+ UPDATE moz_bookmarks SET
+ position = position - 1
+ WHERE parent = OLD.tagFolderId AND
+ position > OLD.tagEntryPosition;
+ END`);
+
+ // Tags a URL by creating a tag folder if it doesn't exist, then inserting a
+ // tag entry for the URL into the tag folder. `NEW.placeId` can be NULL, in
+ // which case we'll just create the tag folder.
+ await db.execute(`
+ CREATE TEMP TRIGGER tagLocalPlace
+ INSTEAD OF INSERT ON localTags
+ BEGIN
+ /* Ensure the tag folder exists. */
+ INSERT OR IGNORE INTO moz_bookmarks(guid, parent, position, type, title,
+ dateAdded, lastModified)
+ VALUES(IFNULL((SELECT b.guid FROM moz_bookmarks b
+ JOIN moz_bookmarks p ON p.id = b.parent
+ WHERE b.title = NEW.tag AND
+ p.guid = '${lazy.PlacesUtils.bookmarks.tagsGuid}'),
+ GENERATE_GUID()),
+ (SELECT id FROM moz_bookmarks
+ WHERE guid = '${lazy.PlacesUtils.bookmarks.tagsGuid}'),
+ (SELECT COUNT(*) FROM moz_bookmarks b
+ JOIN moz_bookmarks p ON p.id = b.parent
+ WHERE p.guid = '${lazy.PlacesUtils.bookmarks.tagsGuid}'),
+ ${lazy.PlacesUtils.bookmarks.TYPE_FOLDER}, NEW.tag,
+ NEW.lastModifiedMicroseconds,
+ NEW.lastModifiedMicroseconds);
+
+ /* Record an item added notification if we created a tag folder.
+ "CHANGES()" returns the number of rows affected by the INSERT above:
+ 1 if we created the folder, or 0 if the folder already existed. */
+ INSERT INTO itemsAdded(guid, isTagging)
+ SELECT b.guid, 1
+ FROM moz_bookmarks b
+ JOIN moz_bookmarks p ON p.id = b.parent
+ WHERE CHANGES() > 0 AND
+ b.title = NEW.tag AND
+ p.guid = '${lazy.PlacesUtils.bookmarks.tagsGuid}';
+
+ /* Add a tag entry for the URL under the tag folder. Omitting the place
+ ID creates a tag folder without tagging the URL. */
+ INSERT OR IGNORE INTO moz_bookmarks(guid, parent, position, type, fk,
+ dateAdded, lastModified)
+ SELECT IFNULL((SELECT b.guid FROM moz_bookmarks b
+ JOIN moz_bookmarks p ON p.id = b.parent
+ WHERE b.fk = NEW.placeId AND
+ p.title = NEW.tag AND
+ p.parent = (SELECT id FROM moz_bookmarks
+ WHERE guid = '${lazy.PlacesUtils.bookmarks.tagsGuid}')),
+ GENERATE_GUID()),
+ (SELECT b.id FROM moz_bookmarks b
+ JOIN moz_bookmarks p ON p.id = b.parent
+ WHERE p.guid = '${lazy.PlacesUtils.bookmarks.tagsGuid}' AND
+ b.title = NEW.tag),
+ (SELECT COUNT(*) FROM moz_bookmarks b
+ JOIN moz_bookmarks p ON p.id = b.parent
+ WHERE p.title = NEW.tag AND
+ p.parent = (SELECT id FROM moz_bookmarks
+ WHERE guid = '${lazy.PlacesUtils.bookmarks.tagsGuid}')),
+ ${lazy.PlacesUtils.bookmarks.TYPE_BOOKMARK}, NEW.placeId,
+ NEW.lastModifiedMicroseconds,
+ NEW.lastModifiedMicroseconds
+ WHERE NEW.placeId NOT NULL;
+
+ /* Record an item added notification for the tag entry. */
+ INSERT INTO itemsAdded(guid, isTagging)
+ SELECT b.guid, 1
+ FROM moz_bookmarks b
+ JOIN moz_bookmarks p ON p.id = b.parent
+ WHERE CHANGES() > 0 AND
+ b.fk = NEW.placeId AND
+ p.title = NEW.tag AND
+ p.parent = (SELECT id FROM moz_bookmarks
+ WHERE guid = '${lazy.PlacesUtils.bookmarks.tagsGuid}');
+ END`);
+
+ // Stores properties to pass to `onItem{Added, Changed, Moved, Removed}`
+ // bookmark observers for new, updated, moved, and deleted items.
+ await db.execute(`CREATE TEMP TABLE itemsAdded(
+ guid TEXT PRIMARY KEY,
+ isTagging BOOLEAN NOT NULL DEFAULT 0,
+ keywordChanged BOOLEAN NOT NULL DEFAULT 0,
+ level INTEGER NOT NULL DEFAULT -1
+ ) WITHOUT ROWID`);
+
+ await db.execute(`CREATE INDEX addedItemLevels ON itemsAdded(level)`);
+
+ await db.execute(`CREATE TEMP TABLE guidsChanged(
+ itemId INTEGER PRIMARY KEY,
+ oldGuid TEXT NOT NULL,
+ level INTEGER NOT NULL DEFAULT -1
+ )`);
+
+ await db.execute(`CREATE INDEX changedGuidLevels ON guidsChanged(level)`);
+
+ await db.execute(`CREATE TEMP TABLE itemsChanged(
+ itemId INTEGER PRIMARY KEY,
+ oldTitle TEXT,
+ oldPlaceId INTEGER,
+ keywordChanged BOOLEAN NOT NULL DEFAULT 0,
+ level INTEGER NOT NULL DEFAULT -1
+ )`);
+
+ await db.execute(`CREATE INDEX changedItemLevels ON itemsChanged(level)`);
+
+ await db.execute(`CREATE TEMP TABLE itemsMoved(
+ itemId INTEGER PRIMARY KEY,
+ oldParentId INTEGER NOT NULL,
+ oldParentGuid TEXT NOT NULL,
+ oldPosition INTEGER NOT NULL,
+ level INTEGER NOT NULL DEFAULT -1
+ )`);
+
+ await db.execute(`CREATE INDEX movedItemLevels ON itemsMoved(level)`);
+
+ await db.execute(`CREATE TEMP TABLE itemsRemoved(
+ itemId INTEGER PRIMARY KEY,
+ guid TEXT NOT NULL,
+ parentId INTEGER NOT NULL,
+ position INTEGER NOT NULL,
+ type INTEGER NOT NULL,
+ title TEXT NOT NULL,
+ placeId INTEGER,
+ parentGuid TEXT NOT NULL,
+ /* We record the original level of the removed item in the tree so that we
+ can notify children before parents. */
+ level INTEGER NOT NULL DEFAULT -1,
+ isUntagging BOOLEAN NOT NULL DEFAULT 0,
+ keywordRemoved BOOLEAN NOT NULL DEFAULT 0
+ )`);
+
+ await db.execute(
+ `CREATE INDEX removedItemLevels ON itemsRemoved(level DESC)`
+ );
+
+ // Stores locally changed items staged for upload.
+ await db.execute(`CREATE TEMP TABLE itemsToUpload(
+ id INTEGER PRIMARY KEY,
+ guid TEXT UNIQUE NOT NULL,
+ syncChangeCounter INTEGER NOT NULL,
+ isDeleted BOOLEAN NOT NULL DEFAULT 0,
+ parentGuid TEXT,
+ parentTitle TEXT,
+ dateAdded INTEGER, /* In milliseconds. */
+ type INTEGER,
+ title TEXT,
+ placeId INTEGER,
+ isQuery BOOLEAN NOT NULL DEFAULT 0,
+ url TEXT,
+ tagFolderName TEXT,
+ keyword TEXT,
+ position INTEGER
+ )`);
+
+ await db.execute(`CREATE TEMP TABLE structureToUpload(
+ guid TEXT PRIMARY KEY,
+ parentId INTEGER NOT NULL REFERENCES itemsToUpload(id)
+ ON DELETE CASCADE,
+ position INTEGER NOT NULL
+ ) WITHOUT ROWID`);
+
+ await db.execute(
+ `CREATE INDEX parentsToUpload ON structureToUpload(parentId, position)`
+ );
+
+ await db.execute(`CREATE TEMP TABLE tagsToUpload(
+ id INTEGER REFERENCES itemsToUpload(id)
+ ON DELETE CASCADE,
+ tag TEXT,
+ PRIMARY KEY(id, tag)
+ ) WITHOUT ROWID`);
+}
+
+async function resetMirror(db) {
+ await db.execute(`DELETE FROM meta`);
+ await db.execute(`DELETE FROM structure`);
+ await db.execute(`DELETE FROM items`);
+ await db.execute(`DELETE FROM urls`);
+
+ // Since we need to reset the modified times and merge flags for the syncable
+ // roots, we simply delete and recreate them.
+ await createMirrorRoots(db);
+}
+
+// Converts a Sync record's last modified time to milliseconds.
+function determineServerModified(record) {
+ return Math.max(record.modified * 1000, 0) || 0;
+}
+
+// Determines a Sync record's creation date.
+function determineDateAdded(record) {
+ let serverModified = determineServerModified(record);
+ return lazy.PlacesSyncUtils.bookmarks.ratchetTimestampBackwards(
+ record.dateAdded,
+ serverModified
+ );
+}
+
+function validateTitle(rawTitle) {
+ if (typeof rawTitle != "string" || !rawTitle) {
+ return null;
+ }
+ return rawTitle.slice(0, DB_TITLE_LENGTH_MAX);
+}
+
+function validateURL(rawURL) {
+ if (typeof rawURL != "string" || rawURL.length > DB_URL_LENGTH_MAX) {
+ return null;
+ }
+ let url = null;
+ try {
+ url = new URL(rawURL);
+ } catch (ex) {}
+ return url;
+}
+
+function validateKeyword(rawKeyword) {
+ if (typeof rawKeyword != "string") {
+ return null;
+ }
+ let keyword = rawKeyword.trim();
+ // Drop empty keywords.
+ return keyword ? keyword.toLowerCase() : null;
+}
+
+function validateTag(rawTag) {
+ if (typeof rawTag != "string") {
+ return null;
+ }
+ let tag = rawTag.trim();
+ if (!tag || tag.length > lazy.PlacesUtils.bookmarks.MAX_TAG_LENGTH) {
+ // Drop empty and oversized tags.
+ return null;
+ }
+ return tag;
+}
+
+/**
+ * Measures and logs the time taken to execute a function, using a monotonic
+ * clock.
+ *
+ * @param {String} name
+ * The name of the operation, used for logging.
+ * @param {Function} func
+ * The function to time.
+ * @param {Function} [recordTiming]
+ * An optional function with the signature `(time: Number)`, where
+ * `time` is the measured time.
+ * @return The return value of the timed function.
+ */
+async function withTiming(name, func, recordTiming) {
+ lazy.MirrorLog.debug(name);
+
+ let startTime = Cu.now();
+ let result = await func();
+ let elapsedTime = Cu.now() - startTime;
+
+ lazy.MirrorLog.debug(`${name} took ${elapsedTime.toFixed(3)}ms`);
+ if (typeof recordTiming == "function") {
+ recordTiming(elapsedTime, result);
+ }
+
+ return result;
+}
+
+/**
+ * Fires bookmark and keyword observer notifications for all changes made during
+ * the merge.
+ */
+class BookmarkObserverRecorder {
+ constructor(db, { notifyInStableOrder, signal }) {
+ this.db = db;
+ this.notifyInStableOrder = notifyInStableOrder;
+ this.signal = signal;
+ this.placesEvents = [];
+ this.shouldInvalidateKeywords = false;
+ }
+
+ /**
+ * Fires observer notifications for all changed items, invalidates the
+ * livemark cache if necessary, and recalculates frecencies for changed
+ * URLs. This is called outside the merge transaction.
+ */
+ async notifyAll() {
+ await this.noteAllChanges();
+ if (this.shouldInvalidateKeywords) {
+ await lazy.PlacesUtils.keywords.invalidateCachedKeywords();
+ }
+ this.notifyBookmarkObservers();
+ if (this.signal.aborted) {
+ throw new SyncedBookmarksMirror.InterruptedError(
+ "Interrupted before recalculating frecencies for new URLs"
+ );
+ }
+ }
+
+ orderBy(level, parent, position) {
+ return `ORDER BY ${
+ this.notifyInStableOrder ? `${level}, ${parent}, ${position}` : level
+ }`;
+ }
+
+ /**
+ * Records Places observer notifications for removed, added, moved, and
+ * changed items.
+ */
+ async noteAllChanges() {
+ lazy.MirrorLog.trace("Recording observer notifications for removed items");
+ // `ORDER BY v.level DESC` sorts deleted children before parents, to ensure
+ // that we update caches in the correct order (bug 1297941).
+ await this.db.execute(
+ `SELECT v.itemId AS id, v.parentId, v.parentGuid, v.position, v.type,
+ (SELECT h.url FROM moz_places h WHERE h.id = v.placeId) AS url,
+ v.title, v.guid, v.isUntagging, v.keywordRemoved
+ FROM itemsRemoved v
+ ${this.orderBy("v.level", "v.parentId", "v.position")}`,
+ null,
+ (row, cancel) => {
+ if (this.signal.aborted) {
+ cancel();
+ return;
+ }
+ let info = {
+ id: row.getResultByName("id"),
+ parentId: row.getResultByName("parentId"),
+ position: row.getResultByName("position"),
+ type: row.getResultByName("type"),
+ urlHref: row.getResultByName("url"),
+ title: row.getResultByName("title"),
+ guid: row.getResultByName("guid"),
+ parentGuid: row.getResultByName("parentGuid"),
+ isUntagging: row.getResultByName("isUntagging"),
+ };
+ this.noteItemRemoved(info);
+ if (row.getResultByName("keywordRemoved")) {
+ this.shouldInvalidateKeywords = true;
+ }
+ }
+ );
+ if (this.signal.aborted) {
+ throw new SyncedBookmarksMirror.InterruptedError(
+ "Interrupted while recording observer notifications for removed items"
+ );
+ }
+
+ lazy.MirrorLog.trace("Recording observer notifications for changed GUIDs");
+ await this.db.execute(
+ `SELECT b.id, b.lastModified, b.type, b.guid AS newGuid,
+ p.guid AS parentGuid, gp.guid AS grandParentGuid
+ FROM guidsChanged c
+ JOIN moz_bookmarks b ON b.id = c.itemId
+ JOIN moz_bookmarks p ON p.id = b.parent
+ LEFT JOIN moz_bookmarks gp ON gp.id = p.parent
+ ${this.orderBy("c.level", "b.parent", "b.position")}`,
+ null,
+ (row, cancel) => {
+ if (this.signal.aborted) {
+ cancel();
+ return;
+ }
+ let info = {
+ id: row.getResultByName("id"),
+ lastModified: row.getResultByName("lastModified"),
+ type: row.getResultByName("type"),
+ newGuid: row.getResultByName("newGuid"),
+ parentGuid: row.getResultByName("parentGuid"),
+ grandParentGuid: row.getResultByName("grandParentGuid"),
+ };
+ this.noteGuidChanged(info);
+ }
+ );
+ if (this.signal.aborted) {
+ throw new SyncedBookmarksMirror.InterruptedError(
+ "Interrupted while recording observer notifications for changed GUIDs"
+ );
+ }
+
+ lazy.MirrorLog.trace("Recording observer notifications for new items");
+ await this.db.execute(
+ `SELECT b.id, p.id AS parentId, b.position, b.type,
+ (SELECT h.url FROM moz_places h WHERE h.id = b.fk) AS url,
+ IFNULL(b.title, '') AS title, b.dateAdded, b.guid,
+ p.guid AS parentGuid, n.isTagging, n.keywordChanged
+ FROM itemsAdded n
+ JOIN moz_bookmarks b ON b.guid = n.guid
+ JOIN moz_bookmarks p ON p.id = b.parent
+ ${this.orderBy("n.level", "b.parent", "b.position")}`,
+ null,
+ (row, cancel) => {
+ if (this.signal.aborted) {
+ cancel();
+ return;
+ }
+ let info = {
+ id: row.getResultByName("id"),
+ parentId: row.getResultByName("parentId"),
+ position: row.getResultByName("position"),
+ type: row.getResultByName("type"),
+ urlHref: row.getResultByName("url"),
+ title: row.getResultByName("title"),
+ dateAdded: row.getResultByName("dateAdded"),
+ guid: row.getResultByName("guid"),
+ parentGuid: row.getResultByName("parentGuid"),
+ isTagging: row.getResultByName("isTagging"),
+ };
+ this.noteItemAdded(info);
+ if (row.getResultByName("keywordChanged")) {
+ this.shouldInvalidateKeywords = true;
+ }
+ }
+ );
+ if (this.signal.aborted) {
+ throw new SyncedBookmarksMirror.InterruptedError(
+ "Interrupted while recording observer notifications for new items"
+ );
+ }
+
+ lazy.MirrorLog.trace("Recording observer notifications for moved items");
+ await this.db.execute(
+ `SELECT b.id, b.guid, b.type, p.guid AS newParentGuid, c.oldParentGuid,
+ b.position AS newPosition, c.oldPosition,
+ gp.guid AS grandParentGuid,
+ (SELECT h.url FROM moz_places h WHERE h.id = b.fk) AS url
+ FROM itemsMoved c
+ JOIN moz_bookmarks b ON b.id = c.itemId
+ JOIN moz_bookmarks p ON p.id = b.parent
+ LEFT JOIN moz_bookmarks gp ON gp.id = p.parent
+ ${this.orderBy("c.level", "b.parent", "b.position")}`,
+ null,
+ (row, cancel) => {
+ if (this.signal.aborted) {
+ cancel();
+ return;
+ }
+ let info = {
+ id: row.getResultByName("id"),
+ guid: row.getResultByName("guid"),
+ type: row.getResultByName("type"),
+ newParentGuid: row.getResultByName("newParentGuid"),
+ oldParentGuid: row.getResultByName("oldParentGuid"),
+ newPosition: row.getResultByName("newPosition"),
+ oldPosition: row.getResultByName("oldPosition"),
+ urlHref: row.getResultByName("url"),
+ grandParentGuid: row.getResultByName("grandParentGuid"),
+ };
+ this.noteItemMoved(info);
+ }
+ );
+ if (this.signal.aborted) {
+ throw new SyncedBookmarksMirror.InterruptedError(
+ "Interrupted while recording observer notifications for moved items"
+ );
+ }
+
+ lazy.MirrorLog.trace("Recording observer notifications for changed items");
+ await this.db.execute(
+ `SELECT b.id, b.guid, b.lastModified, b.type,
+ IFNULL(b.title, '') AS newTitle,
+ IFNULL(c.oldTitle, '') AS oldTitle,
+ (SELECT h.url FROM moz_places h
+ WHERE h.id = b.fk) AS newURL,
+ (SELECT h.url FROM moz_places h
+ WHERE h.id = c.oldPlaceId) AS oldURL,
+ p.id AS parentId, p.guid AS parentGuid,
+ c.keywordChanged,
+ gp.guid AS grandParentGuid,
+ (SELECT h.url FROM moz_places h WHERE h.id = b.fk) AS url
+ FROM itemsChanged c
+ JOIN moz_bookmarks b ON b.id = c.itemId
+ JOIN moz_bookmarks p ON p.id = b.parent
+ LEFT JOIN moz_bookmarks gp ON gp.id = p.parent
+ ${this.orderBy("c.level", "b.parent", "b.position")}`,
+ null,
+ (row, cancel) => {
+ if (this.signal.aborted) {
+ cancel();
+ return;
+ }
+ let info = {
+ id: row.getResultByName("id"),
+ guid: row.getResultByName("guid"),
+ lastModified: row.getResultByName("lastModified"),
+ type: row.getResultByName("type"),
+ newTitle: row.getResultByName("newTitle"),
+ oldTitle: row.getResultByName("oldTitle"),
+ newURLHref: row.getResultByName("newURL"),
+ oldURLHref: row.getResultByName("oldURL"),
+ parentId: row.getResultByName("parentId"),
+ parentGuid: row.getResultByName("parentGuid"),
+ grandParentGuid: row.getResultByName("grandParentGuid"),
+ };
+ this.noteItemChanged(info);
+ if (row.getResultByName("keywordChanged")) {
+ this.shouldInvalidateKeywords = true;
+ }
+ }
+ );
+ if (this.signal.aborted) {
+ throw new SyncedBookmarksMirror.InterruptedError(
+ "Interrupted while recording observer notifications for changed items"
+ );
+ }
+ }
+
+ noteItemAdded(info) {
+ this.placesEvents.push(
+ new PlacesBookmarkAddition({
+ id: info.id,
+ parentId: info.parentId,
+ index: info.position,
+ url: info.urlHref || "",
+ title: info.title,
+ // Note that both the database and the legacy `onItem{Moved, Removed,
+ // Changed}` notifications use microsecond timestamps, but
+ // `PlacesBookmarkAddition` uses milliseconds.
+ dateAdded: info.dateAdded / 1000,
+ guid: info.guid,
+ parentGuid: info.parentGuid,
+ source: lazy.PlacesUtils.bookmarks.SOURCES.SYNC,
+ itemType: info.type,
+ isTagging: info.isTagging,
+ })
+ );
+ }
+
+ noteGuidChanged(info) {
+ lazy.PlacesUtils.invalidateCachedGuidFor(info.id);
+ this.placesEvents.push(
+ new PlacesBookmarkGuid({
+ id: info.id,
+ itemType: info.type,
+ url: info.urlHref,
+ guid: info.newGuid,
+ parentGuid: info.parentGuid,
+ lastModified: info.lastModified,
+ source: lazy.PlacesUtils.bookmarks.SOURCES.SYNC,
+ isTagging:
+ info.parentGuid === lazy.PlacesUtils.bookmarks.tagsGuid ||
+ info.grandParentGuid === lazy.PlacesUtils.bookmarks.tagsGuid,
+ })
+ );
+ }
+
+ noteItemMoved(info) {
+ this.placesEvents.push(
+ new PlacesBookmarkMoved({
+ id: info.id,
+ itemType: info.type,
+ url: info.urlHref,
+ guid: info.guid,
+ parentGuid: info.newParentGuid,
+ source: lazy.PlacesUtils.bookmarks.SOURCES.SYNC,
+ index: info.newPosition,
+ oldParentGuid: info.oldParentGuid,
+ oldIndex: info.oldPosition,
+ isTagging:
+ info.newParentGuid === lazy.PlacesUtils.bookmarks.tagsGuid ||
+ info.grandParentGuid === lazy.PlacesUtils.bookmarks.tagsGuid,
+ })
+ );
+ }
+
+ noteItemChanged(info) {
+ if (info.oldTitle != info.newTitle) {
+ this.placesEvents.push(
+ new PlacesBookmarkTitle({
+ id: info.id,
+ itemType: info.type,
+ url: info.urlHref,
+ guid: info.guid,
+ parentGuid: info.parentGuid,
+ title: info.newTitle,
+ lastModified: info.lastModified,
+ source: lazy.PlacesUtils.bookmarks.SOURCES.SYNC,
+ isTagging:
+ info.parentGuid === lazy.PlacesUtils.bookmarks.tagsGuid ||
+ info.grandParentGuid === lazy.PlacesUtils.bookmarks.tagsGuid,
+ })
+ );
+ }
+ if (info.oldURLHref != info.newURLHref) {
+ this.placesEvents.push(
+ new PlacesBookmarkUrl({
+ id: info.id,
+ itemType: info.type,
+ url: info.newURLHref,
+ guid: info.guid,
+ parentGuid: info.parentGuid,
+ lastModified: info.lastModified,
+ source: lazy.PlacesUtils.bookmarks.SOURCES.SYNC,
+ isTagging:
+ info.parentGuid === lazy.PlacesUtils.bookmarks.tagsGuid ||
+ info.grandParentGuid === lazy.PlacesUtils.bookmarks.tagsGuid,
+ })
+ );
+ }
+ }
+
+ noteItemRemoved(info) {
+ this.placesEvents.push(
+ new PlacesBookmarkRemoved({
+ id: info.id,
+ parentId: info.parentId,
+ index: info.position,
+ url: info.urlHref || "",
+ title: info.title,
+ guid: info.guid,
+ parentGuid: info.parentGuid,
+ source: lazy.PlacesUtils.bookmarks.SOURCES.SYNC,
+ itemType: info.type,
+ isTagging: info.isUntagging,
+ isDescendantRemoval: false,
+ })
+ );
+ }
+
+ notifyBookmarkObservers() {
+ lazy.MirrorLog.trace("Notifying bookmark observers");
+
+ if (this.placesEvents.length) {
+ PlacesObservers.notifyListeners(this.placesEvents);
+ }
+
+ lazy.MirrorLog.trace("Notified bookmark observers");
+ }
+}
+
+/**
+ * Holds Sync metadata and the cleartext for a locally changed record. The
+ * bookmarks engine inflates a Sync record from the cleartext, and updates the
+ * `synced` property for successfully uploaded items.
+ *
+ * At the end of the sync, the engine writes the uploaded cleartext back to the
+ * mirror, and passes the updated change record as part of the changeset to
+ * `PlacesSyncUtils.bookmarks.pushChanges`.
+ */
+class BookmarkChangeRecord {
+ constructor(syncChangeCounter, cleartext) {
+ this.tombstone = cleartext.deleted === true;
+ this.counter = syncChangeCounter;
+ this.cleartext = cleartext;
+ this.synced = false;
+ }
+}
+
+function bagToNamedCounts(bag, names) {
+ let counts = [];
+ for (let name of names) {
+ let count = bag.getProperty(name);
+ if (count > 0) {
+ counts.push({ name, count });
+ }
+ }
+ return counts;
+}
+
+/**
+ * Returns an `AbortSignal` that aborts if either `finalizeSignal` or
+ * `interruptSignal` aborts. This is like `Promise.race`, but for
+ * cancellations.
+ *
+ * @param {AbortSignal} finalizeSignal
+ * @param {AbortSignal?} signal
+ * @return {AbortSignal}
+ */
+function anyAborted(finalizeSignal, interruptSignal = null) {
+ if (finalizeSignal.aborted || !interruptSignal) {
+ // If the mirror was already finalized, or we don't have an interrupt
+ // signal for this merge, just use the finalize signal.
+ return finalizeSignal;
+ }
+ if (interruptSignal.aborted) {
+ // If the merge was interrupted, return its already-aborted signal.
+ return interruptSignal;
+ }
+ // Otherwise, we return a new signal that aborts if either the mirror is
+ // finalized, or the merge is interrupted, whichever happens first.
+ let controller = new AbortController();
+ function onAbort() {
+ finalizeSignal.removeEventListener("abort", onAbort);
+ interruptSignal.removeEventListener("abort", onAbort);
+ controller.abort();
+ }
+ finalizeSignal.addEventListener("abort", onAbort);
+ interruptSignal.addEventListener("abort", onAbort);
+ return controller.signal;
+}
+
+// In conclusion, this is why bookmark syncing is hard.