diff --git a/packages/firestore/src/core/bundle.ts b/packages/firestore/src/core/bundle.ts new file mode 100644 index 00000000000..464b40e62ef --- /dev/null +++ b/packages/firestore/src/core/bundle.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright 2020 Google LLC + * + * 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 { Query } from './query'; +import { SnapshotVersion } from './snapshot_version'; + +/** + * Represents a Firestore bundle saved by the SDK in its local storage. + */ +export interface Bundle { + readonly id: string; + readonly version: number; + /** + * Set to the snapshot version of the bundle if created by the Server SDKs. + * Otherwise set to SnapshotVersion.MIN. + */ + readonly createTime: SnapshotVersion; +} + +/** + * Represents a Query saved by the SDK in its local storage. + */ +export interface NamedQuery { + readonly name: string; + readonly query: Query; + /** The time at which the results for this query were read. */ + readonly readTime: SnapshotVersion; +} diff --git a/packages/firestore/src/core/component_provider.ts b/packages/firestore/src/core/component_provider.ts index 2c34723ecef..38ba5d2c763 100644 --- a/packages/firestore/src/core/component_provider.ts +++ b/packages/firestore/src/core/component_provider.ts @@ -137,7 +137,8 @@ export class MemoryComponentProvider implements ComponentProvider { !cfg.persistenceSettings.durable, 'Can only start memory persistence' ); - return new MemoryPersistence(MemoryEagerDelegate.factory); + const serializer = cfg.platform.newSerializer(cfg.databaseInfo.databaseId); + return new MemoryPersistence(MemoryEagerDelegate.factory, serializer); } createRemoteStore(cfg: ComponentConfiguration): RemoteStore { diff --git a/packages/firestore/src/local/bundle_cache.ts b/packages/firestore/src/local/bundle_cache.ts new file mode 100644 index 00000000000..acf2a94f554 --- /dev/null +++ b/packages/firestore/src/local/bundle_cache.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2020 Google LLC + * + * 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 { PersistenceTransaction } from './persistence'; +import { PersistencePromise } from './persistence_promise'; +import * as bundleProto from '../protos/firestore_bundle_proto'; +import { Bundle, NamedQuery } from '../core/bundle'; + +/** + * Provides interfaces to save and read Firestore bundles. + */ +export interface BundleCache { + /** + * Gets a saved `Bundle` for a given `bundleId`, returns undefined if + * no bundles are found under the given id. + */ + getBundle( + transaction: PersistenceTransaction, + bundleId: string + ): PersistencePromise; + + /** + * Saves a `BundleMetadata` from a bundle into local storage, using its id as + * the persistent key. + */ + saveBundleMetadata( + transaction: PersistenceTransaction, + metadata: bundleProto.BundleMetadata + ): PersistencePromise; + + /** + * Gets a saved `NamedQuery` for the given query name. Returns undefined if + * no queries are found under the given name. + */ + getNamedQuery( + transaction: PersistenceTransaction, + queryName: string + ): PersistencePromise; + + /** + * Saves a `NamedQuery` from a bundle, using its name as the persistent key. + */ + saveNamedQuery( + transaction: PersistenceTransaction, + query: bundleProto.NamedQuery + ): PersistencePromise; +} diff --git a/packages/firestore/src/local/indexeddb_bundle_cache.ts b/packages/firestore/src/local/indexeddb_bundle_cache.ts new file mode 100644 index 00000000000..10e58aeae21 --- /dev/null +++ b/packages/firestore/src/local/indexeddb_bundle_cache.ts @@ -0,0 +1,111 @@ +/** + * @license + * Copyright 2020 Google LLC + * + * 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 { PersistenceTransaction } from './persistence'; +import { PersistencePromise } from './persistence_promise'; +import * as bundleProto from '../protos/firestore_bundle_proto'; +import { BundleCache } from './bundle_cache'; +import { + DbBundle, + DbBundlesKey, + DbNamedQuery, + DbNamedQueriesKey +} from './indexeddb_schema'; +import { SimpleDbStore } from './simple_db'; +import { IndexedDbPersistence } from './indexeddb_persistence'; +import { + fromDbBundle, + fromDbNamedQuery, + LocalSerializer, + toDbBundle, + toDbNamedQuery +} from './local_serializer'; +import { Bundle, NamedQuery } from '../core/bundle'; + +export class IndexedDbBundleCache implements BundleCache { + constructor(private serializer: LocalSerializer) {} + + getBundle( + transaction: PersistenceTransaction, + bundleId: string + ): PersistencePromise { + return bundlesStore(transaction) + .get(bundleId) + .next(bundle => { + if (bundle) { + return fromDbBundle(this.serializer, bundle); + } + return undefined; + }); + } + + saveBundleMetadata( + transaction: PersistenceTransaction, + bundleMetadata: bundleProto.BundleMetadata + ): PersistencePromise { + return bundlesStore(transaction).put( + toDbBundle(this.serializer, bundleMetadata) + ); + } + + getNamedQuery( + transaction: PersistenceTransaction, + queryName: string + ): PersistencePromise { + return namedQueriesStore(transaction) + .get(queryName) + .next(query => { + if (query) { + return fromDbNamedQuery(this.serializer, query); + } + return undefined; + }); + } + + saveNamedQuery( + transaction: PersistenceTransaction, + query: bundleProto.NamedQuery + ): PersistencePromise { + return namedQueriesStore(transaction).put( + toDbNamedQuery(this.serializer, query) + ); + } +} + +/** + * Helper to get a typed SimpleDbStore for the bundles object store. + */ +function bundlesStore( + txn: PersistenceTransaction +): SimpleDbStore { + return IndexedDbPersistence.getStore( + txn, + DbBundle.store + ); +} + +/** + * Helper to get a typed SimpleDbStore for the namedQueries object store. + */ +function namedQueriesStore( + txn: PersistenceTransaction +): SimpleDbStore { + return IndexedDbPersistence.getStore( + txn, + DbNamedQuery.store + ); +} diff --git a/packages/firestore/src/local/indexeddb_persistence.ts b/packages/firestore/src/local/indexeddb_persistence.ts index 4409e0fab79..86d183f8f2e 100644 --- a/packages/firestore/src/local/indexeddb_persistence.ts +++ b/packages/firestore/src/local/indexeddb_persistence.ts @@ -32,6 +32,7 @@ import { EncodedResourcePath, encodeResourcePath } from './encoded_resource_path'; +import { IndexedDbBundleCache } from './indexeddb_bundle_cache'; import { IndexedDbIndexManager } from './indexeddb_index_manager'; import { IndexedDbMutationQueue, @@ -226,6 +227,7 @@ export class IndexedDbPersistence implements Persistence { private readonly targetCache: IndexedDbTargetCache; private readonly indexManager: IndexedDbIndexManager; private readonly remoteDocumentCache: IndexedDbRemoteDocumentCache; + private readonly bundleCache: IndexedDbBundleCache; private readonly webStorage: Storage; readonly referenceDelegate: IndexedDbLruDelegate; @@ -259,6 +261,7 @@ export class IndexedDbPersistence implements Persistence { this.serializer, this.indexManager ); + this.bundleCache = new IndexedDbBundleCache(this.serializer); if (platform.window && platform.window.localStorage) { this.window = platform.window; this.webStorage = this.window.localStorage; @@ -763,6 +766,14 @@ export class IndexedDbPersistence implements Persistence { return this.indexManager; } + getBundleCache(): IndexedDbBundleCache { + debugAssert( + this.started, + 'Cannot initialize BundleCache before persistence is started.' + ); + return this.bundleCache; + } + runTransaction( action: string, mode: PersistenceTransactionMode, diff --git a/packages/firestore/src/local/indexeddb_schema.ts b/packages/firestore/src/local/indexeddb_schema.ts index 2026b373adb..579a8d9eeeb 100644 --- a/packages/firestore/src/local/indexeddb_schema.ts +++ b/packages/firestore/src/local/indexeddb_schema.ts @@ -1092,11 +1092,11 @@ export type DbBundlesKey = string; /** * A object representing a bundle loaded by the SDK. */ -export class DbBundles { +export class DbBundle { /** Name of the IndexedDb object store. */ static store = 'bundles'; - static keyPath = ['bundleId']; + static keyPath = 'bundleId'; constructor( /** The ID of the loaded bundle. */ @@ -1109,8 +1109,8 @@ export class DbBundles { } function createBundlesStore(db: IDBDatabase): void { - db.createObjectStore(DbBundles.store, { - keyPath: DbBundles.keyPath + db.createObjectStore(DbBundle.store, { + keyPath: DbBundle.keyPath }); } @@ -1119,11 +1119,11 @@ export type DbNamedQueriesKey = string; /** * A object representing a named query loaded by the SDK via a bundle. */ -export class DbNamedQueries { +export class DbNamedQuery { /** Name of the IndexedDb object store. */ static store = 'namedQueries'; - static keyPath = ['name']; + static keyPath = 'name'; constructor( /** The name of the query. */ @@ -1136,8 +1136,8 @@ export class DbNamedQueries { } function createNamedQueriesStore(db: IDBDatabase): void { - db.createObjectStore(DbNamedQueries.store, { - keyPath: DbNamedQueries.keyPath + db.createObjectStore(DbNamedQuery.store, { + keyPath: DbNamedQuery.keyPath }); } @@ -1174,7 +1174,7 @@ export const V8_STORES = [...V6_STORES, DbCollectionParent.store]; // V10 does not change the set of stores. -export const V11_STORES = [...V8_STORES, DbCollectionParent.store]; +export const V11_STORES = [...V8_STORES, DbBundle.store, DbNamedQuery.store]; /** * The list of all default IndexedDB stores used throughout the SDK. This is diff --git a/packages/firestore/src/local/local_serializer.ts b/packages/firestore/src/local/local_serializer.ts index e15697b934c..d6507198200 100644 --- a/packages/firestore/src/local/local_serializer.ts +++ b/packages/firestore/src/local/local_serializer.ts @@ -31,7 +31,9 @@ import { debugAssert, fail } from '../util/assert'; import { ByteString } from '../util/byte_string'; import { Target } from '../core/target'; import { + DbBundle, DbMutationBatch, + DbNamedQuery, DbNoDocument, DbQuery, DbRemoteDocument, @@ -41,10 +43,13 @@ import { DbUnknownDocument } from './indexeddb_schema'; import { TargetData, TargetPurpose } from './target_data'; +import { Bundle, NamedQuery } from '../core/bundle'; +import { Query } from '../core/query'; +import * as bundleProto from '../protos/firestore_bundle_proto'; /** Serializer for values stored in the LocalStore. */ export class LocalSerializer { - constructor(private remoteSerializer: JsonProtoSerializer) {} + constructor(readonly remoteSerializer: JsonProtoSerializer) {} /** Decodes a remote document from storage locally to a Document. */ fromDbRemoteDocument(remoteDoc: DbRemoteDocument): MaybeDocument { @@ -124,12 +129,12 @@ export class LocalSerializer { return SnapshotVersion.fromTimestamp(timestamp); } - private toDbTimestamp(snapshotVersion: SnapshotVersion): DbTimestamp { + toDbTimestamp(snapshotVersion: SnapshotVersion): DbTimestamp { const timestamp = snapshotVersion.toTimestamp(); return new DbTimestamp(timestamp.seconds, timestamp.nanoseconds); } - private fromDbTimestamp(dbTimestamp: DbTimestamp): SnapshotVersion { + fromDbTimestamp(dbTimestamp: DbTimestamp): SnapshotVersion { const timestamp = new Timestamp( dbTimestamp.seconds, dbTimestamp.nanoseconds @@ -239,3 +244,99 @@ export class LocalSerializer { function isDocumentQuery(dbQuery: DbQuery): dbQuery is api.DocumentsTarget { return (dbQuery as api.DocumentsTarget).documents !== undefined; } + +/** Encodes a DbBundle to a Bundle. */ +export function fromDbBundle( + serializer: LocalSerializer, + dbBundle: DbBundle +): Bundle { + return { + id: dbBundle.bundleId, + createTime: serializer.fromDbTimestamp(dbBundle.createTime), + version: dbBundle.version + }; +} + +/** Encodes a BundleMetadata to a DbBundle. */ +export function toDbBundle( + serializer: LocalSerializer, + metadata: bundleProto.BundleMetadata +): DbBundle { + return { + bundleId: metadata.id!, + createTime: serializer.toDbTimestamp( + serializer.remoteSerializer.fromVersion(metadata.createTime!) + ), + version: metadata.version! + }; +} + +/** Encodes a DbNamedQuery to a NamedQuery. */ +export function fromDbNamedQuery( + serializer: LocalSerializer, + dbNamedQuery: DbNamedQuery +): NamedQuery { + return { + name: dbNamedQuery.name, + query: fromBundledQuery(serializer, dbNamedQuery.bundledQuery), + readTime: serializer.fromDbTimestamp(dbNamedQuery.readTime) + }; +} + +/** Encodes a NamedQuery from a bundle proto to a DbNamedQuery. */ +export function toDbNamedQuery( + serializer: LocalSerializer, + query: bundleProto.NamedQuery +): DbNamedQuery { + return { + name: query.name!, + readTime: serializer.toDbTimestamp( + serializer.remoteSerializer.fromVersion(query.readTime!) + ), + bundledQuery: query.bundledQuery! + }; +} + +/** + * Encodes a `BundledQuery` from bundle proto to a Query object. + * + * This reconstructs the original query used to build the bundle being loaded, + * including features exists only in SDKs (for example: limit-to-last). + */ +export function fromBundledQuery( + serializer: LocalSerializer, + bundledQuery: bundleProto.BundledQuery +): Query { + const query = serializer.remoteSerializer.convertQueryTargetToQuery({ + parent: bundledQuery.parent!, + structuredQuery: bundledQuery.structuredQuery! + }); + if (bundledQuery.limitType === 'LAST') { + return query.withLimitToLast(query.limit); + } + return query; +} + +/** Encodes a NamedQuery proto object to a NamedQuery model object. */ +export function fromProtoNamedQuery( + serializer: LocalSerializer, + namedQuery: bundleProto.NamedQuery +): NamedQuery { + return { + name: namedQuery.name!, + query: fromBundledQuery(serializer, namedQuery.bundledQuery!), + readTime: serializer.remoteSerializer.fromVersion(namedQuery.readTime!) + }; +} + +/** Encodes a BundleMetadata proto object to a Bundle model object. */ +export function fromBundleMetadata( + serializer: LocalSerializer, + metadata: bundleProto.BundleMetadata +): Bundle { + return { + id: metadata.id!, + version: metadata.version!, + createTime: serializer.remoteSerializer.fromVersion(metadata.createTime!) + }; +} diff --git a/packages/firestore/src/local/memory_bundle_cache.ts b/packages/firestore/src/local/memory_bundle_cache.ts new file mode 100644 index 00000000000..eb16c8f8de8 --- /dev/null +++ b/packages/firestore/src/local/memory_bundle_cache.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright 2020 Google LLC + * + * 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 { PersistenceTransaction } from './persistence'; +import { PersistencePromise } from './persistence_promise'; +import * as bundleProto from '../protos/firestore_bundle_proto'; +import { BundleCache } from './bundle_cache'; +import { Bundle, NamedQuery } from '../core/bundle'; +import { + fromBundleMetadata, + fromProtoNamedQuery, + LocalSerializer +} from './local_serializer'; + +export class MemoryBundleCache implements BundleCache { + private bundles = new Map(); + private namedQueries = new Map(); + + constructor(private serializer: LocalSerializer) {} + + getBundle( + transaction: PersistenceTransaction, + bundleId: string + ): PersistencePromise { + return PersistencePromise.resolve(this.bundles.get(bundleId)); + } + + saveBundleMetadata( + transaction: PersistenceTransaction, + bundleMetadata: bundleProto.BundleMetadata + ): PersistencePromise { + this.bundles.set( + bundleMetadata.id!, + fromBundleMetadata(this.serializer, bundleMetadata) + ); + return PersistencePromise.resolve(); + } + + getNamedQuery( + transaction: PersistenceTransaction, + queryName: string + ): PersistencePromise { + return PersistencePromise.resolve(this.namedQueries.get(queryName)); + } + + saveNamedQuery( + transaction: PersistenceTransaction, + query: bundleProto.NamedQuery + ): PersistencePromise { + this.namedQueries.set( + query.name!, + fromProtoNamedQuery(this.serializer, query) + ); + return PersistencePromise.resolve(); + } +} diff --git a/packages/firestore/src/local/memory_persistence.ts b/packages/firestore/src/local/memory_persistence.ts index 391f8686565..ce84ff95ff2 100644 --- a/packages/firestore/src/local/memory_persistence.ts +++ b/packages/firestore/src/local/memory_persistence.ts @@ -45,6 +45,9 @@ import { import { PersistencePromise } from './persistence_promise'; import { ReferenceSet } from './reference_set'; import { TargetData } from './target_data'; +import { MemoryBundleCache } from './memory_bundle_cache'; +import { JsonProtoSerializer } from '../remote/serializer'; +import { LocalSerializer } from './local_serializer'; const LOG_TAG = 'MemoryPersistence'; /** @@ -63,7 +66,9 @@ export class MemoryPersistence implements Persistence { private mutationQueues: { [user: string]: MemoryMutationQueue } = {}; private readonly remoteDocumentCache: MemoryRemoteDocumentCache; private readonly targetCache: MemoryTargetCache; + private readonly bundleCache: MemoryBundleCache; private readonly listenSequence = new ListenSequence(0); + private serializer: LocalSerializer; private _started = false; @@ -76,7 +81,8 @@ export class MemoryPersistence implements Persistence { * checked or asserted on every access. */ constructor( - referenceDelegateFactory: (p: MemoryPersistence) => MemoryReferenceDelegate + referenceDelegateFactory: (p: MemoryPersistence) => MemoryReferenceDelegate, + serializer: JsonProtoSerializer ) { this._started = true; this.referenceDelegate = referenceDelegateFactory(this); @@ -88,6 +94,8 @@ export class MemoryPersistence implements Persistence { this.indexManager, sizer ); + this.serializer = new LocalSerializer(serializer); + this.bundleCache = new MemoryBundleCache(this.serializer); } start(): Promise { @@ -132,6 +140,10 @@ export class MemoryPersistence implements Persistence { return this.remoteDocumentCache; } + getBundleCache(): MemoryBundleCache { + return this.bundleCache; + } + runTransaction( action: string, mode: PersistenceTransactionMode, diff --git a/packages/firestore/src/local/persistence.ts b/packages/firestore/src/local/persistence.ts index 146b0331738..84debbc8afa 100644 --- a/packages/firestore/src/local/persistence.ts +++ b/packages/firestore/src/local/persistence.ts @@ -25,6 +25,7 @@ import { PersistencePromise } from './persistence_promise'; import { TargetCache } from './target_cache'; import { RemoteDocumentCache } from './remote_document_cache'; import { TargetData } from './target_data'; +import { BundleCache } from './bundle_cache'; export const PRIMARY_LEASE_LOST_ERROR_MSG = 'The current tab is not in the required state to perform this operation. ' + @@ -216,6 +217,15 @@ export interface Persistence { */ getRemoteDocumentCache(): RemoteDocumentCache; + /** + * Returns a BundleCache representing the persisted cache of loaded bundles. + * + * Note: The implementation is free to return the same instance every time + * this is called. In particular, the memory-backed implementation does this + * to emulate the persisted implementation to the extent possible. + */ + getBundleCache(): BundleCache; + /** * Returns an IndexManager instance that manages our persisted query indexes. * diff --git a/packages/firestore/src/remote/serializer.ts b/packages/firestore/src/remote/serializer.ts index de29942ffc2..db1ff3f3c39 100644 --- a/packages/firestore/src/remote/serializer.ts +++ b/packages/firestore/src/remote/serializer.ts @@ -787,7 +787,7 @@ export class JsonProtoSerializer { return result; } - fromQueryTarget(target: api.QueryTarget): Target { + convertQueryTargetToQuery(target: api.QueryTarget): Query { let path = this.fromQueryPath(target.parent!); const query = target.structuredQuery!; @@ -840,7 +840,11 @@ export class JsonProtoSerializer { LimitType.First, startAt, endAt - ).toTarget(); + ); + } + + fromQueryTarget(target: api.QueryTarget): Target { + return this.convertQueryTargetToQuery(target).toTarget(); } toListenRequestLabels( diff --git a/packages/firestore/test/unit/local/bundle_cache.test.ts b/packages/firestore/test/unit/local/bundle_cache.test.ts new file mode 100644 index 00000000000..8761acb9391 --- /dev/null +++ b/packages/firestore/test/unit/local/bundle_cache.test.ts @@ -0,0 +1,209 @@ +/** + * @license + * Copyright 2020 Google LLC + * + * 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 { filter, orderBy, path } from '../../util/helpers'; +import { TestBundleCache } from './test_bundle_cache'; +import { SnapshotVersion } from '../../../src/core/snapshot_version'; +import { Timestamp } from '../../../src/api/timestamp'; +import { Query } from '../../../src/core/query'; +import { + clearTestPersistence, + JSON_SERIALIZER, + testIndexedDbPersistence, + testMemoryEagerPersistence +} from './persistence_test_helpers'; +import { ResourcePath } from '../../../src/model/path'; +import { NamedQuery } from '../../../src/core/bundle'; + +describe('MemoryBundleCache', () => { + let cache: TestBundleCache; + + beforeEach(async () => { + cache = await testMemoryEagerPersistence().then( + persistence => new TestBundleCache(persistence) + ); + }); + + genericBundleCacheTests(() => cache); +}); + +describe('IndexedDbBundleCache', () => { + if (!IndexedDbPersistence.isAvailable()) { + console.warn('No IndexedDB. Skipping IndexedDbBundleCache tests.'); + return; + } + + let cache: TestBundleCache; + let persistence: IndexedDbPersistence; + beforeEach(async () => { + persistence = await testIndexedDbPersistence(); + cache = new TestBundleCache(persistence); + }); + + afterEach(async () => { + await persistence.shutdown(); + await clearTestPersistence(); + }); + + genericBundleCacheTests(() => cache); +}); + +/** + * Defines the set of tests to run against both bundle cache implementations. + */ +function genericBundleCacheTests(cacheFn: () => TestBundleCache): void { + let cache: TestBundleCache; + + beforeEach(async () => { + cache = cacheFn(); + }); + + function verifyNamedQuery( + actual: NamedQuery, + expectedName: string, + expectedQuery: Query, + expectedReadSeconds: number, + expectedReadNanos: number + ): void { + expect(actual.name).to.equal(expectedName); + expect(actual.query.isEqual(expectedQuery)).to.be.true; + expect( + actual.readTime.isEqual( + SnapshotVersion.fromTimestamp( + new Timestamp(expectedReadSeconds, expectedReadNanos) + ) + ) + ).to.be.true; + } + + it('returns undefined when bundle id is not found', async () => { + expect(await cache.getBundle('bundle-1')).to.be.undefined; + }); + + it('returns saved bundle', async () => { + await cache.saveBundleMetadata({ + id: 'bundle-1', + version: 1, + createTime: { seconds: 1, nanos: 9999 } + }); + expect(await cache.getBundle('bundle-1')).to.deep.equal({ + id: 'bundle-1', + version: 1, + createTime: SnapshotVersion.fromTimestamp(new Timestamp(1, 9999)) + }); + + // Overwrite + await cache.saveBundleMetadata({ + id: 'bundle-1', + version: 2, + createTime: { seconds: 2, nanos: 1111 } + }); + expect(await cache.getBundle('bundle-1')).to.deep.equal({ + id: 'bundle-1', + version: 2, + createTime: SnapshotVersion.fromTimestamp(new Timestamp(2, 1111)) + }); + }); + + it('returns undefined when query name is not found', async () => { + expect(await cache.getNamedQuery('query-1')).to.be.undefined; + }); + + it('returns saved collection queries', async () => { + const query = Query.atPath(path('collection')) + .addFilter(filter('sort', '>=', 2)) + .addOrderBy(orderBy('sort')); + const queryTarget = JSON_SERIALIZER.toQueryTarget(query.toTarget()); + + await cache.setNamedQuery({ + name: 'query-1', + readTime: { seconds: 1, nanos: 9999 }, + bundledQuery: { + parent: queryTarget.parent, + structuredQuery: queryTarget.structuredQuery + } + }); + + const namedQuery = await cache.getNamedQuery('query-1'); + verifyNamedQuery(namedQuery!, 'query-1', query, 1, 9999); + }); + + it('returns saved collection group queries', async () => { + const query = new Query(ResourcePath.EMPTY_PATH, 'collection'); + const queryTarget = JSON_SERIALIZER.toQueryTarget(query.toTarget()); + + await cache.setNamedQuery({ + name: 'query-1', + readTime: { seconds: 1, nanos: 9999 }, + bundledQuery: { + parent: queryTarget.parent, + structuredQuery: queryTarget.structuredQuery, + limitType: undefined + } + }); + + const namedQuery = await cache.getNamedQuery('query-1'); + verifyNamedQuery(namedQuery!, 'query-1', query, 1, 9999); + }); + + it('returns expected limit queries', async () => { + const query = Query.atPath(path('collection')) + .addOrderBy(orderBy('sort')) + .withLimitToFirst(3); + const queryTarget = JSON_SERIALIZER.toQueryTarget(query.toTarget()); + + await cache.setNamedQuery({ + name: 'query-1', + readTime: { seconds: 1, nanos: 9999 }, + bundledQuery: { + parent: queryTarget.parent, + structuredQuery: queryTarget.structuredQuery, + limitType: 'FIRST' + } + }); + + const namedQuery = await cache.getNamedQuery('query-1'); + verifyNamedQuery(namedQuery!, 'query-1', query, 1, 9999); + }); + + it('returns expected limit to last queries', async () => { + const query = Query.atPath(path('collection')) + .addOrderBy(orderBy('sort')) + .withLimitToLast(3); + // Simulating bundle building for limit-to-last queries from the server + // SDKs: they save the equivelent limit-to-first queries with a limitType + // value 'LAST'. Client SDKs should apply a withLimitToLast when they see + // limitType 'LAST' from bundles. + const limitQuery = query.withLimitToFirst(3); + const queryTarget = JSON_SERIALIZER.toQueryTarget(limitQuery.toTarget()); + + await cache.setNamedQuery({ + name: 'query-1', + readTime: { seconds: 1, nanos: 9999 }, + bundledQuery: { + parent: queryTarget.parent, + structuredQuery: queryTarget.structuredQuery, + limitType: 'LAST' + } + }); + + const namedQuery = await cache.getNamedQuery('query-1'); + verifyNamedQuery(namedQuery!, 'query-1', query, 1, 9999); + }); +} diff --git a/packages/firestore/test/unit/local/persistence_test_helpers.ts b/packages/firestore/test/unit/local/persistence_test_helpers.ts index b60b32d4264..9733ae9e009 100644 --- a/packages/firestore/test/unit/local/persistence_test_helpers.ts +++ b/packages/firestore/test/unit/local/persistence_test_helpers.ts @@ -86,7 +86,7 @@ export const INDEXEDDB_TEST_DATABASE_NAME = IndexedDbPersistence.buildStoragePrefix(TEST_DATABASE_INFO) + IndexedDbPersistence.MAIN_DATABASE; -const JSON_SERIALIZER = new JsonProtoSerializer(TEST_DATABASE_ID, { +export const JSON_SERIALIZER = new JsonProtoSerializer(TEST_DATABASE_ID, { useProto3Json: true }); @@ -131,13 +131,16 @@ export async function testIndexedDbPersistence( /** Creates and starts a MemoryPersistence instance for testing. */ export async function testMemoryEagerPersistence(): Promise { - return new MemoryPersistence(MemoryEagerDelegate.factory); + return new MemoryPersistence(MemoryEagerDelegate.factory, JSON_SERIALIZER); } export async function testMemoryLruPersistence( params: LruParams = LruParams.DEFAULT ): Promise { - return new MemoryPersistence(p => new MemoryLruDelegate(p, params)); + return new MemoryPersistence( + p => new MemoryLruDelegate(p, params), + JSON_SERIALIZER + ); } /** Clears the persistence in tests */ diff --git a/packages/firestore/test/unit/local/test_bundle_cache.ts b/packages/firestore/test/unit/local/test_bundle_cache.ts new file mode 100644 index 00000000000..e0ac098952c --- /dev/null +++ b/packages/firestore/test/unit/local/test_bundle_cache.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2020 Google LLC + * + * 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 { Persistence } from '../../../src/local/persistence'; +import { BundleCache } from '../../../src/local/bundle_cache'; +import { Bundle, NamedQuery } from '../../../src/core/bundle'; +import * as bundleProto from '../../../src/protos/firestore_bundle_proto'; + +/** + * A wrapper around a BundleCache that automatically creates a + * transaction around every operation to reduce test boilerplate. + */ +export class TestBundleCache { + private readonly cache: BundleCache; + + constructor(private readonly persistence: Persistence) { + this.cache = persistence.getBundleCache(); + } + + getBundle(bundleId: string): Promise { + return this.persistence.runTransaction( + 'getBundle', + 'readonly', + transaction => { + return this.cache.getBundle(transaction, bundleId); + } + ); + } + + saveBundleMetadata(metadata: bundleProto.BundleMetadata): Promise { + return this.persistence.runTransaction( + 'saveBundleMetadata', + 'readwrite', + transaction => { + return this.cache.saveBundleMetadata(transaction, metadata); + } + ); + } + + getNamedQuery(name: string): Promise { + return this.persistence.runTransaction( + 'getNamedQuery', + 'readonly', + transaction => { + return this.cache.getNamedQuery(transaction, name); + } + ); + } + + setNamedQuery(query: bundleProto.NamedQuery): Promise { + return this.persistence.runTransaction( + 'setNamedQuery', + 'readwrite', + transaction => { + return this.cache.saveNamedQuery(transaction, query); + } + ); + } +} diff --git a/packages/firestore/test/unit/specs/spec_test_components.ts b/packages/firestore/test/unit/specs/spec_test_components.ts index 9fba3e611a4..2bf5b0883d6 100644 --- a/packages/firestore/test/unit/specs/spec_test_components.ts +++ b/packages/firestore/test/unit/specs/spec_test_components.ts @@ -51,6 +51,7 @@ import { ViewSnapshot } from '../../../src/core/view_snapshot'; import { Query } from '../../../src/core/query'; import { Mutation } from '../../../src/model/mutation'; import { expect } from 'chai'; +import { JSON_SERIALIZER } from '../local/persistence_test_helpers'; /** * A test-only MemoryPersistence implementation that is able to inject @@ -161,7 +162,8 @@ export class MockMemoryComponentProvider extends MemoryComponentProvider { return new MockMemoryPersistence( this.gcEnabled ? MemoryEagerDelegate.factory - : p => new MemoryLruDelegate(p, LruParams.DEFAULT) + : p => new MemoryLruDelegate(p, LruParams.DEFAULT), + JSON_SERIALIZER ); } }