Source code

Revision control

Other Tools

/* 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/. */
"use strict";
/**
* 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 `nsINavBookmarkObserver` 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.
*/
var EXPORTED_SYMBOLS = ["SyncedBookmarksMirror"];
const { XPCOMUtils } = ChromeUtils.import(
);
XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
XPCOMUtils.defineLazyModuleGetters(this, {
});
XPCOMUtils.defineLazyGetter(this, "MirrorLog", () =>
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;
const DEFAULT_MAX_FRECENCIES_TO_RECALCULATE = 400;
// Use a shared jankYielder in these functions
XPCOMUtils.defineLazyGetter(this, "yieldState", () => Async.yieldState());
/** Adapts a `Log.jsm` logger to a `mozIServicesLogSink`. */
class LogAdapter {
constructor(log) {
this.log = log;
}
get maxLevel() {
let level = this.log.level;
if (level <= Log.Level.All) {
return Ci.mozIServicesLogSink.LEVEL_TRACE;
}
if (level <= Log.Level.Info) {
return Ci.mozIServicesLogSink.LEVEL_DEBUG;
}
if (level <= Log.Level.Warn) {
return Ci.mozIServicesLogSink.LEVEL_WARN;
}
if (level <= 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.
*/
class SyncedBookmarksMirror {
constructor(
db,
wasCorrupt = false,
{
recordStepTelemetry,
recordValidationTelemetry,
finalizeAt = 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(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 PlacesUtils.promiseUnsafeWritableDBConnection();
if (!db) {
throw new TypeError("Can't open mirror without Places connection");
}
let path = OS.Path.join(OS.Constants.Path.profileDir, options.path);
let wasCorrupt = false;
try {
await attachAndInitMirrorDatabase(db, path);
} catch (ex) {
if (isDatabaseCorrupt(ex)) {
MirrorLog.warn(
"Error attaching mirror to Places; removing and " +
"recreating mirror",
ex
);
wasCorrupt = true;
await OS.File.remove(path);
await attachAndInitMirrorDatabase(db, path);
} else {
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) {
MirrorLog.trace("Sync ID up-to-date in mirror", { existingSyncId });
return;
}
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 = PlacesSyncUtils.bookmarks.recordIdToGuid(record.id);
if (guid == PlacesUtils.bookmarks.rootGuid) {
// The engine should hard DELETE Places roots from the server.
throw new TypeError("Can't store Places root");
}
if (MirrorLog.level <= Log.Level.Trace) {
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;
}
}
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 {Number} [options.maxFrecenciesToRecalculate]
* The maximum number of bookmark URL frecencies to recalculate after
* this merge. Frecency calculation blocks other Places writes, so we
* limit the number of URLs we process at once. We'll process either
* the next set of URLs after the next merge, or all remaining URLs
* when Places automatically fixes invalid frecencies on idle;
* whichever comes first.
* @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,
maxFrecenciesToRecalculate,
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,
maxFrecenciesToRecalculate,
notifyInStableOrder
);
} finally {
this.progress.reset();
}
return changeRecords;
}
async tryApply(
signal,
localTimeSeconds,
remoteTimeSeconds,
maxFrecenciesToRecalculate = DEFAULT_MAX_FRECENCIES_TO_RECALCULATE,
notifyInStableOrder = false
) {
let wasMerged = await withTiming("Merging bookmarks in Rust", () =>
this.merge(signal, localTimeSeconds, remoteTimeSeconds)
);
if (!wasMerged) {
MirrorLog.debug("No changes detected in both mirror and Places");
await updateFrecencies(this.db, maxFrecenciesToRecalculate);
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, {
maxFrecenciesToRecalculate,
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.
PlacesUtils.invalidateCachedGuids();
await PlacesUtils.keywords.invalidateCachedKeywords();
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 = PlacesSyncUtils.bookmarks.recordIdToGuid(record.id);
let url = validateURL(record.bmkUri);
if (url) {
await this.maybeStoreRemoteURL(url);
}
let parentGuid = 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 = 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 = 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 = PlacesSyncUtils.bookmarks.recordIdToGuid(record.id);
let parentGuid = 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 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(PlacesSyncUtils.bookmarks.recordIdToGuid)]
);
offset += chunk.length;
}
}
}
async storeRemoteLivemark(record, { needsMerge }) {
let guid = PlacesSyncUtils.bookmarks.recordIdToGuid(record.id);
let parentGuid = 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 = PlacesSyncUtils.bookmarks.recordIdToGuid(record.id);
let parentGuid = 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 = 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: 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.jsm` callbacks swallow exceptions (bug 1387775), so we
// accumulate all rows in an array, and process them after.
childGuidRows.push(row);
}
}
);
await Async.yieldingForEach(
childGuidRows,
row => {
if (signal.aborted) {
throw new SyncedBookmarksMirror.InterruptedError(
"Interrupted while fetching structure to upload"
);
}
let localParentId = row.getResultByName("parentId");
let childRecordId = PlacesSyncUtils.bookmarks.guidToRecordId(
row.getResultByName("guid")
);
let childRecordIds = childRecordIdsByLocalParentId.get(localParentId);
if (childRecordIds) {
childRecordIds.push(childRecordId);
} else {
childRecordIdsByLocalParentId.set(localParentId, [childRecordId]);
}
},
yieldState
);
let tagRows = [];
await this.db.execute(
`SELECT id, tag FROM tagsToUpload`,
null,
(row, cancel) => {
if (signal.aborted) {
cancel();
} else {
tagRows.push(row);
}
}
);
await 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]);
}
},
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 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 = 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 = PlacesSyncUtils.bookmarks.guidToRecordId(
parentGuid
);
let type = row.getResultByName("type");
switch (type) {
case 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 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 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");
}
},
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;
}
}
this.SyncedBookmarksMirror = SyncedBookmarksMirror;
/** 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 = ${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: PlacesUtils.bookmarks.rootGuid,
// The Places root is its own parent, to satisfy the foreign key and
// `NOT NULL` constraints on `structure`.
parentGuid: PlacesUtils.bookmarks.rootGuid,
position: -1,
needsMerge: false,
},
...PlacesUtils.bookmarks.userContentRoots.map((guid, position) => {
return {
guid,
parentGuid: 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