diff --git a/packages/firestore/src/local/indexeddb_persistence.ts b/packages/firestore/src/local/indexeddb_persistence.ts index c4bcd5c8634..b09548e5e2b 100644 --- a/packages/firestore/src/local/indexeddb_persistence.ts +++ b/packages/firestore/src/local/indexeddb_persistence.ts @@ -25,8 +25,13 @@ import { AutoId } from '../util/misc'; import { IndexedDbMutationQueue } from './indexeddb_mutation_queue'; import { IndexedDbQueryCache } from './indexeddb_query_cache'; import { IndexedDbRemoteDocumentCache } from './indexeddb_remote_document_cache'; -import { ALL_STORES, DbOwner, DbOwnerKey } from './indexeddb_schema'; -import { createOrUpgradeDb, SCHEMA_VERSION } from './indexeddb_schema'; +import { + ALL_STORES, + createOrUpgradeDb, + DbOwner, + DbOwnerKey, + SCHEMA_VERSION +} from './indexeddb_schema'; import { LocalSerializer } from './local_serializer'; import { MutationQueue } from './mutation_queue'; import { Persistence } from './persistence'; diff --git a/packages/firestore/src/local/indexeddb_schema.ts b/packages/firestore/src/local/indexeddb_schema.ts index 88bfb484371..d3689ff94f1 100644 --- a/packages/firestore/src/local/indexeddb_schema.ts +++ b/packages/firestore/src/local/indexeddb_schema.ts @@ -22,53 +22,48 @@ import { assert } from '../util/assert'; import { encode, EncodedResourcePath } from './encoded_resource_path'; -export const SCHEMA_VERSION = 1; - -/** Performs database creation and (in the future) upgrades between versions. */ -export function createOrUpgradeDb(db: IDBDatabase, oldVersion: number): void { - assert(oldVersion === 0, 'Unexpected upgrade from version ' + oldVersion); - - db.createObjectStore(DbMutationQueue.store, { - keyPath: DbMutationQueue.keyPath - }); - - // TODO(mikelehen): Get rid of "as any" if/when TypeScript fixes their - // types. https://github.com/Microsoft/TypeScript/issues/14322 - db.createObjectStore( - DbMutationBatch.store, - // tslint:disable-next-line:no-any - { keyPath: DbMutationBatch.keyPath as any } - ); +/** + * Schema Version for the Web client (containing the Mutation Queue, the Query + * and the Remote Document Cache) and Multi-Tab Support. + */ +export const SCHEMA_VERSION = 2; - const targetDocumentsStore = db.createObjectStore( - DbTargetDocument.store, - // tslint:disable-next-line:no-any - { keyPath: DbTargetDocument.keyPath as any } - ); - targetDocumentsStore.createIndex( - DbTargetDocument.documentTargetsIndex, - DbTargetDocument.documentTargetsKeyPath, - { unique: true } +/** + * Performs database creation and schema upgrades. + * + * Note that in production, this method is only ever used to upgrade the schema + * to SCHEMA_VERSION. Different versions are only used for testing and + * local feature development. + */ +export function createOrUpgradeDb( + db: IDBDatabase, + fromVersion: number, + toVersion: number +): void { + // This function currently supports migrating to schema version 1 (Mutation + // Queue, Query and Remote Document Cache) and schema version 2 (Multi-Tab). + assert( + fromVersion < toVersion && fromVersion >= 0 && toVersion <= 2, + 'Unexpected schema upgrade from v${fromVersion} to v{toVersion}.' ); - const targetStore = db.createObjectStore(DbTarget.store, { - keyPath: DbTarget.keyPath - }); - // NOTE: This is unique only because the TargetId is the suffix. - targetStore.createIndex( - DbTarget.queryTargetsIndexName, - DbTarget.queryTargetsKeyPath, - { unique: true } - ); + if (fromVersion < 1 && toVersion >= 1) { + createOwnerStore(db); + createMutationQueue(db); + createQueryCache(db); + createRemoteDocumentCache(db); + } - // NOTE: keys for these stores are specified explicitly rather than using a - // keyPath. - db.createObjectStore(DbDocumentMutation.store); - db.createObjectStore(DbRemoteDocument.store); - db.createObjectStore(DbOwner.store); - db.createObjectStore(DbTargetGlobal.store); + if (fromVersion < 2 && toVersion >= 2) { + createClientMetadataStore(db); + createTargetChangeStore(db); + } } +// TODO(mikelehen): Get rid of "as any" if/when TypeScript fixes their types. +// https://github.com/Microsoft/TypeScript/issues/14322 +type KeyPath = any; // tslint:disable-line:no-any + /** * Wrapper class to store timestamps (seconds and nanos) in IndexedDb objects. */ @@ -94,6 +89,10 @@ export class DbOwner { constructor(public ownerId: string, public leaseTimestampMs: number) {} } +function createOwnerStore(db: IDBDatabase): void { + db.createObjectStore(DbOwner.store); +} + /** Object keys in the 'mutationQueues' store are userId strings. */ export type DbMutationQueueKey = string; @@ -183,6 +182,18 @@ export class DbMutationBatch { */ export type DbDocumentMutationKey = [string, EncodedResourcePath, BatchId]; +function createMutationQueue(db: IDBDatabase): void { + db.createObjectStore(DbMutationQueue.store, { + keyPath: DbMutationQueue.keyPath + }); + + db.createObjectStore(DbMutationBatch.store, { + keyPath: DbMutationBatch.keyPath as KeyPath + }); + + db.createObjectStore(DbDocumentMutation.store); +} + /** * An object to be stored in the 'documentMutations' store in IndexedDb. * @@ -241,6 +252,10 @@ export class DbDocumentMutation { */ export type DbRemoteDocumentKey = string[]; +function createRemoteDocumentCache(db: IDBDatabase): void { + db.createObjectStore(DbRemoteDocument.store); +} + /** * Represents the known absence of a document at a particular version. * Stored in IndexedDb as part of a DbRemoteDocument object. @@ -455,11 +470,101 @@ export class DbTargetGlobal { ) {} } +function createQueryCache(db: IDBDatabase): void { + const targetDocumentsStore = db.createObjectStore(DbTargetDocument.store, { + keyPath: DbTargetDocument.keyPath as KeyPath + }); + targetDocumentsStore.createIndex( + DbTargetDocument.documentTargetsIndex, + DbTargetDocument.documentTargetsKeyPath, + { unique: true } + ); + + const targetStore = db.createObjectStore(DbTarget.store, { + keyPath: DbTarget.keyPath + }); + + // NOTE: This is unique only because the TargetId is the suffix. + targetStore.createIndex( + DbTarget.queryTargetsIndexName, + DbTarget.queryTargetsKeyPath, + { unique: true } + ); + db.createObjectStore(DbTargetGlobal.store); +} + +/** + * An object representing the changes at a particular snapshot version for the + * given target. This is used to facilitate storing query changelogs in the + * targetChanges object store. + * + * PORTING NOTE: This is used for change propagation during multi-tab syncing + * and not needed on iOS and Android. + */ +export class DbTargetChange { + /** Name of the IndexedDb object store. */ + static store = 'targetChanges'; + + /** Keys are automatically assigned via the targetId and snapshotVersion. */ + static keyPath = ['targetId', 'snapshotVersion']; + + constructor( + /** + * The targetId identifying a target. + */ + public targetId: TargetId, + /** + * The snapshot version for this change. + */ + public snapshotVersion: DbTimestamp, + /** + * The keys of the changed documents in this snapshot. + */ + public changes: { + added?: EncodedResourcePath[]; + modified?: EncodedResourcePath[]; + removed?: EncodedResourcePath[]; + } + ) {} +} + +function createTargetChangeStore(db: IDBDatabase): void { + db.createObjectStore(DbTargetChange.store, { + keyPath: DbTargetChange.keyPath as KeyPath + }); +} + /** - * The list of all IndexedDB stored used by the SDK. This is used when creating - * transactions so that access across all stores is done atomically. + * A record of the metadata state of each client. + * + * PORTING NOTE: This is used to synchronize multi-tab state and does not need + * to be ported to iOS or Android. */ -export const ALL_STORES = [ +export class DbClientMetadata { + /** Name of the IndexedDb object store. */ + static store = 'clientMetadata'; + + /** Keys are automatically assigned via the clientKey properties. */ + static keyPath = ['clientKey']; + + constructor( + /** The auto-generated client key assigned at client startup. */ + public clientKey: string, + /** The last time this state was updated. */ + public updateTimeMs: DbTimestamp, + /** Whether this client is running in a foreground tab. */ + public inForeground: boolean + ) {} +} + +function createClientMetadataStore(db: IDBDatabase): void { + db.createObjectStore(DbClientMetadata.store, { + keyPath: DbClientMetadata.keyPath as KeyPath + }); +} + +// Visible for testing +export const V1_STORES = [ DbMutationQueue.store, DbMutationBatch.store, DbDocumentMutation.store, @@ -469,3 +574,12 @@ export const ALL_STORES = [ DbTargetGlobal.store, DbTargetDocument.store ]; + +const V2_STORES = [DbClientMetadata.store, DbTargetChange.store]; + +/** + * The list of all default IndexedDB stores used throughout the SDK. This is + * used when creating transactions so that access across all stores is done + * atomically. + */ +export const ALL_STORES = [...V1_STORES, ...V2_STORES]; diff --git a/packages/firestore/src/local/simple_db.ts b/packages/firestore/src/local/simple_db.ts index cfaa6bb9767..8adbcad37d5 100644 --- a/packages/firestore/src/local/simple_db.ts +++ b/packages/firestore/src/local/simple_db.ts @@ -19,6 +19,7 @@ import { debug } from '../util/log'; import { AnyDuringMigration } from '../util/misc'; import { PersistencePromise } from './persistence_promise'; +import { SCHEMA_VERSION } from './indexeddb_schema'; const LOG_TAG = 'SimpleDb'; @@ -34,7 +35,11 @@ export class SimpleDb { static openOrCreate( name: string, version: number, - runUpgrade: (db: IDBDatabase, oldVersion: number) => void + runUpgrade: ( + db: IDBDatabase, + fromVersion: number, + toVersion: number + ) => void ): Promise { assert( SimpleDb.isAvailable(), @@ -70,7 +75,7 @@ export class SimpleDb { // cheating and just passing the raw IndexedDB in, since // createObjectStore(), etc. are synchronous. const db = (event.target as IDBOpenDBRequest).result; - runUpgrade(db, event.oldVersion); + runUpgrade(db, event.oldVersion, SCHEMA_VERSION); }; }).toPromise(); } diff --git a/packages/firestore/test/unit/local/schema_migration.test.ts b/packages/firestore/test/unit/local/schema_migration.test.ts new file mode 100644 index 00000000000..3faad875333 --- /dev/null +++ b/packages/firestore/test/unit/local/schema_migration.test.ts @@ -0,0 +1,90 @@ +/** + * Copyright 2018 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import { IndexedDbPersistence } from '../../../src/local/indexeddb_persistence'; +import { + ALL_STORES, + createOrUpgradeDb, + V1_STORES +} from '../../../src/local/indexeddb_schema'; +import { Deferred } from '../../../src/util/promise'; +import { SimpleDb } from '../../../src/local/simple_db'; + +const INDEXEDDB_TEST_DATABASE = 'schemaTest'; + +function withDb(schemaVersion, fn: (db: IDBDatabase) => void): Promise { + return new Promise((resolve, reject) => { + const request = window.indexedDB.open( + INDEXEDDB_TEST_DATABASE, + schemaVersion + ); + request.onupgradeneeded = (event: IDBVersionChangeEvent) => { + const db = (event.target as IDBOpenDBRequest).result; + createOrUpgradeDb(db, event.oldVersion, schemaVersion); + }; + request.onsuccess = (event: Event) => { + resolve((event.target as IDBOpenDBRequest).result); + }; + request.onerror = (event: ErrorEvent) => { + reject((event.target as IDBOpenDBRequest).error); + }; + }).then(db => { + fn(db); + db.close(); + }); +} + +function getAllObjectStores(db: IDBDatabase): String[] { + const objectStores: String[] = []; + for (let i = 0; i < db.objectStoreNames.length; ++i) { + objectStores.push(db.objectStoreNames.item(i)); + } + objectStores.sort(); + return objectStores; +} + +describe('IndexedDbSchema: createOrUpgradeDb', () => { + if (!IndexedDbPersistence.isAvailable()) { + console.warn('No IndexedDB. Skipping createOrUpgradeDb() tests.'); + return; + } + + beforeEach(() => SimpleDb.delete(INDEXEDDB_TEST_DATABASE)); + + it('can install schema version 1', () => { + return withDb(1, db => { + expect(db.version).to.be.equal(1); + expect(getAllObjectStores(db)).to.have.members(V1_STORES); + }); + }); + + it('can install schema version 2', () => { + return withDb(2, db => { + expect(db.version).to.be.equal(2); + expect(getAllObjectStores(db)).to.have.members(ALL_STORES); + }); + }); + + it('can upgrade from schema version 1 to 2', () => { + return withDb(1, () => {}).then(() => + withDb(2, db => { + expect(db.version).to.be.equal(2); + expect(getAllObjectStores(db)).to.have.members(ALL_STORES); + }) + ); + }); +});