diff --git a/packages/rxfire/auth/index.ts b/packages/rxfire/auth/index.ts index 14ce26ce63a..d8e0ba8f00f 100644 --- a/packages/rxfire/auth/index.ts +++ b/packages/rxfire/auth/index.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { auth, User } from 'firebase/app'; +import { auth, User } from 'firebase'; import { Observable, from, of } from 'rxjs'; import { switchMap } from 'rxjs/operators'; @@ -47,7 +47,7 @@ export function user(auth: auth.Auth): Observable { * sign-out, and token refresh events * @param auth firebase.auth.Auth */ -export function idToken(auth: auth.Auth) { +export function idToken(auth: auth.Auth): Observable { return user(auth).pipe( switchMap(user => (user ? from(user.getIdToken()) : of(null))) ); diff --git a/packages/rxfire/auth/package.json b/packages/rxfire/auth/package.json index c891b72ad2e..d8e19b41d10 100644 --- a/packages/rxfire/auth/package.json +++ b/packages/rxfire/auth/package.json @@ -1,5 +1,6 @@ { "name": "rxfire/auth", "main": "dist/index.cjs.js", - "module": "dist/index.esm.js" + "module": "dist/index.esm.js", + "typings": "dist/auth/index.d.ts" } diff --git a/packages/rxfire/database/fromRef.d.ts b/packages/rxfire/database/fromRef.d.ts new file mode 100644 index 00000000000..c0a08b78c5c --- /dev/null +++ b/packages/rxfire/database/fromRef.d.ts @@ -0,0 +1,27 @@ +/** + * 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 { database } from 'firebase'; +import { Observable } from 'rxjs'; +import { ListenEvent, QueryChange } from './interfaces'; +/** + * Create an observable from a Database Reference or Database Query. + * @param ref Database Reference + * @param event Listen event type ('value', 'added', 'changed', 'removed', 'moved') + */ +export declare function fromRef( + ref: database.Query, + event: ListenEvent +): Observable; diff --git a/packages/rxfire/database/fromRef.ts b/packages/rxfire/database/fromRef.ts new file mode 100644 index 00000000000..9cd8b6a5c23 --- /dev/null +++ b/packages/rxfire/database/fromRef.ts @@ -0,0 +1,50 @@ +/** + * 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 { database } from 'firebase'; +import { Observable } from 'rxjs'; +import { map, delay, share } from 'rxjs/operators'; +import { ListenEvent, QueryChange } from './interfaces'; + +/** + * Create an observable from a Database Reference or Database Query. + * @param ref Database Reference + * @param event Listen event type ('value', 'added', 'changed', 'removed', 'moved') + */ +export function fromRef( + ref: database.Query, + event: ListenEvent +): Observable { + return new Observable(subscriber => { + const fn = ref.on( + event, + (snapshot, prevKey) => { + subscriber.next({ snapshot, prevKey, event }); + }, + subscriber.error.bind(subscriber) + ); + return { + unsubscribe() { + ref.off(event, fn); + } + }; + }).pipe( + // Ensures subscribe on observable is async. This handles + // a quirk in the SDK where on/once callbacks can happen + // synchronously. + delay(0) + ); +} diff --git a/packages/rxfire/database/index.ts b/packages/rxfire/database/index.ts new file mode 100644 index 00000000000..cdafece2c8d --- /dev/null +++ b/packages/rxfire/database/index.ts @@ -0,0 +1,20 @@ +/** + * 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. + */ + +export * from './fromRef'; +export * from './interfaces'; +export * from './list'; +export * from './object'; diff --git a/packages/rxfire/database/interfaces.d.ts b/packages/rxfire/database/interfaces.d.ts new file mode 100644 index 00000000000..a67024536e0 --- /dev/null +++ b/packages/rxfire/database/interfaces.d.ts @@ -0,0 +1,28 @@ +/** + * 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 { database } from 'firebase'; +export declare enum ListenEvent { + added = 'child_added', + removed = 'child_removed', + changed = 'child_changed', + moved = 'child_moved', + value = 'value' +} +export interface QueryChange { + snapshot: database.DataSnapshot; + prevKey: string | null | undefined; + event: ListenEvent; +} diff --git a/packages/rxfire/database/interfaces.ts b/packages/rxfire/database/interfaces.ts new file mode 100644 index 00000000000..88f72ca1fcd --- /dev/null +++ b/packages/rxfire/database/interfaces.ts @@ -0,0 +1,31 @@ +/** + * 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 { database } from 'firebase'; + +export enum ListenEvent { + added = 'child_added', + removed = 'child_removed', + changed = 'child_changed', + moved = 'child_moved', + value = 'value' +} + +export interface QueryChange { + snapshot: database.DataSnapshot; + prevKey: string | null | undefined; + event: ListenEvent; +} diff --git a/packages/rxfire/database/list/audit-trail.d.ts b/packages/rxfire/database/list/audit-trail.d.ts new file mode 100644 index 00000000000..8f452f33022 --- /dev/null +++ b/packages/rxfire/database/list/audit-trail.d.ts @@ -0,0 +1,22 @@ +/** + * 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 { database } from 'firebase'; +import { Observable } from 'rxjs'; +import { QueryChange, ListenEvent } from '../interfaces'; +export declare function auditTrail( + query: database.Query, + events?: ListenEvent[] +): Observable; diff --git a/packages/rxfire/database/list/audit-trail.ts b/packages/rxfire/database/list/audit-trail.ts new file mode 100644 index 00000000000..80765b60100 --- /dev/null +++ b/packages/rxfire/database/list/audit-trail.ts @@ -0,0 +1,82 @@ +/** + * 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 { database } from 'firebase'; +import { Observable } from 'rxjs'; +import { QueryChange, ListenEvent } from '../interfaces'; +import { fromRef } from '../fromRef'; +import { map, withLatestFrom, scan, skipWhile } from 'rxjs/operators'; +import { stateChanges } from './index'; + +interface LoadedMetadata { + data: QueryChange; + lastKeyToLoad: any; +} + +export function auditTrail( + query: database.Query, + events?: ListenEvent[] +): Observable { + const auditTrail$ = stateChanges(query, events).pipe( + scan((current, changes) => [...current, changes], []) + ); + return waitForLoaded(query, auditTrail$); +} + +function loadedData(query: database.Query): Observable { + // Create an observable of loaded values to retrieve the + // known dataset. This will allow us to know what key to + // emit the "whole" array at when listening for child events. + return fromRef(query, ListenEvent.value).pipe( + map(data => { + // Store the last key in the data set + let lastKeyToLoad; + // Loop through loaded dataset to find the last key + data.snapshot.forEach(child => { + lastKeyToLoad = child.key; + return false; + }); + // return data set and the current last key loaded + return { data, lastKeyToLoad }; + }) + ); +} + +function waitForLoaded( + query: database.Query, + snap$: Observable +) { + const loaded$ = loadedData(query); + return loaded$.pipe( + withLatestFrom(snap$), + // Get the latest values from the "loaded" and "child" datasets + // We can use both datasets to form an array of the latest values. + map(([loaded, changes]) => { + // Store the last key in the data set + let lastKeyToLoad = loaded.lastKeyToLoad; + // Store all child keys loaded at this point + const loadedKeys = changes.map(change => change.snapshot.key); + return { changes, lastKeyToLoad, loadedKeys }; + }), + // This is the magical part, only emit when the last load key + // in the dataset has been loaded by a child event. At this point + // we can assume the dataset is "whole". + skipWhile(meta => meta.loadedKeys.indexOf(meta.lastKeyToLoad) === -1), + // Pluck off the meta data because the user only cares + // to iterate through the snapshots + map(meta => meta.changes) + ); +} diff --git a/packages/rxfire/database/list/index.ts b/packages/rxfire/database/list/index.ts new file mode 100644 index 00000000000..9edcb0d9050 --- /dev/null +++ b/packages/rxfire/database/list/index.ts @@ -0,0 +1,129 @@ +/** + * 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 { database } from 'firebase'; +import { QueryChange, ListenEvent } from '../interfaces'; +import { Observable, of, merge, from } from 'rxjs'; +import { validateEventsArray, isNil } from '../utils'; +import { fromRef } from '../fromRef'; +import { switchMap, scan, distinctUntilChanged, map } from 'rxjs/operators'; + +export function stateChanges(query: database.Query, events?: ListenEvent[]) { + events = validateEventsArray(events); + const childEvent$ = events.map(event => fromRef(query, event)); + return merge(...childEvent$); +} + +function fromOnce(query: database.Query): Observable { + return from(query.once(ListenEvent.value)).pipe( + map(snapshot => { + const event = ListenEvent.value; + return { snapshot, prevKey: null, event }; + }) + ); +} + +export function list( + query: database.Query, + events?: ListenEvent[] +): Observable { + events = validateEventsArray(events); + return fromOnce(query).pipe( + switchMap(change => { + const childEvent$ = [of(change)]; + events.forEach(event => childEvent$.push(fromRef(query, event))); + return merge(...childEvent$).pipe(scan(buildView, [])); + }), + distinctUntilChanged() + ); +} + +function positionFor(changes: QueryChange[], key) { + const len = changes.length; + for (let i = 0; i < len; i++) { + if (changes[i].snapshot.key === key) { + return i; + } + } + return -1; +} + +function positionAfter(changes: QueryChange[], prevKey?: string) { + if (isNil(prevKey)) { + return 0; + } else { + const i = positionFor(changes, prevKey); + if (i === -1) { + return changes.length; + } else { + return i + 1; + } + } +} + +function buildView(current: QueryChange[], change: QueryChange) { + const { snapshot, prevKey, event } = change; + const { key } = snapshot; + const currentKeyPosition = positionFor(current, key); + const afterPreviousKeyPosition = positionAfter(current, prevKey); + switch (event) { + case ListenEvent.value: + if (change.snapshot && change.snapshot.exists()) { + let prevKey = null; + change.snapshot.forEach(snapshot => { + const action: QueryChange = { + snapshot, + event: ListenEvent.value, + prevKey + }; + prevKey = snapshot.key; + current = [...current, action]; + return false; + }); + } + return current; + case ListenEvent.added: + if (currentKeyPosition > -1) { + // check that the previouskey is what we expect, else reorder + const previous = current[currentKeyPosition - 1]; + if (((previous && previous.snapshot.key) || null) != prevKey) { + current = current.filter(x => x.snapshot.key !== snapshot.key); + current.splice(afterPreviousKeyPosition, 0, change); + } + } else if (prevKey == null) { + return [change, ...current]; + } else { + current = current.slice(); + current.splice(afterPreviousKeyPosition, 0, change); + } + return current; + case ListenEvent.removed: + return current.filter(x => x.snapshot.key !== snapshot.key); + case ListenEvent.changed: + return current.map(x => (x.snapshot.key === key ? change : x)); + case ListenEvent.moved: + if (currentKeyPosition > -1) { + const data = current.splice(currentKeyPosition, 1)[0]; + current = current.slice(); + current.splice(afterPreviousKeyPosition, 0, data); + return current; + } + return current; + // default will also remove null results + default: + return current; + } +} diff --git a/packages/rxfire/database/object/index.ts b/packages/rxfire/database/object/index.ts new file mode 100644 index 00000000000..240b61aac95 --- /dev/null +++ b/packages/rxfire/database/object/index.ts @@ -0,0 +1,28 @@ +/** + * 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 { database } from 'firebase'; +import { QueryChange, ListenEvent } from '../interfaces'; +import { fromRef } from '../fromRef'; +import { Observable } from 'rxjs'; + +/** + * Get the snapshot changes of an object + * @param query + */ +export function object(query: database.Query): Observable { + return fromRef(query, ListenEvent.value); +} diff --git a/packages/rxfire/database/package.json b/packages/rxfire/database/package.json new file mode 100644 index 00000000000..aac222092e9 --- /dev/null +++ b/packages/rxfire/database/package.json @@ -0,0 +1,5 @@ +{ + "name": "rxfire/database", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js" +} diff --git a/packages/rxfire/database/utils.d.ts b/packages/rxfire/database/utils.d.ts new file mode 100644 index 00000000000..ac2cdeec7ab --- /dev/null +++ b/packages/rxfire/database/utils.d.ts @@ -0,0 +1,25 @@ +/** + * 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 { ListenEvent } from './interfaces'; +export declare function isNil(obj: any): boolean; +/** + * Check the length of the provided array. If it is empty return an array + * that is populated with all the Realtime Database child events. + * @param events + */ +export declare function validateEventsArray( + events?: ListenEvent[] +): ListenEvent[]; diff --git a/packages/rxfire/database/utils.ts b/packages/rxfire/database/utils.ts new file mode 100644 index 00000000000..e34b33b6964 --- /dev/null +++ b/packages/rxfire/database/utils.ts @@ -0,0 +1,38 @@ +/** + * 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 { ListenEvent } from './interfaces'; + +export function isNil(obj: any): boolean { + return obj === undefined || obj === null; +} + +/** + * Check the length of the provided array. If it is empty return an array + * that is populated with all the Realtime Database child events. + * @param events + */ +export function validateEventsArray(events?: ListenEvent[]) { + if (isNil(events) || events!.length === 0) { + events = [ + ListenEvent.added, + ListenEvent.removed, + ListenEvent.changed, + ListenEvent.moved + ]; + } + return events; +} diff --git a/packages/rxfire/docs/auth.md b/packages/rxfire/docs/auth.md new file mode 100644 index 00000000000..c5f7d0da150 --- /dev/null +++ b/packages/rxfire/docs/auth.md @@ -0,0 +1,78 @@ +# RxFire Auth + +## Auth State Observables + +### `authState()` +The `authState()` function creates an observable that emits authentication changes such as a logged out or logged in state. + +| | | +|-----------------|------------------------------------------| +| **function** | `authState()` | +| **params** | `auth.Auth` | +| **import path** | `rxfire/auth` | +| **return** | `Observable` | + +#### TypeScript Example +```ts +import { authState } from 'rxfire/firestore'; +import { auth, initializeApp } from 'firebase'; +import 'firebase/auth'; +import { filter } from 'rxjs/operators'; + +// Set up Firebase +const app = initializeApp({ /* config */ }); +const auth = app.auth(); +authState(auth).subscribe(user => { + console.log(user, ' will be null if logged out'); +}); + +// Listen only for logged in state +const loggedIn$ = authState(auth).pipe(filter(user => !!user)); +loggedIn$.subscribe(user => { console.log(user); }); +``` + +### `user()` +The `user()` function creates an observable that emits authentication changes such as a logged out, logged in, and token refresh state. The token refresh emissions is what makes `user()` different from `authState()`. + +| | | +|-----------------|------------------------------------------| +| **function** | `user()` | +| **params** | `auth.Auth` | +| **import path** | `rxfire/auth` | +| **return** | `Observable` | + +#### TypeScript Example +```ts +import { user } from 'rxfire/firestore'; +import { auth, initializeApp } from 'firebase'; +import 'firebase/auth'; +import { filter } from 'rxjs/operators'; + +// Set up Firebase +const app = initializeApp({ /* config */ }); +const auth = app.auth(); +user(auth).subscribe(u => { console.log(u); ); +``` + +### `idToken()` +The `idToken()` function creates an observable that emits the `idToken` refreshes. This is useful for keeping third party authentication in sync with Firebase Auth refreshes. + +| | | +|-----------------|------------------------------------------| +| **function** | `idToken()` | +| **params** | `auth.Auth` | +| **import path** | `rxfire/auth` | +| **return** | `Observable` | + +#### TypeScript Example +```ts +import { idToken } from 'rxfire/firestore'; +import { auth, initializeApp } from 'firebase'; +import 'firebase/auth'; +import { filter } from 'rxjs/operators'; + +// Set up Firebase +const app = initializeApp({ /* config */ }); +const auth = app.auth(); +idToken(auth).subscribe(token => { console.log(token); ); +``` diff --git a/packages/rxfire/docs/database.md b/packages/rxfire/docs/database.md new file mode 100644 index 00000000000..bca8be1c7d2 --- /dev/null +++ b/packages/rxfire/docs/database.md @@ -0,0 +1,248 @@ +# RxFire Database + +## Object Observables + +### `object()` +The `object()` function creates an observable that emits object changes. + +| | | +|-----------------|------------------------------------------| +| **function** | `object()` | +| **params** | `database.Reference` | +| **import path** | `rxfire/database` | +| **return** | `Observable` | + +#### TypeScript Example +```ts +import { object } from 'rxfire/database'; +import { database, initializeApp } from 'firebase'; +import 'firebase/database'; +import { map } from 'rxjs/operators'; + +// Set up Firebase +const app = initializeApp({ /* config */ }); +const db = app.database(); +const ref = db.ref('users/david'); + +// Seed the database +ref.set({ name: 'David' }); + +object(ref).subscribe(change => { + const { event, snapshot, prevKey } = change; + console.log(event, ' will always be value'); + console.log(prevKey, ' the previous key'); + console.log(snapshot.val(), ' this is the data'); +}); + +// Retrieve the data and key +object(ref) + .pipe(map(change => ({ _key: change.snapshot.key, ...change.snapshot.val() }))) + .subscribe(data => { console.log(data); }); +``` + +## List Observables + +### `list()` +The `list()` function creates an observable that emits a sorted array for each child event change. The optional `events` parameter will filter which child events populate the array. + +| | | +|-----------------|-------------------------------------------------------| +| **function** | `list()` | +| **params** | ref: `database.Reference` or `database.Query`, events?: `ListenEvent[]` | +| **import path** | `rxfire/database` | +| **return** | `Observable` | + +#### TypeScript Example +```ts +import { list, ListenEvent } from 'rxfire/database'; +import { database } from 'firebase'; +import 'firebase/database'; +import { map } from 'rxjs/operators'; + +// Set up Firebase +const app = initializeApp({ /* config */ }); +const db = app.database(); +const ref = db.ref('users'); + +// Seed the database +ref.push({ name: 'David' }); + +list(ref).subscribe(changes => { + changes.forEach(change => { + const { snapshot, event, prevKey } = change; + console.log(event, ' the event that populated the array'); + console.log(prevKey, ' the previous key'); + console.log(snapshot.val(), ' this is the data of the single change'); + }); +}); + +// Retrieve the data, key, and event +list(ref) + .pipe( + map(changes => changes.map(c => { + return { _key: c.snapshot.key, event: c.event, ...c.snapshot.val(); }; + )) + ) + .subscribe(users => { console.log(users); }) + +// Listen only to 'child_added' events +list(ref, [ListenEvent.added] /* 'child_added' for js */) + .subscribe(addedChanges => { console.log(addedChanges); }); + +// Listen only to 'child_added' and 'child_removed' events +list(ref, [ListenEvent.added, ListenEvent.removed] /* 'child_added', 'child_removed' for js */) + .subscribe(addedChanges => { console.log(addedChanges); }); +``` + +### `stateChanges()` +The `stateChanges()` function creates an observable that emits each time a change occurs at the reference or query passed. This is useful for tracking the changes in your list. The optional `events` parameter will filter which child events populate the array. + +| | | +|-----------------|------------------------------------------------------| +| **function** | `stateChanges()` | +| **params** | ref: `database.Reference` or `database.Query`, events?: `ListenEvent[]` | +| **import path** | `rxfire/database` | +| **return** | `Observable` | + +#### TypeScript Example +```ts +import { stateChanges, ListenEvent } from 'rxfire/database'; +import { database } from 'firebase'; +import 'firebase/database'; +import { map } from 'rxjs/operators'; + +// Set up Firebase +const app = initializeApp({ /* config */ }); +const db = app.database(); +const ref = db.ref('users'); + +// Seed the database +ref.push({ name: 'David' }); + +stateChanges(ref).subscribe(change => { + const { event, snapshot, prevKey } = change; + console.log(event, ' the event type that just occured'); + console.log(snapshot.val(), ' the value of the change'); +}); + +// Retrieve the data, event, and key +stateChanges(ref).pipe( + map(change => { + return { + _key: change.snapshot.key, + event: change.event, + ...change.snapshot.val(); + }; + }) +).subscribe(data => { console.log(data); }); + +// Listen only to 'child_added' events +stateChanges(ref, [ListenEvent.added] /* 'child_added' for js */) + .subscribe(addedChanges => { console.log(addedChanges); }); + +// Listen only to 'child_added' and 'child_removed' events +stateChanges(ref, [ListenEvent.added, ListenEvent.removed] /* 'child_added', 'child_removed' for js */) + .subscribe(addedChanges => { console.log(addedChanges); }); + +``` + +### `auditTrail()` +The `auditTrail()` function creates an observable that emits the entire state trail. This is useful for debugging or replaying the state of a list in your app. The optional `events` parameter will filter which child events populate the array. + +| | | +|-----------------|------------------------------------------------------| +| **function** | `auditTrail()` | +| **params** | ref: `database.Reference` or `database.Query`, events?: `ListenEvent[]` | +| **import path** | `rxfire/database` | +| **return** | `Observable` | + +#### TypeScript Example +```ts +import { auditTrail, ListenEvent } from 'rxfire/database'; +import { database } from 'firebase'; +import 'firebase/database'; +import { map } from 'rxjs/operators'; + +// Set up Firebase +const app = initializeApp({ /* config */ }); +const db = app.database(); +const ref = db.ref('users'); + +// Seed the database +const davidRef = ref.push(); +davidRef.set({ name: 'David' }); + +auditTrail(ref).pipe( + map(change => { + return { + _key: change.snapshot.key, + event: change.event, + ...change.snapshot.val(); + }; + }) +).subscribe(stateTrail => { + console.log(stateTrail); + /** + first emission: + [{ _key: '3qtWqaKga8jA; name: 'David', event: 'child_added' }] + + second emission: + [ + { _key: '3qtWqaKga8jA; name: 'David', event: 'child_added' }, + { _key: '3qtWqaKga8jA; name: 'David', event: 'child_removed' } + ] + */ +}); + +// When more events occur the trail still contains the previous events +// In this case we'll remove the only item +davidRef.remove(); + +// Now this will trigger the subscribe function above +``` + +## Event Observables + +The `fromRef()` function creates an observable that emits reference changes. + +| | | +|-----------------|------------------------------------------| +| **function** | `fromRef()` | +| **params** | ref: `database.Reference` or `database.Query`, event: `ListenEvent` | +| **import path** | `rxfire/database` | +| **return** | `Observable` | + +#### TypeScript Example +```ts +import { fromRef, ListenEvent } from 'rxfire/database'; +import { database, initializeApp } from 'firebase'; +import 'firebase/database'; +import { merge } from 'rxjs'; +import { map } from 'rxjs/operators'; + +// Set up Firebase +const app = initializeApp({ /* config */ }); +const db = app.database(); +const ref = db.ref('users'); + +// Seed the database +ref.child('david').set({ name: 'David' }); + +// Subscribe to events +fromRef(ref, ListenEvent.value /* 'value' for js users */) + .subscribe(change => { + // Get value changes, this is basically what `object()` does + }); + +// Merge multiple events (however this is really what `stateChanges()` does) +const addedChanges = fromRef(ref, ListenEvent.added); +const removedChanges = fromRef(ref, ListenEvent.removed); +merge(addedChanges, removedChanges) + .subscribe(change => { + const { event, snapshot, prevKey } = change; + console.log(event); // This will be 'child_added' or 'child_removed' + // Note: Don't write this yourself. Use `stateChanges()` for this type of + // functionality. This is just an example of using fromRef for custom + // behavior. + }); +``` diff --git a/packages/rxfire/docs/firestore.md b/packages/rxfire/docs/firestore.md new file mode 100644 index 00000000000..e2fe9375dec --- /dev/null +++ b/packages/rxfire/docs/firestore.md @@ -0,0 +1,246 @@ +# RxFire Firestore + +## Document Observables + +### `doc()` +The `doc()` function creates an observable that emits document changes. + +| | | +|-----------------|------------------------------------------| +| **function** | `doc()` | +| **params** | `firestore.DocumentReference` | +| **import path** | `rxfire/firestore` | +| **return** | `Observable` | + +#### TypeScript Example +```ts +import { doc } from 'rxfire/firestore'; +import { firestore, initializeApp } from 'firebase'; +import 'firebase/firestore'; +import { map } from 'rxjs/operators'; + +// Set up Firebase +const app = initializeApp({ /* config */ }); +const db = app.firestore(); +const davidDoc = db.doc('users/david'); + +// Seed the firestore +davidDoc.set({ name: 'David' }); + +doc(davidDoc).subscribe(snapshot => { + console.log(snapshot.id); + console.log(snapshot.data()); +}); +``` + +## Collection Observables + +### `collection()` +The `collection()` function creates an observable that emits collection changes. + +| | | +|-----------------|------------------------------------------| +| **function** | `collection()` | +| **params** | `firestore.CollectionReference` | `firestore.Query` | +| **import path** | `rxfire/firestore` | +| **return** | `Observable` | + +#### TypeScript Example +```ts +import { collection } from 'rxfire/firestore'; +import { firestore, initializeApp } from 'firebase'; +import 'firebase/firestore'; +import { map } from 'rxjs/operators'; + +// Set up Firebase +const app = initializeApp({ /* config */ }); +const db = app.firestore(); +const davidDoc = db.doc('users/david'); + +// Seed the firestore +davidDoc.set({ name: 'David' }); + +collection(db.collection('users')) + .pipe(map(docs => docs.map(d => d.data()))) + .subscribe(users => { console.log(users) }); +``` + +### `docChanges()` +The `docChanges()` function creates an observable that emits the event changes on a collection. This is different than the collection function in that it does not contain the state of your application but only the individual changes. The optional `events` parameter will filter which child events populate the array. + +| | | +|-----------------|------------------------------------------| +| **function** | `docChanges()` | +| **params** | query: `firestore.CollectionReference` | `firestore.Query`, events?: `firestore.DocumentChangeType[]` | +| **import path** | `rxfire/firestore` | +| **return** | `Observable` | + +#### TypeScript Example +```ts +import { docChanges } from 'rxfire/firestore'; +import { firestore, initializeApp } from 'firebase'; +import 'firebase/firestore'; +import { map } from 'rxjs/operators'; + +// Set up Firebase +const app = initializeApp({ /* config */ }); +const db = app.firestore(); +const davidDoc = db.doc('users/david'); + +// Seed the firestore +davidDoc.set({ name: 'David' }); + +docChanges(db.collection('users')) + .subscribe(changes => { console.log(users) }); + +// Listen to only 'added' events +docChanges(db.collection('users'), ['added']) + .subscribe(addedEvents => { console.log(addedEvents) }); +``` + +### `sortedChanges()` +The `sortedChanges()` function creates an observable that emits the reduced state of individual changes. This is different than the collection function in that it creates an array out of every individual change to occur. It also contains the `type` property to indicate what kind of change occured. The optional `events` parameter will filter which child events populate the array. + +| | | +|-----------------|------------------------------------------| +| **function** | `sortedChanges()` | +| **params** | query: `firestore.CollectionReference` | `firestore.Query`, events?: `firestore.DocumentChangeType[]` | +| **import path** | `rxfire/firestore` | +| **return** | `Observable` | + +#### TypeScript Example +```ts +import { sortedChanges } from 'rxfire/firestore'; +import { firestore, initializeApp } from 'firebase'; +import 'firebase/firestore'; +import { map } from 'rxjs/operators'; + +// Set up Firebase +const app = initializeApp({ /* config */ }); +const db = app.firestore(); +const davidDoc = db.doc('users/david'); + +// Seed the firestore +davidDoc.set({ name: 'David' }); + +sortedChanges(db.collection('users')) + .subscribe(changes => { console.log(users) }); + +// Listen to only 'added' events +docChanges(db.collection('users'), ['added']) + .subscribe(addedEvents => { console.log(addedEvents) }); +``` + +### `auditTrail()` +The `auditTrail()` function creates an observable that emits the entire state trail. This is useful for debugging or replaying the state of a list in your app. The optional `events` parameter will filter which child events populate the array. + +| | | +|-----------------|------------------------------------------------------| +| **function** | `auditTrail()` | +| **params** | ref: `firestore.Reference` or `firestore.Query`, events?: `firestore.DocumentChangeType[]` | +| **import path** | `rxfire/firestore` | +| **return** | `Observable` | + +#### TypeScript Example +```ts +import { auditTrail } from 'rxfire/firestore'; +import { firestore } from 'firebase'; +import 'firebase/firestore'; +import { map } from 'rxjs/operators'; + +// Set up Firebase +const app = initializeApp({ /* config */ }); +const db = app.firestore(); +const collection = db.collection('users'); + +// Seed Firestore +const davidDoc = collection.doc('users/david'); +davidDoc.set({ name: 'David' }); + +auditTrail(collection).pipe( + map(change => { + return { + _key: change.snapshot.key, + event: change.event, + ...change.snapshot.val(); + }; + }) +).subscribe(stateTrail => { + console.log(stateTrail); + /** + first emission: + [{ _key: '3qtWqaKga8jA; name: 'David', event: 'added' }] + + second emission: + [ + { _key: '3qtWqaKga8jA; name: 'David', event: 'added' }, + { _key: '3qtWqaKga8jA; name: 'David', event: 'removed' } + ] + */ +}); + +// When more events occur the trail still contains the previous events +// In this case we'll remove the only item +davidDoc.delete(); + +// Now this will trigger the subscribe function above +``` + +## Event Observables + +### `fromDocRef()` +The `fromDocRef()` function creates an observable that emits document changes. This is an alias to the `doc()` function. + +| | | +|-----------------|------------------------------------------| +| **function** | `fromDocRef()` | +| **params** | ref: `firestore.Reference` | +| **import path** | `rxfire/firestore` | +| **return** | `Observable` | + +#### TypeScript Example +```ts +import { fromDocRef } from 'rxfire/firestore'; +import { firestore, initializeApp } from 'firebase'; +import 'firebase/firestore'; +import { map } from 'rxjs/operators'; + +// Set up Firebase +const app = initializeApp({ /* config */ }); +const db = app.firestore(); +const davidDoc = db.doc('users/david'); + +// Seed Firestore +davidDoc.set({ name: 'David' }); + +fromDocRef(davidDoc).subscribe(snap => { console.log(snap); }) +``` + +### `fromCollectionRef()` +The `fromCollectionRef()` function creates an observable that emits document changes. This is different than the `collection()` function in that it returns the full `QuerySnapshot` instead of plucking off the `QueryDocumentSnapshot[]` array. + +| | | +|-----------------|------------------------------------------| +| **function** | `fromCollectionRef()` | +| **params** | ref: `firestore.CollectionReference` or `firestore.Query` | +| **import path** | `rxfire/firestore` | +| **return** | `Observable` | + +#### TypeScript Example +```ts +import { fromCollectionRef } from 'rxfire/firestore'; +import { firestore, initializeApp } from 'firebase'; +import 'firebase/firestore'; +import { map } from 'rxjs/operators'; + +// Set up Firebase +const app = initializeApp({ /* config */ }); +const db = app.firestore(); +const collection = db.collection('users'); +const davidDoc = collection.doc('david'); + +// Seed Firestore +davidDoc.set({ name: 'David' }); + +fromCollectionRef(collection).subscribe(snap => { console.log(snap.docs); }) +``` diff --git a/packages/rxfire/firestore/document/index.ts b/packages/rxfire/firestore/document/index.ts index c808a8a5a3e..323acf3bb75 100644 --- a/packages/rxfire/firestore/document/index.ts +++ b/packages/rxfire/firestore/document/index.ts @@ -16,6 +16,7 @@ import { firestore } from 'firebase/app'; import { fromDocRef } from '../fromRef'; +import { Observable } from 'rxjs'; export function doc(ref: firestore.DocumentReference) { return fromDocRef(ref); diff --git a/packages/rxfire/firestore/package.json b/packages/rxfire/firestore/package.json index 63c78472064..4de130539a4 100644 --- a/packages/rxfire/firestore/package.json +++ b/packages/rxfire/firestore/package.json @@ -1,5 +1,6 @@ { "name": "rxfire/firestore", "main": "dist/index.cjs.js", - "module": "dist/index.esm.js" + "module": "dist/index.esm.js", + "typings": "dist/firestore/index.d.ts" } diff --git a/packages/rxfire/functions/package.json b/packages/rxfire/functions/package.json index 95a9b36735f..5c07e0bf8bc 100644 --- a/packages/rxfire/functions/package.json +++ b/packages/rxfire/functions/package.json @@ -1,5 +1,6 @@ { "name": "rxfire/functions", "main": "dist/index.cjs.js", - "module": "dist/index.esm.js" + "module": "dist/index.esm.js", + "typings": "dist/functions/index.d.ts" } diff --git a/packages/rxfire/package.json b/packages/rxfire/package.json index dedeff2eb43..dc084573c55 100644 --- a/packages/rxfire/package.json +++ b/packages/rxfire/package.json @@ -13,7 +13,8 @@ "firebase", "realtime", "storage", - "rxjs" + "rxjs", + "notifications" ], "repository": { "type": "git", @@ -29,8 +30,7 @@ "main": "dist/index.node.cjs.js", "browser": "dist/index.cjs.js", "module": "dist/index.esm.js", - "react-native": "dist/index.rn.cjs.js", - "dependencies": { + "peerDependencies": { "firebase": "5.2.0", "rxjs": "6.2.0" }, @@ -76,6 +76,8 @@ "/storage/dist", "/functions/package.json", "/functions/dist", + "/database/dist", + "/database/package.json", "/rxfire-auth.js", "/rxfire-auth.js.map", "/rxfire-firestore.js", @@ -83,6 +85,8 @@ "/rxfire-functions.js", "/rxfire-functions.js.map", "/rxfire-storage.js", - "/rxfire-storage.js.map" + "/rxfire-storage.js.map", + "/rxfire-database.js", + "/rxfire-database.js.map" ] } diff --git a/packages/rxfire/rollup.config.js b/packages/rxfire/rollup.config.js index d5dfa677161..1e5dc49f9c8 100644 --- a/packages/rxfire/rollup.config.js +++ b/packages/rxfire/rollup.config.js @@ -25,12 +25,14 @@ import authPkg from './auth/package.json'; import storagePkg from './storage/package.json'; import functionsPkg from './functions/package.json'; import firestorePkg from './firestore/package.json'; +import databasePkg from './database/package.json'; const pkgsByName = { auth: authPkg, storage: storagePkg, functions: functionsPkg, - firestore: firestorePkg + firestore: firestorePkg, + database: databasePkg }; const plugins = [ @@ -48,7 +50,7 @@ const external = [...Object.keys(pkg.dependencies || {}), 'rxjs/operators']; */ const GLOBAL_NAME = 'rxfire'; -const components = ['auth', 'storage', 'functions', 'firestore']; +const components = ['auth', 'storage', 'functions', 'firestore', 'database']; const componentBuilds = components .map(component => { const pkg = pkgsByName[component]; diff --git a/packages/rxfire/storage/package.json b/packages/rxfire/storage/package.json index ff40a8e3dc9..96ee8641809 100644 --- a/packages/rxfire/storage/package.json +++ b/packages/rxfire/storage/package.json @@ -1,5 +1,6 @@ { "name": "rxfire/storage", "main": "dist/index.cjs.js", - "module": "dist/index.esm.js" + "module": "dist/index.esm.js", + "typings": "dist/storage/index.d.ts" } diff --git a/packages/rxfire/test/database.test.ts b/packages/rxfire/test/database.test.ts new file mode 100644 index 00000000000..fd06f28f1b6 --- /dev/null +++ b/packages/rxfire/test/database.test.ts @@ -0,0 +1,614 @@ +/** + * 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 { initializeApp, database, app } from 'firebase'; +import { fromRef } from '../database/fromRef'; +import { list, ListenEvent } from '../database'; +import { take, skip, switchMap } from 'rxjs/operators'; +import { BehaviorSubject } from 'rxjs'; +import { auditTrail } from '../database/list/audit-trail'; + +export const TEST_PROJECT = require('../../../config/project.json'); + +const rando = () => + Math.random() + .toString(36) + .substring(5); + +let batch = (items: any[]) => { + let batch = {}; + Object.keys(items).forEach(function(key) { + const itemValue = items[key]; + batch[itemValue.key] = itemValue; + }); + // make batch immutable to preserve integrity + return Object.freeze(batch); +}; + +describe('RxFire Database', () => { + let app: app.App = null; + let database: database.Database = null; + let ref = (path: string) => { + app.database().goOffline(); + return app.database().ref(path); + }; + + function prepareList( + opts: { events?: ListenEvent[]; skipnumber: number } = { skipnumber: 0 } + ) { + const { events, skipnumber } = opts; + const aref = ref(rando()); + const snapChanges = list(aref, events); + return { + snapChanges: snapChanges.pipe(skip(skipnumber)), + ref: aref + }; + } + + /** + * Each test runs inside it's own app instance and the app + * is deleted after the test runs. + * + * Database tests run "offline" to reduce "flakeyness". + * + * Each test is responsible for seeding and removing data. Helper + * functions are useful if the process becomes brittle or tedious. + * Note that removing is less necessary since the tests are run + * offline. + * + * Note: Database tests do not run exactly the same offline as + * they do online. Querying can act differently, tests must + * account for this. + */ + beforeEach(() => { + app = initializeApp({ + projectId: TEST_PROJECT.projectId, + databaseURL: TEST_PROJECT.databaseURL + }); + database = app.database(); + database.goOffline(); + }); + + afterEach((done: MochaDone) => { + app.delete().then(() => done()); + }); + + describe('fromRef', () => { + const items = [{ name: 'one' }, { name: 'two' }, { name: 'three' }].map( + item => ({ key: rando(), ...item }) + ); + const itemsObj = batch(items); + + /** + * This test checks that "non-existent" or null value references are + * handled. + */ + it('it should should handle non-existence', done => { + const itemRef = ref(rando()); + itemRef.set({}); + const obs = fromRef(itemRef, ListenEvent.value); + obs + .pipe(take(1)) + .subscribe(change => { + expect(change.snapshot.exists()).to.equal(false); + expect(change.snapshot.val()).to.equal(null); + }) + .add(done); + }); + + /** + * This test checks that the Observable unsubscribe mechanism works. + * + * Calling unsubscribe should trigger the ref.off() method. + */ + it('it should listen and then unsubscribe', done => { + const itemRef = ref(rando()); + itemRef.set(itemsObj); + const obs = fromRef(itemRef, ListenEvent.value); + let count = 0; + const sub = obs.subscribe(_ => { + count = count + 1; + // hard coding count to one will fail if the unsub + // doesn't actually unsub + expect(count).to.equal(1); + done(); + sub.unsubscribe(); + itemRef.push({ name: 'anotha one' }); + }); + }); + + describe('events', () => { + /** + * This test provides the `child_added` event and tests that only + * `child_added` events are received. + */ + it('should stream back a child_added event', (done: any) => { + const itemRef = ref(rando()); + const data = itemsObj; + itemRef.set(data); + const obs = fromRef(itemRef, ListenEvent.added); + let count = 0; + const sub = obs.subscribe(change => { + count = count + 1; + const { event, snapshot } = change; + expect(event).to.equal(ListenEvent.added); + expect(snapshot.val()).to.eql(data[snapshot.key]); + if (count === items.length) { + done(); + sub.unsubscribe(); + expect(sub.closed).to.equal(true); + } + }); + }); + + /** + * This test provides the `child_changed` event and tests that only + * `child_changed` events are received. + */ + it('should stream back a child_changed event', (done: any) => { + const itemRef = ref(rando()); + itemRef.set(itemsObj); + const obs = fromRef(itemRef, ListenEvent.changed); + const name = 'look at what you made me do'; + const key = items[0].key; + const sub = obs.subscribe(change => { + const { event, snapshot } = change; + expect(event).to.equal(ListenEvent.changed); + expect(snapshot.key).to.equal(key); + expect(snapshot.val()).to.eql({ key, name }); + sub.unsubscribe(); + done(); + }); + itemRef.child(key).update({ name }); + }); + + /** + * This test provides the `child_removed` event and tests that only + * `child_removed` events are received. + */ + it('should stream back a child_removed event', (done: any) => { + const itemRef = ref(rando()); + itemRef.set(itemsObj); + const obs = fromRef(itemRef, ListenEvent.removed); + const key = items[0].key; + const name = items[0].name; + const sub = obs.subscribe(change => { + const { event, snapshot } = change; + expect(event).to.equal(ListenEvent.removed); + expect(snapshot.key).to.equal(key); + expect(snapshot.val()).to.eql({ key, name }); + sub.unsubscribe(); + done(); + }); + itemRef.child(key).remove(); + }); + + /** + * This test provides the `child_moved` event and tests that only + * `child_moved` events are received. + */ + it('should stream back a child_moved event', (done: any) => { + const itemRef = ref(rando()); + itemRef.set(itemsObj); + const obs = fromRef(itemRef, ListenEvent.moved); + const key = items[2].key; + const name = items[2].name; + const sub = obs.subscribe(change => { + const { event, snapshot } = change; + expect(event).to.equal(ListenEvent.moved); + expect(snapshot.key).to.equal(key); + expect(snapshot.val()).to.eql({ key, name }); + sub.unsubscribe(); + done(); + }); + itemRef.child(key).setPriority(-100, () => {}); + }); + + /** + * This test provides the `value` event and tests that only + * `value` events are received. + */ + it('should stream back a value event', (done: any) => { + const itemRef = ref(rando()); + const data = itemsObj; + itemRef.set(data); + const obs = fromRef(itemRef, ListenEvent.value); + const sub = obs.subscribe(change => { + const { event, snapshot } = change; + expect(event).to.equal(ListenEvent.value); + expect(snapshot.val()).to.eql(data); + done(); + sub.unsubscribe(); + expect(sub.closed).to.equal(true); + }); + }); + + /** + * This test provides queries a reference and checks that the queried + * values are streamed back. + */ + it('should stream back query results', (done: any) => { + const itemRef = ref(rando()); + itemRef.set(itemsObj); + const query = itemRef.orderByChild('name').equalTo(items[0].name); + const obs = fromRef(query, ListenEvent.value); + obs.subscribe(change => { + let child; + change.snapshot.forEach(snap => { + child = snap.val(); + return true; + }); + expect(child).to.eql(items[0]); + done(); + }); + }); + }); + }); + + describe('list', () => { + const items = [{ name: 'zero' }, { name: 'one' }, { name: 'two' }].map( + (item, i) => ({ key: `${i}`, ...item }) + ); + + const itemsObj = batch(items); + + describe('events', () => { + /** + * `value` events are provided first when subscribing to a list. We need + * to know what the "intial" data list is, so a value event is used. + */ + it('should stream value at first', done => { + const someRef = ref(rando()); + const obs = list(someRef, [ListenEvent.added]); + obs + .pipe(take(1)) + .subscribe(changes => { + const data = changes.map(change => change.snapshot.val()); + expect(data).to.eql(items); + }) + .add(done); + + someRef.set(itemsObj); + }); + + /** + * This test checks that `child_added` events are only triggered when + * specified in the events array. + * + * The first result is skipped because it is always `value`. A `take(1)` + * is used to close the stream after the `child_added` event occurs. + */ + it('should process a new child_added event', done => { + const aref = ref(rando()); + const obs = list(aref, [ListenEvent.added]); + obs + .pipe(skip(1), take(1)) + .subscribe(changes => { + const data = changes.map(change => change.snapshot.val()); + expect(data[3]).to.eql({ name: 'anotha one' }); + }) + .add(done); + aref.set(itemsObj); + aref.push({ name: 'anotha one' }); + }); + + /** + * This test checks that events are emitted in proper order. The reference + * is queried and the test ensures that the array is in proper order. + */ + it('should stream in order events', done => { + const aref = ref(rando()); + const obs = list(aref.orderByChild('name'), [ListenEvent.added]); + obs + .pipe(take(1)) + .subscribe(changes => { + const names = changes.map(change => change.snapshot.val().name); + expect(names[0]).to.eql('one'); + expect(names[1]).to.eql('two'); + expect(names[2]).to.eql('zero'); + }) + .add(done); + aref.set(itemsObj); + }); + + /** + * This test checks that the array is in order with child_added specified. + * A new record is added that appears on top of the query and the test + * skips the first value event and checks that the newly added item is + * on top. + */ + it('should stream in order events w/child_added', done => { + const aref = ref(rando()); + const obs = list(aref.orderByChild('name'), [ListenEvent.added]); + obs + .pipe(skip(1), take(1)) + .subscribe(changes => { + const names = changes.map(change => change.snapshot.val().name); + expect(names[0]).to.eql('anotha one'); + expect(names[1]).to.eql('one'); + expect(names[2]).to.eql('two'); + expect(names[3]).to.eql('zero'); + }) + .add(done); + aref.set(itemsObj); + aref.push({ name: 'anotha one' }); + }); + + /** + * This test checks that a filtered reference still emits the proper events. + */ + it('should stream events filtering', done => { + const aref = ref(rando()); + const obs = list(aref.orderByChild('name').equalTo('zero'), [ + ListenEvent.added + ]); + obs + .pipe(skip(1), take(1)) + .subscribe(changes => { + const names = changes.map(change => change.snapshot.val().name); + expect(names[0]).to.eql('zero'); + expect(names[1]).to.eql('zero'); + }) + .add(done); + aref.set(itemsObj); + aref.push({ name: 'zero' }); + }); + + /** + * This test checks that the a `child_removed` event is processed in the + * array by testing that the new length is shorter than the original + * length. + */ + it('should process a new child_removed event', done => { + const aref = ref(rando()); + const obs = list(aref, [ListenEvent.added, ListenEvent.removed]); + const sub = obs + .pipe(skip(1), take(1)) + .subscribe(changes => { + const data = changes.map(change => change.snapshot.val()); + expect(data.length).to.eql(items.length - 1); + }) + .add(done); + app.database().goOnline(); + aref.set(itemsObj).then(() => { + aref.child(items[0].key).remove(); + }); + }); + + /** + * This test checks that the `child_changed` event is processed by + * checking the new value of the object in the array. + */ + it('should process a new child_changed event', done => { + const aref = ref(rando()); + const obs = list(aref, [ListenEvent.added, ListenEvent.changed]); + const sub = obs + .pipe(skip(1), take(1)) + .subscribe(changes => { + const data = changes.map(change => change.snapshot.val()); + expect(data[1].name).to.eql('lol'); + }) + .add(done); + app.database().goOnline(); + aref.set(itemsObj).then(() => { + aref.child(items[1].key).update({ name: 'lol' }); + }); + }); + + /** + * This test checks the `child_moved` event is processed by checking that + * the new position is properly updated. + */ + it('should process a new child_moved event', done => { + const aref = ref(rando()); + const obs = list(aref, [ListenEvent.added, ListenEvent.moved]); + const sub = obs + .pipe(skip(1), take(1)) + .subscribe(changes => { + const data = changes.map(change => change.snapshot.val()); + // We moved the first item to the last item, so we check that + // the new result is now the last result + expect(data[data.length - 1]).to.eql(items[0]); + }) + .add(done); + app.database().goOnline(); + aref.set(itemsObj).then(() => { + aref.child(items[0].key).setPriority('a', () => {}); + }); + }); + + /** + * If no events array is provided in `list()` all events are listened to. + * + * This test checks that all events are processed without providing the + * array. + */ + it('should listen to all events by default', done => { + const { snapChanges, ref } = prepareList(); + snapChanges + .pipe(take(1)) + .subscribe(actions => { + const data = actions.map(a => a.snapshot.val()); + expect(data).to.eql(items); + }) + .add(done); + ref.set(itemsObj); + }); + + /** + * This test checks that multiple subscriptions work properly. + */ + it('should handle multiple subscriptions (hot)', done => { + const { snapChanges, ref } = prepareList(); + const sub = snapChanges.subscribe(() => {}).add(done); + snapChanges + .pipe(take(1)) + .subscribe(actions => { + const data = actions.map(a => a.snapshot.val()); + expect(data).to.eql(items); + }) + .add(sub); + ref.set(itemsObj); + }); + + /** + * This test checks that multiple subscriptions work properly. + */ + it('should handle multiple subscriptions (warm)', done => { + const { snapChanges, ref } = prepareList(); + snapChanges + .pipe(take(1)) + .subscribe(() => {}) + .add(() => { + snapChanges + .pipe(take(1)) + .subscribe(actions => { + const data = actions.map(a => a.snapshot.val()); + expect(data).to.eql(items); + }) + .add(done); + }); + ref.set(itemsObj); + }); + + /** + * This test checks that only `child_added` events are processed. + */ + it('should listen to only child_added events', done => { + const { snapChanges, ref } = prepareList({ + events: [ListenEvent.added], + skipnumber: 0 + }); + snapChanges + .pipe(take(1)) + .subscribe(actions => { + const data = actions.map(a => a.snapshot.val()); + expect(data).to.eql(items); + }) + .add(done); + ref.set(itemsObj); + }); + + /** + * This test checks that only `child_added` and `child_changed` events are + * processed. + */ + + it('should listen to only child_added, child_changed events', done => { + const { snapChanges, ref } = prepareList({ + events: [ListenEvent.added, ListenEvent.changed], + skipnumber: 1 + }); + const name = 'ligatures'; + snapChanges + .pipe(take(1)) + .subscribe(actions => { + const data = actions.map(a => a.snapshot.val()); + const copy = [...items]; + copy[0].name = name; + expect(data).to.eql(copy); + }) + .add(done); + app.database().goOnline(); + ref.set(itemsObj).then(() => { + ref.child(items[0].key).update({ name }); + }); + }); + + /** + * This test checks that empty sets are processed. + */ + it('should handle empty sets', done => { + const aref = ref(rando()); + aref.set({}); + list(aref) + .pipe(take(1)) + .subscribe(data => { + expect(data.length).to.eql(0); + }) + .add(done); + }); + + /** + * This test checks that dynamic querying works even with results that + * are empty. + */ + it('should handle dynamic queries that return empty sets', done => { + let count = 0; + let namefilter$ = new BehaviorSubject(null); + const aref = ref(rando()); + aref.set(itemsObj); + namefilter$ + .pipe( + switchMap(name => { + const filteredRef = name + ? aref.child('name').equalTo(name) + : aref; + return list(filteredRef); + }), + take(2) + ) + .subscribe(data => { + count = count + 1; + // the first time should all be 'added' + if (count === 1) { + expect(Object.keys(data).length).to.eql(3); + namefilter$.next(-1); + } + // on the second round, we should have filtered out everything + if (count === 2) { + expect(Object.keys(data).length).to.eql(0); + } + }) + .add(done); + }); + }); + }); + + describe('auditTrail', () => { + const items = [{ name: 'zero' }, { name: 'one' }, { name: 'two' }].map( + (item, i) => ({ key: `${i}`, ...item }) + ); + + const itemsObj = batch(items); + + function prepareAuditTrail( + opts: { events?: ListenEvent[]; skipnumber: number } = { skipnumber: 0 } + ) { + const { events, skipnumber } = opts; + const aref = ref(rando()); + aref.set(itemsObj); + const changes = auditTrail(aref, events); + return { + changes: changes.pipe(skip(skipnumber)), + ref: aref + }; + } + + /** + * This test checks that auditTrail retuns all events by default. + */ + it('should listen to all events by default', done => { + const { changes } = prepareAuditTrail(); + changes.subscribe(actions => { + const data = actions.map(a => a.snapshot.val()); + expect(data).to.eql(items); + done(); + }); + }); + }); +}); diff --git a/packages/rxfire/test/firestore.test.ts b/packages/rxfire/test/firestore.test.ts index e61b3ce0e24..ee9c217a992 100644 --- a/packages/rxfire/test/firestore.test.ts +++ b/packages/rxfire/test/firestore.test.ts @@ -25,6 +25,8 @@ import { } from '../firestore'; import { map, take, skip } from 'rxjs/operators'; +export const TEST_PROJECT = require('../../../config/project.json'); + const createId = () => Math.random() .toString(36) @@ -78,7 +80,7 @@ describe('RxFire Firestore', () => { * offline. */ beforeEach(() => { - app = initializeApp({ projectId: 'rxfire-test' }); + app = initializeApp({ projectId: TEST_PROJECT.projectId }); firestore = app.firestore(); firestore.settings({ timestampsInSnapshots: true }); firestore.disableNetwork(); diff --git a/packages/rxfire/tsconfig.json b/packages/rxfire/tsconfig.json index e557fdf8668..64082d8c046 100644 --- a/packages/rxfire/tsconfig.json +++ b/packages/rxfire/tsconfig.json @@ -2,9 +2,10 @@ "extends": "../../config/tsconfig.base.json", "compilerOptions": { "outDir": "dist", - "declaration": false + "declaration": true }, "exclude": [ - "dist/**/*" + "dist/**/*", + "test/**/*" ] }