diff --git a/packages/firestore/src/local/shared_client_state.ts b/packages/firestore/src/local/shared_client_state.ts index bd9fb0b576c..9f8f3cdff4a 100644 --- a/packages/firestore/src/local/shared_client_state.ts +++ b/packages/firestore/src/local/shared_client_state.ts @@ -18,10 +18,12 @@ import { Code, FirestoreError } from '../util/error'; import { BatchId, TargetId } from '../core/types'; import { assert } from '../util/assert'; import { debug, error } from '../util/log'; -import { primitiveComparator } from '../util/misc'; +import { min, primitiveComparator } from '../util/misc'; import { SortedSet } from '../util/sorted_set'; import { isSafeInteger } from '../util/types'; import * as objUtils from '../util/obj'; +import { User } from '../auth/user'; +import { SharedClientStateSyncer } from './shared_client_state_syncer'; const LOG_TAG = 'SharedClientState'; @@ -31,6 +33,14 @@ const LOG_TAG = 'SharedClientState'; // fs_clients__ const CLIENT_STATE_KEY_PREFIX = 'fs_clients'; +// The format of the LocalStorage key that stores the mutation state is: +// fs_mutations__ (for unauthenticated users) +// or: fs_mutations___ +// +// 'user_uid' is last to avoid needing to escape '_' characters that it might +// contain. +const MUTATION_BATCH_KEY_PREFIX = 'fs_mutations'; + /** * A randomly-generated key assigned to each Firestore instance at startup. */ @@ -45,9 +55,12 @@ export type ClientKey = string; * * `SharedClientState` is primarily used for synchronization in Multi-Tab * environments. Each tab is responsible for registering its active query - * targets and mutations. As state changes happen in other clients, the - * `SharedClientState` class will call back into SyncEngine to keep the - * local state up to date. + * targets and mutations. `SharedClientState` will then notify the listener + * assigned to `.syncEngine` for updates to mutations and queries that + * originated in other clients. + * + * To receive notifications, `.syncEngine` has to be assigned before calling + * `start()`. * * TODO(multitab): Add callbacks to SyncEngine */ @@ -85,12 +98,107 @@ export interface SharedClientState { * `knownClients` and registers listeners for updates to new and existing * clients. */ - start(knownClients: ClientKey[]): void; + start(initialUser: User, knownClients: ClientKey[]): void; /** Shuts down the `SharedClientState` and its listeners. */ shutdown(): void; } +// Visible for testing +export type MutationBatchState = 'pending' | 'acknowledged' | 'rejected'; + +/** + * The JSON representation of a mutation batch's metadata as used during + * LocalStorage serialization. The UserId and BatchId is omitted as it is + * encoded as part of the key. + */ +interface MutationMetadataSchema { + state: MutationBatchState; + error?: { code: string; message: string }; // Only set when state === 'rejected' +} + +/** + * Holds the state of a mutation batch, including its user ID, batch ID and + * whether the batch is 'pending', 'acknowledged' or 'rejected'. + */ +// Visible for testing +export class MutationMetadata { + constructor( + readonly user: User, + readonly batchId: BatchId, + readonly state: MutationBatchState, + readonly error?: FirestoreError + ) { + assert( + (error !== undefined) === (state === 'rejected'), + `MutationMetadata must contain an error iff state is 'rejected'` + ); + } + + /** + * Parses a MutationMetadata from its JSON representation in LocalStorage. + * Logs a warning and returns null if the format of the data is not valid. + */ + static fromLocalStorageEntry( + user: User, + batchId: BatchId, + value: string + ): MutationMetadata | null { + const mutationBatch = JSON.parse(value) as MutationMetadataSchema; + + let validData = + typeof mutationBatch === 'object' && + ['pending', 'acknowledged', 'rejected'].indexOf(mutationBatch.state) !== + -1 && + (mutationBatch.error === undefined || + typeof mutationBatch.error === 'object'); + + let firestoreError = undefined; + + if (validData && mutationBatch.error) { + validData = + typeof mutationBatch.error.message === 'string' && + typeof mutationBatch.error.code === 'string'; + if (validData) { + firestoreError = new FirestoreError( + mutationBatch.error.code as Code, + mutationBatch.error.message + ); + } + } + + if (validData) { + return new MutationMetadata( + user, + batchId, + mutationBatch.state, + firestoreError + ); + } else { + error( + LOG_TAG, + `Failed to parse mutation state for ID '${batchId}': ${value}` + ); + return null; + } + } + + toLocalStorageJSON(): string { + const batchMetadata: MutationMetadataSchema = { + state: this.state + }; + + if (this.error) { + batchMetadata.error = { + code: this.error.code, + message: this.error.message + }; + } + + return JSON.stringify(batchMetadata); + } +} + /** * The JSON representation of a clients's metadata as used during LocalStorage * serialization. The ClientKey is omitted here as it is encoded as part of the @@ -123,7 +231,7 @@ export interface ClientState { */ class RemoteClientState implements ClientState { private constructor( - readonly clientKey: ClientKey, + readonly clientId: ClientKey, readonly lastUpdateTime: Date, readonly activeTargetIds: SortedSet, readonly minMutationBatchId: BatchId | null, @@ -132,7 +240,7 @@ class RemoteClientState implements ClientState { /** * Parses a RemoteClientState from the JSON representation in LocalStorage. - * Logs a warning and returns null if the data could not be parsed. + * Logs a warning and returns null if the format of the data is not valid. */ static fromLocalStorageEntry( clientKey: string, @@ -166,13 +274,13 @@ class RemoteClientState implements ClientState { clientState.minMutationBatchId, clientState.maxMutationBatchId ); + } else { + error( + LOG_TAG, + `Failed to parse client data for instance '${clientKey}': ${value}` + ); + return null; } - - error( - LOG_TAG, - `Failed to parse client data for instance '${clientKey}': ${value}` - ); - return null; } } @@ -259,20 +367,25 @@ export class LocalClientState implements ClientState { /** * `WebStorageSharedClientState` uses WebStorage (window.localStorage) as the - * backing store for the SharedClientState. It keeps track of all active + * backing store for the SharedClientState. It keeps track of all active * clients and supports modifications of the current client's data. */ +// TODO(multitab): Rename all usages of LocalStorage to WebStorage to better differentiate from LocalClient. export class WebStorageSharedClientState implements SharedClientState { + syncEngine: SharedClientStateSyncer | null; + private readonly storage: Storage; private readonly localClientStorageKey: string; private readonly activeClients: { [key: string]: ClientState } = {}; - private readonly storageListener = this.handleStorageEvent.bind(this); + private readonly storageListener = this.handleLocalStorageEvent.bind(this); private readonly clientStateKeyRe: RegExp; + private readonly mutationBatchKeyRe: RegExp; + private user: User; private started = false; constructor( private readonly persistenceKey: string, - private readonly localClientKey: string + private readonly localClientKey: ClientKey ) { if (!WebStorageSharedClientState.isAvailable()) { throw new FirestoreError( @@ -281,13 +394,16 @@ export class WebStorageSharedClientState implements SharedClientState { ); } this.storage = window.localStorage; - this.localClientStorageKey = this.toLocalStorageClientKey( + this.localClientStorageKey = this.toLocalStorageClientStateKey( this.localClientKey ); this.activeClients[this.localClientKey] = new LocalClientState(); this.clientStateKeyRe = new RegExp( `^${CLIENT_STATE_KEY_PREFIX}_${persistenceKey}_([^_]*)$` ); + this.mutationBatchKeyRe = new RegExp( + `^${MUTATION_BATCH_KEY_PREFIX}_${persistenceKey}_(\\d+)(?:_(.*))?$` + ); } /** Returns 'true' if LocalStorage is available in the current environment. */ @@ -295,35 +411,37 @@ export class WebStorageSharedClientState implements SharedClientState { return typeof window !== 'undefined' && window.localStorage != null; } - start(knownClients: ClientKey[]): void { + // TODO(multitab): Handle user changes. + start(initialUser: User, knownClients: ClientKey[]): void { assert(!this.started, 'WebStorageSharedClientState already started'); window.addEventListener('storage', this.storageListener); for (const clientKey of knownClients) { - const storageKey = this.toLocalStorageClientKey(clientKey); - const clientState = RemoteClientState.fromLocalStorageEntry( - clientKey, - this.storage.getItem(storageKey) + const storageItem = this.storage.getItem( + this.toLocalStorageClientStateKey(clientKey) ); - if (clientState) { - this.activeClients[clientState.clientKey] = clientState; + if (storageItem) { + const clientState = RemoteClientState.fromLocalStorageEntry( + clientKey, + storageItem + ); + if (clientState) { + this.activeClients[clientState.clientId] = clientState; + } } } + this.user = initialUser; this.started = true; - this.persistState(); + this.persistClientState(); } + // TODO(multitab): Return BATCHID_UNKNOWN instead of null getMinimumGlobalPendingMutation(): BatchId | null { - let minMutationBatch = null; + let minMutationBatch: number | null = null; objUtils.forEach(this.activeClients, (key, value) => { - if (minMutationBatch === null) { - minMutationBatch = value.minMutationBatchId; - } else { - minMutationBatch = Math.min(value.minMutationBatchId, minMutationBatch); - } + minMutationBatch = min(minMutationBatch, value.minMutationBatchId); }); - return minMutationBatch; } @@ -337,22 +455,23 @@ export class WebStorageSharedClientState implements SharedClientState { addLocalPendingMutation(batchId: BatchId): void { this.localClientState.addPendingMutation(batchId); - this.persistState(); + this.persistMutationState(batchId, 'pending'); + this.persistClientState(); } removeLocalPendingMutation(batchId: BatchId): void { this.localClientState.removePendingMutation(batchId); - this.persistState(); + this.persistClientState(); } addLocalQueryTarget(targetId: TargetId): void { this.localClientState.addQueryTarget(targetId); - this.persistState(); + this.persistClientState(); } removeLocalQueryTarget(targetId: TargetId): void { this.localClientState.removeQueryTarget(targetId); - this.persistState(); + this.persistClientState(); } shutdown(): void { @@ -365,7 +484,7 @@ export class WebStorageSharedClientState implements SharedClientState { this.started = false; } - private handleStorageEvent(event: StorageEvent): void { + private handleLocalStorageEvent(event: StorageEvent): void { if (!this.started) { return; } @@ -376,17 +495,28 @@ export class WebStorageSharedClientState implements SharedClientState { event.key !== this.localClientStorageKey, 'Received LocalStorage notification for local change.' ); - const clientKey = this.fromLocalStorageClientKey(event.key); - if (clientKey) { - if (event.newValue == null) { - delete this.activeClients[clientKey]; + + if (this.clientStateKeyRe.test(event.key)) { + if (event.newValue != null) { + const clientState = this.fromLocalStorageClientState( + event.key, + event.newValue + ); + if (clientState) { + this.activeClients[clientState.clientId] = clientState; + } } else { - const newClient = RemoteClientState.fromLocalStorageEntry( - clientKey, + const clientId = this.fromLocalStorageClientStateKey(event.key); + delete this.activeClients[clientId]; + } + } else if (this.mutationBatchKeyRe.test(event.key)) { + if (event.newValue !== null) { + const mutationMetadata = this.fromLocalStorageMutationMetadata( + event.key, event.newValue ); - if (newClient) { - this.activeClients[newClient.clientKey] = newClient; + if (mutationMetadata) { + this.handleMutationBatchEvent(mutationMetadata); } } } @@ -397,7 +527,7 @@ export class WebStorageSharedClientState implements SharedClientState { return this.activeClients[this.localClientKey] as LocalClientState; } - private persistState(): void { + private persistClientState(): void { // TODO(multitab): Consider rate limiting/combining state updates for // clients that frequently update their client state. assert(this.started, 'WebStorageSharedClientState used before started.'); @@ -409,8 +539,31 @@ export class WebStorageSharedClientState implements SharedClientState { ); } + private persistMutationState( + batchId: BatchId, + state: MutationBatchState, + error?: FirestoreError + ) { + const mutationState = new MutationMetadata( + this.user, + batchId, + state, + error + ); + + let mutationKey = `${MUTATION_BATCH_KEY_PREFIX}_${ + this.persistenceKey + }_${batchId}`; + + if (this.user.isAuthenticated()) { + mutationKey += `_${this.user.uid}`; + } + + this.storage.setItem(mutationKey, mutationState.toLocalStorageJSON()); + } + /** Assembles the key for a client state in LocalStorage */ - private toLocalStorageClientKey(clientKey: string): string { + private toLocalStorageClientStateKey(clientKey: string): string { assert( clientKey.indexOf('_') === -1, `Client key cannot contain '_', but was '${clientKey}'` @@ -423,8 +576,72 @@ export class WebStorageSharedClientState implements SharedClientState { * Parses a client state key in LocalStorage. Returns null if the key does not * match the expected key format. */ - private fromLocalStorageClientKey(key: string): string | null { + private fromLocalStorageClientStateKey(key: string): ClientKey | null { const match = this.clientStateKeyRe.exec(key); return match ? match[1] : null; } + + /** + * Parses a client state in LocalStorage. Returns 'null' if the value could + * not be parsed. + */ + private fromLocalStorageClientState( + key: string, + value: string + ): RemoteClientState | null { + const clientId = this.fromLocalStorageClientStateKey(key); + assert(clientId !== null, `Cannot parse client state key '${key}'`); + return RemoteClientState.fromLocalStorageEntry(clientId, value); + } + + /** + * Parses a mutation batch state in LocalStorage. Returns 'null' if the value + * could not be parsed. + */ + private fromLocalStorageMutationMetadata( + key: string, + value: string + ): MutationMetadata | null { + const match = this.mutationBatchKeyRe.exec(key); + assert(match !== null, `Cannot parse mutation batch key '${key}'`); + + const batchId = Number(match[1]); + const userId = match[2] !== undefined ? match[2] : null; + return MutationMetadata.fromLocalStorageEntry( + new User(userId), + batchId, + value + ); + } + + private async handleMutationBatchEvent( + mutationBatch: MutationMetadata + ): Promise { + assert( + this.syncEngine !== null, + 'syncEngine property must be set in order to handle events' + ); + + if (mutationBatch.user.uid !== this.user.uid) { + debug( + LOG_TAG, + `Ignoring mutation for non-active user ${mutationBatch.user.uid}` + ); + return; + } + + switch (mutationBatch.state) { + case 'pending': + return this.syncEngine.applyPendingBatch(mutationBatch.batchId); + case 'acknowledged': + return this.syncEngine.applySuccessfulWrite(mutationBatch.batchId); + case 'rejected': + return this.syncEngine.rejectFailedWrite( + mutationBatch.batchId, + mutationBatch.error + ); + default: + throw new Error('Not implemented'); + } + } } diff --git a/packages/firestore/src/local/shared_client_state_syncer.ts b/packages/firestore/src/local/shared_client_state_syncer.ts new file mode 100644 index 00000000000..e0d9f0bc136 --- /dev/null +++ b/packages/firestore/src/local/shared_client_state_syncer.ts @@ -0,0 +1,39 @@ +/** + * 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 { BatchId } from '../core/types'; +import { FirestoreError } from '../util/error'; + +/** + * An interface that describes the actions the SharedClientState class needs to + * perform on a cooperating synchronization engine. + */ +export interface SharedClientStateSyncer { + /** + * Registers a new pending mutation batch. + */ + applyPendingBatch(batchId: BatchId): Promise; + + /** + * Applies the result of a successful write of a mutation batch. + */ + applySuccessfulWrite(batchId: BatchId): Promise; + + /** + * Rejects a failed mutation batch. + */ + rejectFailedWrite(batchId: BatchId, err: FirestoreError): Promise; +} diff --git a/packages/firestore/src/remote/remote_syncer.ts b/packages/firestore/src/remote/remote_syncer.ts index 7918187b3da..07360b8c536 100644 --- a/packages/firestore/src/remote/remote_syncer.ts +++ b/packages/firestore/src/remote/remote_syncer.ts @@ -21,7 +21,7 @@ import { FirestoreError } from '../util/error'; import { RemoteEvent } from './remote_event'; /** - * A interface that describes the actions the RemoteStore needs to perform on + * An interface that describes the actions the RemoteStore needs to perform on * a cooperating synchronization engine. */ export interface RemoteSyncer { diff --git a/packages/firestore/src/util/misc.ts b/packages/firestore/src/util/misc.ts index f699b58e004..c5b45825903 100644 --- a/packages/firestore/src/util/misc.ts +++ b/packages/firestore/src/util/misc.ts @@ -52,6 +52,22 @@ export function primitiveComparator(left: T, right: T): number { return 0; } +/** Implementation of min() that ignores null values. */ +export function min(...values: Array): number | null { + let min = null; + + for (const value of values) { + if (value !== null) { + if (min == null) { + min = value; + } else if (value < min) { + min = value; + } + } + } + + return min; +} /** Duck-typed interface for objects that have an isEqual() method. */ export interface Equatable { isEqual(other: T): boolean; diff --git a/packages/firestore/test/unit/local/persistence_test_helpers.ts b/packages/firestore/test/unit/local/persistence_test_helpers.ts index e910830a7b4..1b4d17ed22a 100644 --- a/packages/firestore/test/unit/local/persistence_test_helpers.ts +++ b/packages/firestore/test/unit/local/persistence_test_helpers.ts @@ -27,6 +27,9 @@ import { import { BatchId, TargetId } from '../../../src/core/types'; import { BrowserPlatform } from '../../../src/platform_browser/browser_platform'; import { AsyncQueue } from '../../../src/util/async_queue'; +import { User } from '../../../src/auth/user'; +import { SharedClientStateSyncer } from '../../../src/local/shared_client_state_syncer'; +import { FirestoreError } from '../../../src/util/error'; /** The persistence prefix used for testing in IndexedBD and LocalStorage. */ export const TEST_PERSISTENCE_PREFIX = 'PersistenceTestHelpers'; @@ -70,14 +73,25 @@ export async function testMemoryPersistence(): Promise { return persistence; } +class NoOpSharedClientStateSyncer implements SharedClientStateSyncer { + async applyPendingBatch(batchId: BatchId): Promise {} + async applySuccessfulWrite(batchId: BatchId): Promise {} + async rejectFailedWrite( + batchId: BatchId, + err: FirestoreError + ): Promise {} +} + /** * Creates and starts a WebStorageSharedClientState instance for testing, * destroying any previous contents in LocalStorage if they existed. */ export async function testWebStorageSharedClientState( + user: User, instanceKey: string, - existingMutationBatchIds: BatchId[], - existingQueryTargetIds: TargetId[] + sharedClientSyncer?: SharedClientStateSyncer, + existingMutationBatchIds?: BatchId[], + existingQueryTargetIds?: TargetId[] ): Promise { let key; for (let i = 0; (key = window.localStorage.key(i)) !== null; ++i) { @@ -88,6 +102,9 @@ export async function testWebStorageSharedClientState( const knownInstances = []; + existingMutationBatchIds = existingMutationBatchIds || []; + existingQueryTargetIds = existingQueryTargetIds || []; + if ( existingMutationBatchIds.length > 0 || existingQueryTargetIds.length > 0 @@ -101,7 +118,8 @@ export async function testWebStorageSharedClientState( knownInstances.push(SECONDARY_INSTANCE_KEY); - await secondaryClientState.start([]); + secondaryClientState.syncEngine = new NoOpSharedClientStateSyncer(); + await secondaryClientState.start(user, [SECONDARY_INSTANCE_KEY]); for (const batchId of existingMutationBatchIds) { secondaryClientState.addLocalPendingMutation(batchId); @@ -112,10 +130,13 @@ export async function testWebStorageSharedClientState( } } + sharedClientSyncer = sharedClientSyncer || new NoOpSharedClientStateSyncer(); + const sharedClientState = new WebStorageSharedClientState( TEST_PERSISTENCE_PREFIX, instanceKey ); - await sharedClientState.start(knownInstances); + sharedClientState.syncEngine = sharedClientSyncer; + await sharedClientState.start(user, knownInstances); return sharedClientState; } diff --git a/packages/firestore/test/unit/local/web_storage_shared_client_state.test.ts b/packages/firestore/test/unit/local/web_storage_shared_client_state.test.ts index ed77dc445a7..b82577f3f9f 100644 --- a/packages/firestore/test/unit/local/web_storage_shared_client_state.test.ts +++ b/packages/firestore/test/unit/local/web_storage_shared_client_state.test.ts @@ -18,11 +18,15 @@ import * as persistenceHelpers from './persistence_test_helpers'; import { WebStorageSharedClientState, SharedClientState, - LocalClientState + LocalClientState, + MutationMetadata } from '../../../src/local/shared_client_state'; import { BatchId, TargetId } from '../../../src/core/types'; import { AutoId } from '../../../src/util/misc'; import { expect } from 'chai'; +import { User } from '../../../src/auth/user'; +import { FirestoreError } from '../../../src/util/error'; +import { SharedClientStateSyncer } from '../../../src/local/shared_client_state_syncer'; /** * The tests assert that the lastUpdateTime of each row in LocalStorage gets @@ -31,6 +35,45 @@ import { expect } from 'chai'; */ const GRACE_INTERVAL_MS = 100; +const AUTHENTICATED_USER = new User('test'); +const UNAUTHENTICATED_USER = User.UNAUTHENTICATED; + +function mutationKey(user: User, batchId: BatchId) { + if (user.isAuthenticated()) { + return `fs_mutations_${ + persistenceHelpers.TEST_PERSISTENCE_PREFIX + }_${batchId}_${user.uid}`; + } else { + return `fs_mutations_${ + persistenceHelpers.TEST_PERSISTENCE_PREFIX + }_${batchId}`; + } +} + +/** + * Implementation of `SharedClientStateSyncer` that aggregates its callback data. + */ +class TestClientSyncer implements SharedClientStateSyncer { + readonly pendingBatches: BatchId[] = []; + readonly acknowledgedBatches: BatchId[] = []; + readonly rejectedBatches: { [batchId: number]: FirestoreError } = {}; + + async applyPendingBatch(batchId: BatchId): Promise { + this.pendingBatches.push(batchId); + } + + async applySuccessfulWrite(batchId: BatchId): Promise { + this.acknowledgedBatches.push(batchId); + } + + async rejectFailedWrite( + batchId: BatchId, + err: FirestoreError + ): Promise { + this.rejectedBatches[batchId] = err; + } +} + describe('WebStorageSharedClientState', () => { if (!WebStorageSharedClientState.isAvailable()) { console.warn( @@ -41,11 +84,40 @@ describe('WebStorageSharedClientState', () => { const localStorage = window.localStorage; - let sharedClientState: SharedClientState; + let sharedClientState: SharedClientState | null; + let previousAddEventListener; let ownerId; + let writeToLocalStorage: ( + key: string, + value: string | null + ) => void = () => {}; + beforeEach(() => { ownerId = AutoId.newId(); + previousAddEventListener = window.addEventListener; + + // We capture the listener here so that we can invoke it from the local + // client. If we directly relied on LocalStorage listeners, we would not + // receive events for local writes. + window.addEventListener = (type, callback) => { + expect(type).to.equal('storage'); + writeToLocalStorage = (key, value) => { + callback({ + key: key, + storageArea: window.localStorage, + newValue: value + }); + }; + }; + }); + + afterEach(() => { + if (sharedClientState) { + sharedClientState.shutdown(); + } + + window.addEventListener = previousAddEventListener; }); function assertClientState( @@ -76,36 +148,61 @@ describe('WebStorageSharedClientState', () => { expect(actual.maxMutationBatchId).to.equal(maxMutationBatchId); } + // TODO(multitab): Add tests for acknowledged and failed batches once + // SharedClientState can handle these updates. describe('persists mutation batches', () => { + function assertBatchState( + batchId: BatchId, + mutationBatchState: string, + err?: FirestoreError + ): void { + const actual = JSON.parse( + localStorage.getItem(mutationKey(AUTHENTICATED_USER, batchId)) + ); + + expect(actual.state).to.equal(mutationBatchState); + + const expectedMembers = ['state']; + + if (mutationBatchState === 'error') { + expectedMembers.push('error'); + expect(actual.error.code).to.equal(err.code); + expect(actual.error.message).to.equal(err.message); + } + + expect(Object.keys(actual)).to.have.members(expectedMembers); + } + beforeEach(() => { return persistenceHelpers - .testWebStorageSharedClientState(ownerId, [], []) - .then(nc => { - sharedClientState = nc; + .testWebStorageSharedClientState(AUTHENTICATED_USER, ownerId) + .then(clientState => { + sharedClientState = clientState; }); }); - afterEach(() => { - sharedClientState.shutdown(); - }); - it('when empty', () => { assertClientState([], null, null); }); - it('with one batch', () => { + it('with one pending batch', () => { sharedClientState.addLocalPendingMutation(0); assertClientState([], 0, 0); + assertBatchState(0, 'pending'); }); - it('with multiple batches', () => { + it('with multiple pending batches', () => { sharedClientState.addLocalPendingMutation(0); sharedClientState.addLocalPendingMutation(1); assertClientState([], 0, 1); + assertBatchState(0, 'pending'); + assertBatchState(1, 'pending'); sharedClientState.addLocalPendingMutation(2); sharedClientState.addLocalPendingMutation(3); assertClientState([], 0, 3); + assertBatchState(2, 'pending'); + assertBatchState(3, 'pending'); // Note: The Firestore client only ever removes mutations in order. sharedClientState.removeLocalPendingMutation(0); @@ -117,16 +214,12 @@ describe('WebStorageSharedClientState', () => { describe('persists query targets', () => { beforeEach(() => { return persistenceHelpers - .testWebStorageSharedClientState(ownerId, [], []) - .then(nc => { - sharedClientState = nc; + .testWebStorageSharedClientState(AUTHENTICATED_USER, ownerId) + .then(clientState => { + sharedClientState = clientState; }); }); - afterEach(() => { - sharedClientState.shutdown(); - }); - it('when empty', () => { assertClientState([], null, null); }); @@ -144,35 +237,26 @@ describe('WebStorageSharedClientState', () => { }); }); - describe('combines data', () => { - let previousAddEventListener; - let storageCallback: (StorageEvent) => void; - + describe('combines client state', () => { beforeEach(() => { - previousAddEventListener = window.addEventListener; - - // We capture the listener here so that we can invoke it from the local - // client. If we directly relied on LocalStorage listeners, we would not - // receive events for local writes. - window.addEventListener = (type, callback) => { - expect(type).to.equal('storage'); - storageCallback = callback; - }; - return persistenceHelpers - .testWebStorageSharedClientState(ownerId, [1, 2], [3, 4]) + .testWebStorageSharedClientState( + AUTHENTICATED_USER, + ownerId, + undefined, + [1, 2], + [3, 4] + ) .then(nc => { sharedClientState = nc; - expect(storageCallback).to.not.be.undefined; + expect(writeToLocalStorage).to.exist; }); }); - afterEach(() => { - sharedClientState.shutdown(); - window.addEventListener = previousAddEventListener; - }); - - function verifyState(minBatchId: BatchId, expectedTargets: TargetId[]) { + function verifyState( + minBatchId: BatchId | null, + expectedTargets: TargetId[] + ) { const actualTargets = sharedClientState.getAllActiveQueryTargets(); expect(actualTargets.toArray()).to.have.members(expectedTargets); @@ -206,36 +290,27 @@ describe('WebStorageSharedClientState', () => { persistenceHelpers.TEST_PERSISTENCE_PREFIX }_${AutoId.newId()}`; + // The prior client has one pending mutation and two active query targets + verifyState(1, [3, 4]); + const oldState = new LocalClientState(); oldState.addQueryTarget(5); + writeToLocalStorage(secondaryClientKey, oldState.toLocalStorageJSON()); + verifyState(1, [3, 4, 5]); + const updatedState = new LocalClientState(); updatedState.addQueryTarget(5); updatedState.addQueryTarget(6); + updatedState.addPendingMutation(0); - // The prior client has one pending mutation and two active query targets - verifyState(1, [3, 4]); - - storageCallback({ - key: secondaryClientKey, - storageArea: window.localStorage, - newValue: oldState.toLocalStorageJSON() - }); - verifyState(0, [3, 4, 5]); - - storageCallback({ - key: secondaryClientKey, - storageArea: window.localStorage, - newValue: updatedState.toLocalStorageJSON(), - oldValue: oldState.toLocalStorageJSON() - }); + writeToLocalStorage( + secondaryClientKey, + updatedState.toLocalStorageJSON() + ); verifyState(0, [3, 4, 5, 6]); - storageCallback({ - key: secondaryClientKey, - storageArea: window.localStorage, - oldValue: updatedState.toLocalStorageJSON() - }); + writeToLocalStorage(secondaryClientKey, null); verifyState(1, [3, 4]); }); @@ -253,12 +328,130 @@ describe('WebStorageSharedClientState', () => { verifyState(1, [3, 4]); // We ignore the newly added target. - storageCallback({ - key: secondaryClientKey, - storageArea: window.localStorage, - newValue: JSON.stringify(invalidState) - }); + writeToLocalStorage(secondaryClientKey, JSON.stringify(invalidState)); verifyState(1, [3, 4]); }); }); + + describe('processes mutation updates', () => { + function withUser( + user: User, + fn: (clientSyncer: TestClientSyncer) => Promise + ) { + const clientSyncer = new TestClientSyncer(); + + return persistenceHelpers + .testWebStorageSharedClientState(user, ownerId, clientSyncer) + .then(clientState => { + sharedClientState = clientState; + expect(writeToLocalStorage).to.exist; + }) + .then(() => fn(clientSyncer)); + } + + it('for pending mutation', () => { + return withUser(AUTHENTICATED_USER, async clientSyncer => { + writeToLocalStorage( + mutationKey(AUTHENTICATED_USER, 1), + new MutationMetadata( + AUTHENTICATED_USER, + 1, + 'pending' + ).toLocalStorageJSON() + ); + + expect(clientSyncer.pendingBatches).to.have.members([1]); + expect(clientSyncer.acknowledgedBatches).to.be.empty; + expect(clientSyncer.rejectedBatches).to.be.empty; + }); + }); + + it('for acknowledged mutation', () => { + return withUser(AUTHENTICATED_USER, async clientSyncer => { + writeToLocalStorage( + mutationKey(AUTHENTICATED_USER, 1), + new MutationMetadata( + AUTHENTICATED_USER, + 1, + 'acknowledged' + ).toLocalStorageJSON() + ); + + expect(clientSyncer.pendingBatches).to.be.empty; + expect(clientSyncer.acknowledgedBatches).to.have.members([1]); + expect(clientSyncer.rejectedBatches).to.be.empty; + }); + }); + + it('for rejected mutation', () => { + return withUser(AUTHENTICATED_USER, async clientSyncer => { + writeToLocalStorage( + mutationKey(AUTHENTICATED_USER, 1), + new MutationMetadata( + AUTHENTICATED_USER, + 1, + 'rejected', + new FirestoreError('internal', 'Test Error') + ).toLocalStorageJSON() + ); + + expect(clientSyncer.pendingBatches).to.be.empty; + expect(clientSyncer.acknowledgedBatches).to.be.empty; + expect(clientSyncer.rejectedBatches[1].code).to.equal('internal'); + expect(clientSyncer.rejectedBatches[1].message).to.equal('Test Error'); + }); + }); + + it('handles unauthenticated user', () => { + return withUser(UNAUTHENTICATED_USER, async clientSyncer => { + writeToLocalStorage( + mutationKey(UNAUTHENTICATED_USER, 1), + new MutationMetadata( + UNAUTHENTICATED_USER, + 1, + 'pending' + ).toLocalStorageJSON() + ); + + expect(clientSyncer.pendingBatches).to.have.members([1]); + }); + }); + + it('ignores different user', () => { + return withUser(AUTHENTICATED_USER, async clientSyncer => { + const otherUser = new User('foobar'); + + writeToLocalStorage( + mutationKey(AUTHENTICATED_USER, 1), + new MutationMetadata( + AUTHENTICATED_USER, + 1, + 'pending' + ).toLocalStorageJSON() + ); + writeToLocalStorage( + mutationKey(otherUser, 1), + new MutationMetadata(otherUser, 2, 'pending').toLocalStorageJSON() + ); + expect(clientSyncer.pendingBatches).to.have.members([1]); + }); + }); + + it('ignores invalid data', () => { + return withUser(AUTHENTICATED_USER, async clientSyncer => { + writeToLocalStorage( + mutationKey(AUTHENTICATED_USER, 1), + new MutationMetadata( + AUTHENTICATED_USER, + 1, + 'invalid' as any + ).toLocalStorageJSON() + ); + + expect(clientSyncer.pendingBatches).to.be.empty; + expect(clientSyncer.acknowledgedBatches).to.be.empty; + expect(clientSyncer.rejectedBatches).to.be.empty; + }); + }); + }); });