Skip to content

Commit

Permalink
Merge pull request #9 from FirebaseExtended/firestore-metadata
Browse files Browse the repository at this point in the history
Firestore observables should emit metadata changes
  • Loading branch information
jamesdaniels authored May 13, 2021
2 parents 985f4de + 0e14c8d commit 76a64ba
Show file tree
Hide file tree
Showing 5 changed files with 126 additions and 37 deletions.
131 changes: 105 additions & 26 deletions firestore/collection/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,30 @@
*/

import firebase from 'firebase/app';
import {fromCollectionRef} from '../fromRef';
import {Observable, MonoTypeOperatorFunction} from 'rxjs';
import {map, filter, scan, distinctUntilChanged} from 'rxjs/operators';
import {snapToData} from '../document';
import { fromCollectionRef } from '../fromRef';
import {
Observable,
MonoTypeOperatorFunction,
OperatorFunction,
pipe,
UnaryFunction
} from 'rxjs';
import {
map,
filter,
scan,
distinctUntilChanged,
startWith,
pairwise
} from 'rxjs/operators';
import { snapToData } from '../document';

type DocumentChangeType = firebase.firestore.DocumentChangeType;
type DocumentData = firebase.firestore.DocumentData;
type DocumentChange<T> = firebase.firestore.DocumentChange<T>;
type Query<T> = firebase.firestore.Query<T>;
type QueryDocumentSnapshot<T> = firebase.firestore.QueryDocumentSnapshot<T>;
type QuerySnapshot<T> = firebase.firestore.QuerySnapshot<T>;

const ALL_EVENTS: DocumentChangeType[] = ['added', 'modified', 'removed'];

Expand All @@ -49,22 +63,15 @@ const filterEvents = <T>(
return hasChange;
});

/**
* Create an operator that filters out empty changes. We provide the
* ability to filter on events, which means all changes can be filtered out.
* This creates an empty array and would be incorrect to emit.
*/
const filterEmpty = filter(<T>(changes: DocumentChange<T>[]) => changes.length > 0);

/**
* Splice arguments on top of a sliced array, to break top-level ===
* this is useful for change-detection
*/
function sliceAndSplice<T>(
original: T[],
start: number,
deleteCount: number,
...args: T[]
original: T[],
start: number,
deleteCount: number,
...args: T[]
): T[] {
const returnArray = original.slice();
returnArray.splice(start, deleteCount, ...args);
Expand Down Expand Up @@ -143,19 +150,91 @@ function processDocumentChanges<T>(
return current;
}

/**
* Create an operator that allows you to compare the current emission with
* the prior, even on first emission (where prior is undefined).
*/
const windowwise = <T = unknown>() =>
pipe(
startWith(undefined),
pairwise() as OperatorFunction<T | undefined, [T | undefined, T]>
);

/**
* Given two snapshots does their metadata match?
* @param a
* @param b
*/
const metaDataEquals = <T,R extends QuerySnapshot<T> | QueryDocumentSnapshot<T>>(
a: R,
b: R
) => JSON.stringify(a.metadata) === JSON.stringify(b.metadata);

/**
* Create an operator that filters out empty changes. We provide the
* ability to filter on events, which means all changes can be filtered out.
* This creates an empty array and would be incorrect to emit.
*/
const filterEmptyUnlessFirst = <T = unknown>(): UnaryFunction<
Observable<T[]>,
Observable<T[]>
> =>
pipe(
windowwise(),
filter(([prior, current]) => current.length > 0 || prior === undefined),
map(([_, current]) => current)
);

/**
* Return a stream of document changes on a query. These results are not in sort order but in
* order of occurence.
* @param query
*/
export function collectionChanges<T=DocumentData>(
query: Query<T>,
events: DocumentChangeType[] = ALL_EVENTS,
query: Query<T>,
events: DocumentChangeType[] = ALL_EVENTS
): Observable<DocumentChange<T>[]> {
return fromCollectionRef(query).pipe(
map((snapshot) => snapshot.docChanges()),
filterEvents(events),
filterEmpty,
return fromCollectionRef(query, { includeMetadataChanges: true }).pipe(
windowwise(),
map(([priorSnapshot, currentSnapshot]) => {
const docChanges = currentSnapshot.docChanges();
if (priorSnapshot && !metaDataEquals(priorSnapshot, currentSnapshot)) {
// the metadata has changed, docChanges() doesn't return metadata events, so let's
// do it ourselves by scanning over all the docs and seeing if the metadata has changed
// since either this docChanges() emission or the prior snapshot
currentSnapshot.docs.forEach((currentDocSnapshot, currentIndex) => {
const currentDocChange = docChanges.find(c =>
c.doc.ref.isEqual(currentDocSnapshot.ref)
);
if (currentDocChange) {
// if the doc is in the current changes and the metadata hasn't changed this doc
if (metaDataEquals(currentDocChange.doc, currentDocSnapshot)) {
return;
}
} else {
// if there is a prior doc and the metadata hasn't changed skip this doc
const priorDocSnapshot = priorSnapshot?.docs.find(d =>
d.ref.isEqual(currentDocSnapshot.ref)
);
if (
priorDocSnapshot &&
metaDataEquals(priorDocSnapshot, currentDocSnapshot)
) {
return;
}
}
docChanges.push({
oldIndex: currentIndex,
newIndex: currentIndex,
type: 'modified',
doc: currentDocSnapshot
});
});
}
return docChanges;
}),
filterEvents(events),
filterEmptyUnlessFirst()
);
}

Expand All @@ -164,7 +243,7 @@ export function collectionChanges<T=DocumentData>(
* @param query
*/
export function collection<T=DocumentData>(query: Query<T>): Observable<QueryDocumentSnapshot<T>[]> {
return fromCollectionRef<T>(query).pipe(map((changes) => changes.docs));
return fromCollectionRef<T>(query, { includeMetadataChanges: true }).pipe(map((changes) => changes.docs));
}

/**
Expand Down Expand Up @@ -207,8 +286,8 @@ export function collectionData<T=DocumentData>(
idField?: string,
): Observable<T[]> {
return collection(query).pipe(
map((arr) => {
return arr.map((snap) => snapToData(snap, idField) as T);
}),
map(arr => {
return arr.map(snap => snapToData(snap, idField) as T);
})
);
}
}
15 changes: 7 additions & 8 deletions firestore/document/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,16 @@
*/

import firebase from 'firebase/app';
import {fromDocRef} from '../fromRef';
import {map} from 'rxjs/operators';
import {Observable} from 'rxjs';
import { fromDocRef } from '../fromRef';
import { map } from 'rxjs/operators';
import { Observable } from 'rxjs';

type DocumentData = firebase.firestore.DocumentData;
type DocumentReference<T> = firebase.firestore.DocumentReference<T>;
type DocumentSnapshot<T> = firebase.firestore.DocumentSnapshot<T>;

export function doc<T=DocumentData>(ref: DocumentReference<T>): Observable<DocumentSnapshot<T>> {
return fromDocRef(ref);
return fromDocRef(ref, { includeMetadataChanges: true });
}

/**
Expand All @@ -36,7 +36,7 @@ export function docData<T=DocumentData>(
ref: DocumentReference<T>,
idField?: string,
): Observable<T> {
return doc(ref).pipe(map((snap) => snapToData(snap, idField) as T));
return doc(ref).pipe(map(snap => snapToData(snap, idField) as T));
}

export function snapToData<T=DocumentData>(
Expand All @@ -47,9 +47,8 @@ export function snapToData<T=DocumentData>(
if (!snapshot.exists) {
return snapshot.data();
}

return {
...snapshot.data(),
...(idField ? {[idField]: snapshot.id} : null),
...(idField ? { [idField]: snapshot.id } : null)
};
}
}
10 changes: 8 additions & 2 deletions firestore/fromRef.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,20 @@ type DocumentData = firebase.firestore.DocumentData;
type DocumentSnapshot<T=DocumentData> = firebase.firestore.DocumentSnapshot<T>;
type QuerySnapshot<T=DocumentData> = firebase.firestore.QuerySnapshot<T>;

const DEFAULT_OPTIONS = { includeMetadataChanges: false };

/* eslint-disable @typescript-eslint/no-explicit-any */
function _fromRef(
ref: any,
options: SnapshotListenOptions | undefined,
options: SnapshotListenOptions=DEFAULT_OPTIONS,
): Observable<any> {
/* eslint-enable @typescript-eslint/no-explicit-any */
return new Observable((subscriber) => {
const unsubscribe = ref.onSnapshot(options || {}, subscriber);
const unsubscribe = ref.onSnapshot(options, {
next: subscriber.next.bind(subscriber),
error: subscriber.error.bind(subscriber),
complete: subscriber.complete.bind(subscriber),
});
return {unsubscribe};
});
}
Expand Down
5 changes: 5 additions & 0 deletions rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ const packageJsonPaths = globSync('**/package.json', { ignore: ['node_modules/**
const packages = packageJsonPaths.reduce((acc, path) => {
const pkg = JSON.parse(readFileSync(path, { encoding: 'utf-8'} ));
const component = dirname(path);
if (component === '.') {
Object.keys(pkg.exports).forEach(exportName => {
pkg.exports[exportName] = pkg.exports[exportName].replace(/^\.\/dist\//, './');
});
}
acc[component] = pkg;
return acc;
}, {});
Expand Down
2 changes: 1 addition & 1 deletion test/firestore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ describe('RxFire Firestore', () => {

const {colRef} = seedTest(firestore);

const nonExistentDoc: firestore.DocumentReference = colRef.doc(
const nonExistentDoc: firebase.firestore.DocumentReference = colRef.doc(
createId(),
);

Expand Down

0 comments on commit 76a64ba

Please sign in to comment.