diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9ddfe8c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true + +indent_style = space +indent_size = 2 \ No newline at end of file diff --git a/.firebaserc b/.firebaserc new file mode 100644 index 0000000..4810561 --- /dev/null +++ b/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "fir-lift" + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bbd6d90 --- /dev/null +++ b/.gitignore @@ -0,0 +1,65 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +dist +.idea + +.vscode + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# next.js build output +.next diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..461ba0c --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "trailingComma": "none", + "bracketSpacing": true, + "arrowParens": "always", + "singleQuote": true +} diff --git a/firebase.json b/firebase.json new file mode 100644 index 0000000..d10b33d --- /dev/null +++ b/firebase.json @@ -0,0 +1,17 @@ +{ + "firestore": { + "rules": "firestore.rules", + "indexes": "firestore.indexes.json" + }, + "emulators": { + "firestore": { + "port": 8080 + }, + "database": { + "port": 9000 + }, + "ui": { + "enabled": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5940dc2 --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "firebase-lift", + "version": "0.0.1", + "description": "", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "test": "node dist/tests/index.js", + "ts-build": "tsc ", + "ts-watch": "tsc -w", + "start": "firebase emulators:start" + }, + "devDependencies": { + "@firebase/testing": "^0.19.5", + "@types/json-stable-stringify": "^1.0.32", + "@types/lodash": "^4.14.158", + "@types/md5": "^2.2.0", + "@types/shortid": "^0.0.29", + "nano-test-runner": "^1.2.0", + "prettier": "^2.0.5", + "typescript": "^3.9.3" + }, + "dependencies": { + "firebase": "^7.14.5", + "json-stable-stringify": "^1.0.1", + "lodash": "^4.17.19", + "md5": "^2.2.1", + "shortid": "^2.2.15" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/kevin-ashton/firebase-lift.git" + }, + "author": "", + "license": "ISC", + "bugs": { + "url": "https://github.com/kevin-ashton/firebase-lift/issues" + }, + "homepage": "https://github.com/kevin-ashton/firebase-lift#readme" +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..0998629 --- /dev/null +++ b/readme.md @@ -0,0 +1,82 @@ +# Firebase Lift + +Firebase provides a variety of tools that are amazing. This wraps various aspects of the api. + +## Firestore + +### Features +* Types on returned documents +* Types on various CRUD functions +* Types for query construction +* Ability group queries/doc fetches +* Metrics that track doc read/writes for various collections + +## Limitations +* Firestore caching is always disabled +* Sub collections are not supported +* Server timestamps are not supported +* Array filters are currently not supported +* Only supports basic types string, number, array, maps. No support for geo data, timestamps, etc. +* Increment is limited to a single number increment (no jumping by multiple numbers, or decrementing) +* startAt, startAfter, endAt, endBefore are supported for values but not for firestore docs or query docs. In other words you must use a value and not a firestore document when using those filters. + +## Realtime Database + +### Features +* Add some types for objects/primatives + +### Limitations +* Only covers part of the API. You can access the raw refs to do everything normally without types. + +## Usage + +```ts +import { + createRtdbLift, + createFirestoreLift, + FirestoreLiftCollection + TypedFirebaseObjectOrPrimativeRefGenerator +} from '../RTDB'; +import * as firebase from 'firebase'; + +interface Person { + name: string; + age: number; +} + +interface Book { + title: string; + year: number; +} + +interface Heartbeat { + dateMs: number; + msg: string; +} + +interface DeviceInfo { + dateMs: number; + dId: string; +} + +const app = firebase.initializeApp(testFirebaseConfig); + +const firestoreLiftExample = createFirestoreLift({ + collections: { + Person: { + collection: 'person' + }, + Book: { + collection: 'book' + } + }, + firebaseApp: app, + firestoreModule: firebase.firestore +}); + +const rtdbLiftExample = createRtdbLift({firebaseApp: app, { + person: (null as unknown) as TypedFirebaseObjectOrPrimativeRefGenerator, + book: (null as unknown) as TypedFirebaseObjectOrPrimativeRefGenerator +}}); + +``` diff --git a/src/BatchRunner.ts b/src/BatchRunner.ts new file mode 100644 index 0000000..2ef740b --- /dev/null +++ b/src/BatchRunner.ts @@ -0,0 +1,108 @@ +import * as firebase from 'firebase'; +import { + BatchTask, + BatchTaskEmpty, + MagicDeleteString, + MagicIncrementString, + MagicServerTimestampString +} from './models'; +import { generateFirestorePathFromObject, defaultEmptyTask } from './misc'; + +export class BatchRunner { + public firestoreModule: typeof firebase.firestore; + public app: firebase.app.App; + + constructor(config: { firestoreModule: typeof firebase.firestore; app: firebase.app.App }) { + this.firestoreModule = config.firestoreModule; + this.app = config.app; + } + + // We use a magic string for deletes so we can pass around batches of change sets to be environment agnostic + private scrubDataPreWrite(p: { obj: any; removeEmptyObjects: boolean }) { + const { obj, removeEmptyObjects } = p; + + if (typeof obj === 'object') { + let keys = Object.keys(obj); + for (let i = 0; i < keys.length; i++) { + let k = keys[i]; + if (obj[k] === MagicDeleteString) { + obj[k] = this.firestoreModule.FieldValue.delete(); + } else if (obj[k] === MagicIncrementString) { + obj[k] = this.firestoreModule.FieldValue.increment(1); + } else if (obj[k] === undefined || obj[k] === null) { + //Undefined values get coerced to null by the Firestore SDK, which makes no sense for a strongly typed library like this + delete obj[k]; + } else if (typeof obj[k] === 'object') { + if (removeEmptyObjects && Object.keys(obj[k]).length === 0) { + delete obj[k]; + } else { + obj[k] = this.scrubDataPreWrite({ obj: obj[k], removeEmptyObjects }); + } + } else { + obj[k] = obj[k]; + } + } + } + if (typeof obj === 'string' && obj === MagicDeleteString) { + return this.firestoreModule.FieldValue.delete(); + } + return obj; + } + + async executeBatch(b: BatchTask[]): Promise { + let batch = this.firestoreModule(this.app).batch(); + try { + for (let i = 0; i < b.length; i++) { + let task = b[i]; + if (task.type === 'empty') { + continue; + } + + if (!task.id) { + throw Error(`Unable to process item. Lacks an id. Collection: ${task.collection}. Task Type: ${task.type}`); + } + let ref = this.firestoreModule(this.app).collection(task.collection).doc(task.id); + + let newObj; + switch (task.type) { + case 'add': + batch.set(ref, this.scrubDataPreWrite({ obj: task.doc, removeEmptyObjects: false }), { merge: true }); + break; + case 'set': + batch.set(ref, this.scrubDataPreWrite({ obj: task.doc, removeEmptyObjects: false }), { merge: false }); + break; + case 'setPath': + let p = generateFirestorePathFromObject(task.pathObj); + let newPathVal = p.path.split('.').reduce((acc, val) => { + if (acc[val] === undefined) { + throw new Error('Missing value for setPath update'); + } + return acc[val]; + }, task.value); + newPathVal = this.scrubDataPreWrite({ obj: newPathVal, removeEmptyObjects: false }); + batch.update(ref, p.path, newPathVal); + break; + case 'update': + //firestore set merge has the very dumb default behavior of making empty objects overwriting the object entirely + newObj = this.scrubDataPreWrite({ obj: task.doc, removeEmptyObjects: true }); + batch.set(ref, newObj, { merge: true }); + break; + case 'delete': + batch.delete(ref); + break; + default: + // @ts-ignore + throw new Error(`Unknown BatchTask type. Type: ${task.type}`); + } + } + + await batch.commit(); + + // Returning an empty task makes it easier for the helper functions (.add, .update) so they always return a batch type. Makes it so we don't have to check for undefined + return defaultEmptyTask; + } catch (e) { + console.error(e); + throw e; + } + } +} diff --git a/src/FirestoreLift.ts b/src/FirestoreLift.ts new file mode 100644 index 0000000..d48a3cd --- /dev/null +++ b/src/FirestoreLift.ts @@ -0,0 +1,65 @@ +import { + FirestoreLiftRoot, + FirestoreLiftInitConfig, + MagicDeleteString, + MagicIncrementString, + MagicServerTimestampString, + FirestoreLiftStats +} from './models'; +import { FirestoreLiftCollection } from './FirestoreLiftCollection'; +import { BatchRunner } from './BatchRunner'; + +// Expects a generic of a very specific shape. See examples +export function createFirestoreLift(config: FirestoreLiftInitConfig): T & FirestoreLiftRoot { + const batchRunner = new BatchRunner({ app: config.firebaseApp, firestoreModule: config.firestoreModule }); + + const pendingFirestoreLift: any = { + _GetStats: () => { + let s: FirestoreLiftStats = { + summary: { + statsInitMS: Date.now(), + totalActiveSubscriptions: 0, + totalDocsFetched: 0, + totalDocsWritten: 0, + totalSubscriptionsOverTime: 0 + }, + byCollection: {} + }; + Object.keys(config.collections).forEach((collectionName) => { + let c = collectionName.charAt(0).toUpperCase() + collectionName.slice(1); + let f: FirestoreLiftCollection = pendingFirestoreLift[c]; + s.summary.totalDocsFetched += f._stats.docsFetched; + s.summary.totalDocsWritten += f._stats.docsWritten; + s.summary.totalActiveSubscriptions += Object.keys(f._stats.activeSubscriptions).length; + s.summary.totalSubscriptionsOverTime += f._stats.totalSubscriptionsOverTime; + s.byCollection[c] = f._stats; + }); + + return s; + }, + _setFirestoreLiftDisabledStatus: (isDisabled: boolean) => { + Object.keys(config.collections).forEach((collectionName) => { + let c = collectionName.charAt(0).toUpperCase() + collectionName.slice(1); + pendingFirestoreLift[c].setFirestoreLiftDisabledStatus(isDisabled); + }); + }, + _RawFirestore: config.firebaseApp.firestore, + _BatchRunner: batchRunner, + _RawFirebaseApp: config.firebaseApp, + _MagicDeleteValue: MagicDeleteString, + _MagicIncrementValue: MagicIncrementString, + _MagicServerTimestamp: MagicServerTimestampString + }; + + Object.keys(config.collections).forEach((key) => { + const col = config.collections[key]; + pendingFirestoreLift[key] = new FirestoreLiftCollection({ + batchRunner, + collection: col.collection, + disableIdGeneration: !!col.disableIdGeneration, + prefixIdWithCollection: col.prefixIdWithCollectionName === false ? false : true // Want true by default + }); + }); + + return pendingFirestoreLift; +} diff --git a/src/FirestoreLiftCollection.ts b/src/FirestoreLiftCollection.ts new file mode 100644 index 0000000..7f5f5c9 --- /dev/null +++ b/src/FirestoreLiftCollection.ts @@ -0,0 +1,615 @@ +import * as firebase from 'firebase'; +import { + BatchTaskAdd, + BatchTaskDelete, + BatchTaskEmpty, + BatchTaskUpdate, + BatchTaskSetPath, + BatchTaskSet, + Optional, + OptionalFlex, + SimpleQuery, + FirestoreLiftCollectionStats, + ActiveSubscriptions, + FirestoreLiftDocRoot, + QueryResultSet, + FirestoreLiftQuerySubscription, + QuerySubscriptionResultSet, + Change, + OrNull, + FirestoreLiftDocSubscription, + FirestoreLiftDocsSubscription +} from './models'; +import { generatePushID, generateQueryRef, defaultEmptyTask, generateFirestorePathFromObject } from './misc'; +import { BatchRunner } from './BatchRunner'; +import * as _ from 'lodash'; +import * as md5 from 'md5'; +import * as jsonStable from 'json-stable-stringify'; + +export class FirestoreLiftCollection { + private readonly collection: string; + private readonly batchRunner: BatchRunner; + public _stats: FirestoreLiftCollectionStats = { + statsInitMS: Date.now(), + docsFetched: 0, + docsWritten: 0, + activeSubscriptions: {}, + totalSubscriptionsOverTime: 0 + }; + private firestoreSubscriptionIdCounter: number = 1; + private firestoreSubscriptions: { + [subscriptionId: string]: { + subscriptionDetails: any; + fns: { [subId: string]: any }; + errorFns: { [subId: string]: any }; + firestoreUnsubscribeFn: any; + currentValue?: any; + }; + } = {}; + private readonly prefixIdWithCollection: boolean; + private readonly disableIdGeneration: boolean; + private firestore: firebase.firestore.Firestore; + private isDisabled: boolean = false; + + constructor(config: { + collection: string; + batchRunner: BatchRunner; + prefixIdWithCollection: boolean; + disableIdGeneration: boolean; + }) { + this.collection = config.collection; + this.batchRunner = config.batchRunner; + this.firestore = this.batchRunner.firestoreModule(this.batchRunner.app); + this.prefixIdWithCollection = config.prefixIdWithCollection; // Add the collection name as a prefix to an id. Makes them easier to read + this.disableIdGeneration = config.disableIdGeneration; // Some id's (such as account ids) you may not want firestore lift to ever generate an id because you want to force it to be assigned manually + } + + public generateId() { + if (this.disableIdGeneration) { + throw new Error( + `Unable to generate id for collection. It has been disabled by init config. Collection: ${this.collection}` + ); + } + return this.prefixIdWithCollection ? `${this.collection}-${generatePushID()}` : generatePushID(); + } + + private registerSubscription(p: { uniqueSubscriptionId: number; subscriptionId: string; fn: any; errorFn?: any }) { + if (!this.firestoreSubscriptions[p.subscriptionId]) { + throw Error('Cannot register a subscription until it has been setup'); + } + + this.firestoreSubscriptions[p.subscriptionId].fns[p.uniqueSubscriptionId] = p.fn; + if (p.errorFn) { + this.firestoreSubscriptions[p.subscriptionId].errorFns[p.uniqueSubscriptionId] = p.errorFn; + } + } + private unregisterSubscription(p: { uniqueSubscriptionId: number; subscriptionId: string }) { + if (!this.firestoreSubscriptions[p.subscriptionId]) { + console.warn('Unable to unregister a subscription if it does not exist'); + return; + } + + delete this.firestoreSubscriptions[p.subscriptionId].fns[p.uniqueSubscriptionId]; + delete this.firestoreSubscriptions[p.subscriptionId].errorFns[p.uniqueSubscriptionId]; + + if (Object.keys(this.firestoreSubscriptions[p.subscriptionId].fns).length <= 0) { + this.firestoreSubscriptions[p.subscriptionId].firestoreUnsubscribeFn(); + delete this.firestoreSubscriptions[p.subscriptionId]; + } + } + + private updateSubscriptionStats() { + let activeSubscriptions: ActiveSubscriptions = {}; + + for (let subscriptionId in this.firestoreSubscriptions) { + activeSubscriptions[subscriptionId] = { + subscriptionDetails: this.firestoreSubscriptions[subscriptionId].subscriptionDetails, + subscriberCount: Object.keys(this.firestoreSubscriptions[subscriptionId].fns).length + }; + } + + this._stats.activeSubscriptions = activeSubscriptions; + } + + public docSubscription(docId: string): FirestoreLiftDocSubscription { + let subscriptionId = md5(docId); + let docRef = this.firestore.collection(this.collection).doc(docId); + + const subscriptionStackTrace = new Error().stack; + + return { + subscribe: (fn, errorFn?: (e: Error) => void) => { + let uniqueSubscriptionId = this.firestoreSubscriptionIdCounter; + this.firestoreSubscriptionIdCounter += 1; + if (!this.firestoreSubscriptions[subscriptionId]) { + let unsubFirestore = docRef.onSnapshot( + // Disable the cache. Can cause strange behavior + { includeMetadataChanges: true }, + (snapshot) => { + if (snapshot.metadata.fromCache && !snapshot.metadata.hasPendingWrites) { + return; + } + this._stats.docsFetched += 1; + let value: OrNull = snapshot.exists ? (snapshot.data() as any) : null; + + if (this.isDisabled) { + console.warn('Cannot docSubscription while firestoreLift disabled'); + value = this.firestoreSubscriptions[subscriptionId].currentValue || null; + } + + this.firestoreSubscriptions[subscriptionId].currentValue = value; + for (let i in this.firestoreSubscriptions[subscriptionId].fns) { + this.firestoreSubscriptions[subscriptionId].fns[i](value); + } + }, + (err) => { + let msg = `${err.message} in firestore-lift subscription on collection ${this.collection} with docId:${docId}`; + // Do NOT delete the console.error. Propagation beyond this point is too inconsistent. This would have saved many hours of dev work with swallowed errors + console.error(msg); + let detailedError = new Error(msg); + detailedError.stack = subscriptionStackTrace; + if (Object.keys(this.firestoreSubscriptions[subscriptionId].errorFns).length > 0) { + for (let i in this.firestoreSubscriptions[subscriptionId].errorFns) { + this.firestoreSubscriptions[subscriptionId].errorFns[i](detailedError); + } + } else { + console.error(detailedError); + } + } + ); + + this.firestoreSubscriptions[subscriptionId] = { + fns: {}, + errorFns: {}, + firestoreUnsubscribeFn: unsubFirestore, + subscriptionDetails: docId + }; + this.registerSubscription({ fn, errorFn, subscriptionId: subscriptionId, uniqueSubscriptionId }); + this._stats.totalSubscriptionsOverTime += 1; + } else { + if (this.firestoreSubscriptions[subscriptionId].currentValue) { + // First time function gets a copy of the current value + fn(this.firestoreSubscriptions[subscriptionId].currentValue); + } + this.registerSubscription({ fn, errorFn, subscriptionId: subscriptionId, uniqueSubscriptionId }); + } + this.updateSubscriptionStats(); + + return { + unsubscribe: () => { + this.unregisterSubscription({ subscriptionId: subscriptionId, uniqueSubscriptionId }); + this.updateSubscriptionStats(); + } + }; + } + }; + } + + public docsSubscription(docIds: string[]): FirestoreLiftDocsSubscription { + return { + subscribe: (fn, errorFn) => { + const unsubscribeFns: any[] = []; + const currentValue: Array> = docIds.map(() => null); + const hasFiredOnceTracker: Record = {}; + docIds.forEach((id, index) => { + const subRef = this.docSubscription(id); + const sub = subRef.subscribe( + (doc) => { + if (!hasFiredOnceTracker[index]) { + hasFiredOnceTracker[index] = true; + } + currentValue[index] = doc; + if (Object.keys(hasFiredOnceTracker).length === docIds.length) { + fn(currentValue); + } + unsubscribeFns.push(sub); + }, + (e) => { + errorFn(e); + } + ); + }); + return { + unsubscribe: () => { + unsubscribeFns.forEach((fn) => fn()); + } + }; + } + }; + } + + public querySubscription(query: SimpleQuery): FirestoreLiftQuerySubscription { + let subscriptionId = md5(jsonStable(query)); + let queryRef = generateQueryRef(query, this.collection, this.firestore as any); + + const subscriptionStackTrace = new Error().stack; + + return { + subscribe: (fn, errorFn: (e: Error) => void) => { + let uniqueSubscriptionId = this.firestoreSubscriptionIdCounter; + this.firestoreSubscriptionIdCounter += 1; + if (!this.firestoreSubscriptions[subscriptionId]) { + let hasFiredAtLeastOnce = false; + let unsubFirestore = queryRef.onSnapshot( + // Disable the cache. Can cause strange behavior + { includeMetadataChanges: true }, + (snapshot) => { + if (snapshot.metadata.fromCache && !snapshot.metadata.hasPendingWrites) { + return; + } + + let docs: any = snapshot.docs.map((d) => d.data()); + let changes: Change = []; + + this._stats.docsFetched += snapshot.docChanges().length; + snapshot.docChanges().forEach((change) => { + changes.push({ doc: change.doc.data() as any, changeType: change.type }); + }); + + let value: QuerySubscriptionResultSet = { + docs: docs, + changes: changes as any, + metadata: snapshot.metadata + }; + + if (this.isDisabled) { + console.warn('Cannot querySubscription while firestoreLift disabled'); + value = this.firestoreSubscriptions[subscriptionId].currentValue || { + changes: [], + docs: [], + metadata: snapshot.metadata + }; + } + + this.firestoreSubscriptions[subscriptionId].currentValue = value; + + //Firestore randomly fires some subscriptions about every 25 seconds with an empty array of docChanges. It's quite baffling. + //But it provides no useful data and triggers a cascade of data fetching which we want to prevent. + if (hasFiredAtLeastOnce && !snapshot.docChanges().length) { + return; + } + + hasFiredAtLeastOnce = true; + + for (let i in this.firestoreSubscriptions[subscriptionId].fns) { + this.firestoreSubscriptions[subscriptionId].fns[i](value); + } + }, + (err) => { + let msg = `${err.message} in firestore-lift subscription on collection ${ + this.collection + } with query:${JSON.stringify(query)}`; + // Do NOT delete the console.error. Propagation beyond this point is too inconsistent. This would have saved many hours of dev work with swallowed errors + console.error(msg); + let detailedError = new Error(msg); + detailedError.stack = subscriptionStackTrace; + if (Object.keys(this.firestoreSubscriptions[subscriptionId].errorFns).length > 0) { + for (let i in this.firestoreSubscriptions[subscriptionId].errorFns) { + this.firestoreSubscriptions[subscriptionId].errorFns[i](detailedError); + } + } else { + console.error(detailedError); + } + } + ); + this.firestoreSubscriptions[subscriptionId] = { + fns: {}, + errorFns: {}, + firestoreUnsubscribeFn: unsubFirestore, + subscriptionDetails: query + }; + this.registerSubscription({ fn, errorFn, subscriptionId, uniqueSubscriptionId }); + this._stats.totalSubscriptionsOverTime += 1; + } else { + if (this.firestoreSubscriptions[subscriptionId].currentValue) { + // First time function gets a copy of the current value + fn(this.firestoreSubscriptions[subscriptionId].currentValue); + } + this.registerSubscription({ fn, errorFn, subscriptionId, uniqueSubscriptionId }); + } + this.updateSubscriptionStats(); + + return { + unsubscribe: () => { + this.unregisterSubscription({ subscriptionId, uniqueSubscriptionId }); + this.updateSubscriptionStats(); + } + }; + } + }; + } + + async multiQuery(p: { + queries: SimpleQuery[]; + mergeProcess?: { orderBy?: { sortKey: keyof DocModel; dir: 'asc' | 'desc' }; runDedupe?: boolean }; + }): Promise> { + const results = await Promise.all(p.queries.map((q) => this.query(q))); + let docs: DocModel[] = []; + results.forEach((p) => { + docs.push(...p.docs); + }); + + if (p.mergeProcess?.runDedupe) { + docs = _.uniqBy(docs, 'id'); + } + + if (p.mergeProcess?.orderBy) { + docs = _.sortBy(docs, p.mergeProcess.orderBy.sortKey); + + if (p.mergeProcess.orderBy.dir === 'desc') { + docs.reverse(); + } + } + + return { + docs + }; + } + + multiQuerySubscription(p: { + queries: SimpleQuery[]; + mergeProcess?: { orderBy?: { sortKey: keyof DocModel; dir: 'asc' | 'desc' }; runDedupe?: boolean }; + }): FirestoreLiftQuerySubscription { + return { + subscribe: (fn, errorFn) => { + const unsubscribeFns: any[] = []; + const currentValues: DocModel[][] = p.queries.map(() => []); + const hasFiredOnceTracker: Record = {}; + let hasFiredOnce = false; + p.queries.forEach((q, index) => { + const subRef = this.querySubscription(q); + const sub = subRef.subscribe( + (result) => { + if (!hasFiredOnceTracker[index]) { + hasFiredOnceTracker[index] = true; + } + currentValues[index] = result.docs; + if (Object.keys(hasFiredOnceTracker).length === p.queries.length) { + let docs = _.flatten(currentValues); + + if (p.mergeProcess?.runDedupe) { + docs = _.uniqBy(docs, 'id'); + } + + if (p.mergeProcess?.orderBy) { + docs = _.sortBy(docs, p.mergeProcess.orderBy.sortKey); + + if (p.mergeProcess.orderBy.dir === 'desc') { + docs.reverse(); + } + } + fn({ docs, changes: hasFiredOnce ? result.changes : [], metadata: result.metadata }); + hasFiredOnce = true; + } + }, + (e) => { + errorFn(e); + } + ); + unsubscribeFns.push(sub.unsubscribe); + }); + return { + unsubscribe: () => { + unsubscribeFns.forEach((f) => f()); + } + }; + } + }; + } + + async query(query: SimpleQuery): Promise> { + if (this.isDisabled) { + console.warn('Cannot query while firestoreLift disabled'); + return { docs: [] }; + } + + try { + let queryRef = generateQueryRef(query, this.collection, this.firestore as any); + if (query._internalStartAfterDocId) { + // Find start doc. This is used for pagination + let startAfterDoc = await this.firestore.collection(this.collection).doc(query._internalStartAfterDocId).get(); + queryRef = queryRef.startAfter(startAfterDoc) as any; + } + let results: DocModel[] = []; + let res = await queryRef.get(); + for (let i = 0; i < res.docs.length; i++) { + let doc: any = res.docs[i].data(); + results.push(doc); + } + + let result: QueryResultSet = { docs: results }; + + if (res.size === query.limit) { + let paginationQuery = { ...query }; + let lastDoc = res.docs[res.docs.length - 1]; + paginationQuery._internalStartAfterDocId = lastDoc.id; + result.nextQuery = paginationQuery; + } + + this._stats.docsFetched += result.docs.length; + return result; + } catch (err) { + let msg = `${err.message} in firestore-lift subscription on collection ${ + this.collection + } with query:${JSON.stringify(query)}`; + // Do NOT delete the console.error. Propagation beyond this point is too inconsistent. This would have saved many hours of dev work with swallowed errors + console.error(msg); + throw err; + } + } + + // Fetches a batch of documents based on ids + async getDocs(ids: string[]): Promise>> { + if (this.isDisabled) { + console.warn('Cannot get while firestoreLift disabled'); + return []; + } + let p = []; + for (let i = 0; i < ids.length; i++) { + p.push( + (async () => { + try { + let res = await this.firestore.collection(this.collection).doc(ids[i]).get(); + let doc = res.data(); + if (doc) { + return doc as any; + } else { + return null; + } + } catch (err) { + let msg = `${err.message} in firestore-lift get action ${this.collection} with id:${ids[i]}`; + // Do NOT delete the console.error. Propagation beyond this point is too inconsistent. This would have saved many hours of dev work with swallowed errors + console.error(msg); + throw err; + } + })() + ); + } + + this._stats.docsFetched += p.length; + return await Promise.all(p); + } + + async getDoc(id: string): Promise> { + if (this.isDisabled) { + console.warn('Cannot add while firestoreLift disabled'); + null; + } + const val = await this.getDocs([id]); + + if (val[0]) { + return val[0]; + } + + return null; + } + + // Adds a document + async add(request: { doc: DocModel }, config?: { returnBatchTask: boolean }): Promise { + if (this.isDisabled) { + console.warn('Cannot add while firestoreLift disabled'); + return defaultEmptyTask; + } + if (!request.doc['id']) { + request.doc['id'] = this.generateId(); + } + + // We always add a createdAtMS + if (!request.doc['createdAtMS']) { + request.doc['createdAtMS'] = Date.now(); + } + + let task: BatchTaskAdd = { + id: request.doc['id'], + type: 'add', + collection: this.collection, + doc: request.doc + }; + + this._stats.docsWritten += 1; + if (config && config.returnBatchTask) { + return task; + } else { + return await this.batchRunner.executeBatch([task]); + } + } + + // Overwrites a doc + async set( + request: { id: string; doc: DocModel }, + config?: { returnBatchTask: boolean } + ): Promise { + if (this.isDisabled) { + console.warn('Cannot setDoc while firestoreLift disabled'); + return defaultEmptyTask; + } + + if (!request.doc['id']) { + request.doc['id'] = request.id; + } + + let task: BatchTaskSet = { + type: 'set', + id: request.doc['id'], + collection: this.collection, + doc: request.doc + }; + + this._stats.docsWritten += 1; + if (config && config.returnBatchTask) { + return task; + } else { + return await this.batchRunner.executeBatch([task]); + } + } + + // Destructive update/delete for document path. Does not merge with existing data. + async setPath( + request: { id: string; pathObj: OptionalFlex; value: Optional }, + config?: { returnBatchTask: boolean } + ): Promise { + if (this.isDisabled) { + console.warn('Cannot setPath while firestoreLift disabled'); + return defaultEmptyTask; + } + let task: BatchTaskSetPath = { + type: 'setPath', + id: request.id, + pathObj: request.pathObj, + value: request.value, + collection: this.collection + }; + this._stats.docsWritten += 1; + if (config && config.returnBatchTask) { + return task; + } else { + return await this.batchRunner.executeBatch([task]); + } + } + + // Updates/deletes parts of a document. Will merge with existing data. + async update( + request: { id: string; doc: Optional }, + config?: { returnBatchTask: boolean } + ): Promise { + if (this.isDisabled) { + console.warn('Cannot update while firestoreLift disabled'); + return defaultEmptyTask; + } + let task: BatchTaskUpdate = { + type: 'update', + id: request.id, + doc: request.doc, + collection: this.collection + }; + this._stats.docsWritten += 1; + if (config && config.returnBatchTask) { + return task; + } else { + return await this.batchRunner.executeBatch([task]); + } + } + + // Deletes a document + async delete(r: { id: string }, config?: { returnBatchTask: boolean }): Promise { + if (this.isDisabled) { + console.warn('Cannot delete while firestoreLift disabled'); + return defaultEmptyTask; + } + let task: BatchTaskDelete = { + type: 'delete', + collection: this.collection, + id: r.id + }; + this._stats.docsWritten += 1; + if (config && config.returnBatchTask) { + return task; + } else { + return await this.batchRunner.executeBatch([task]); + } + } + + setFirestoreLiftDisabledStatus(status: boolean) { + this.isDisabled = status; + } +} diff --git a/src/RTDB.ts b/src/RTDB.ts new file mode 100644 index 0000000..57f9668 --- /dev/null +++ b/src/RTDB.ts @@ -0,0 +1,40 @@ +import * as firebase from 'firebase'; + +export type TypedFirebaseObjectOrPrimativeRefGenerator = (path: string) => TypedFirebaseObjectOrPrimativeRef; + +export interface RootRtdbLift { + _RawRtdb: firebase.database.Database; +} + +interface RtdbConfig { + firebaseApp: firebase.app.App; + nodes: T; +} + +export function createRtdbLift(config: RtdbConfig): T & RootRtdbLift { + const db = config.firebaseApp.database(); + + const r: any = { + _RawRtdb: db + }; + + Object.keys(config.nodes).forEach((key) => { + r[key] = (path: string) => db.ref(path); + }); + + return r; +} + +type TypedFirebaseObjectOrPrimativeRef = Omit< + firebase.database.Reference, + 'set' | 'update' | 'once' | 'on' +> & { + set: (o: Obj) => ReturnType; + update: (o: Partial) => ReturnType; + once: (type: 'value') => Promise>; + on: (t: 'value', sub: (snap: TypedSnapshot) => void) => ReturnType; +}; + +type TypedSnapshot = Omit & { + val: () => Obj | undefined; +}; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..bd2a8fe --- /dev/null +++ b/src/index.ts @@ -0,0 +1,4 @@ +export { createFirestoreLift } from './FirestoreLift'; +export { BatchTask, FirestoreLiftInitConfig } from './models'; +export { FirestoreLiftCollection } from './FirestoreLiftCollection'; +export { createRtdbLift, TypedFirebaseObjectOrPrimativeRefGenerator } from './RTDB'; diff --git a/src/misc.ts b/src/misc.ts new file mode 100644 index 0000000..108e1eb --- /dev/null +++ b/src/misc.ts @@ -0,0 +1,133 @@ +import { SimpleQuery, BatchTaskEmpty } from './models'; +import * as firebase from 'firebase'; +import * as shortid from 'shortid'; + +export const defaultQueryLimit = 1000; + +export const defaultEmptyTask: BatchTaskEmpty = { + id: '', + collection: 'empty', + type: 'empty' +}; + +export function generateQueryRef( + queryRequest: SimpleQuery, + collection: string, + fireStore: firebase.firestore.Firestore +): firebase.firestore.CollectionReference { + let query = fireStore.collection(collection); + + if (queryRequest.where) { + for (let i = 0; i < queryRequest.where.length; i++) { + let whereItem = queryRequest.where[i]; + let r1 = generateFirestorePathFromObject(whereItem); + + if (!(r1.value instanceof Array)) { + throw new Error( + "Query value must be an array. Example: { where: [{id: ['==', '123']}] }. Instead encountered the following query: " + + JSON.stringify(queryRequest, null, 2) + ); + } + query = query.where(r1.path, r1.value[0], r1.value[1]) as any; + } + } + if (queryRequest.orderBy) { + for (let i = 0; i < queryRequest.orderBy.length; i++) { + let orderBy = queryRequest.orderBy[i]; + let path = generateFirestorePathFromObject(orderBy.pathObj).path; + query = query.orderBy(path as any, orderBy.dir || undefined) as any; + } + } + + if (queryRequest.startAtValue) { + query = query.startAt(...queryRequest.startAtValue) as any; + } + + if (queryRequest.startAfterValue) { + query = query.startAfter(...queryRequest.startAfterValue) as any; + } + + if (queryRequest.endAtValue) { + query = query.endAt(...queryRequest.endAtValue) as any; + } + + if (queryRequest.endBeforeValue) { + query = query.endBefore(...queryRequest.endBeforeValue) as any; + } + + // Lock it to something to prevent massive batches but also to make it easier to detect if we need to paginate + let limit = queryRequest.limit === undefined ? defaultQueryLimit : queryRequest.limit; + query = query.limit(limit) as any; + + return query; +} + +export function generateFirestorePathFromObject( + obj: any, + acc: string[] = [] +): { path: string; value: boolean | string | number | any[] } { + let type = typeof obj; + + if (['string', 'number', 'boolean'].includes(type) || obj instanceof Array) { + return { path: acc.join('.'), value: obj }; + } + + let keys = Object.keys(obj); + if (keys.length > 1) { + console.warn(`Was expecting to find 1 key but found ${keys.length}`); + } + + acc.push(keys[0]); + + return generateFirestorePathFromObject(obj[keys[0]], acc); +} + +export const generatePushID = (function () { + // Modeled after base64 web-safe chars, but ordered by ASCII. + var PUSH_CHARS = 'XX0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + + // Timestamp of last push, used to prevent local collisions if you push twice in one ms. + var lastPushTime = 0; + + // We generate 72-bits of randomness which get turned into 12 characters and appended to the + // timestamp to prevent collisions with other clients. We store the last characters we + // generated because in the event of a collision, we'll use those same characters except + // "incremented" by one. + var lastRandChars: any[] = []; + + return function () { + var now = new Date().getTime(); + var duplicateTime = now === lastPushTime; + lastPushTime = now; + + var timeStampChars = new Array(8); + for (var i = 7; i >= 0; i--) { + timeStampChars[i] = PUSH_CHARS.charAt(now % 64); + // NOTE: Can't use << here because javascript will convert to int and lose the upper bits. + now = Math.floor(now / 64); + } + if (now !== 0) throw new Error('We should have converted the entire timestamp.'); + + var id = timeStampChars.join(''); + + if (!duplicateTime) { + for (i = 0; i < 12; i++) { + lastRandChars[i] = Math.floor(Math.random() * 64); + } + } else { + // If the timestamp hasn't changed since last push, use the same random number, except incremented by 1. + for (i = 11; i >= 0 && lastRandChars[i] === 63; i--) { + lastRandChars[i] = 0; + } + lastRandChars[i]++; + } + for (i = 0; i < 12; i++) { + id += PUSH_CHARS.charAt(lastRandChars[i]); + } + if (id.length != 20) throw new Error('Length should be 20.'); + + id = id.substr(1); + + return `${id}${shortid.generate()}`; + }; +})(); diff --git a/src/models.ts b/src/models.ts new file mode 100644 index 0000000..762d355 --- /dev/null +++ b/src/models.ts @@ -0,0 +1,197 @@ +import * as firebase from 'firebase'; +import { BatchRunner } from './BatchRunner'; + +// Expected all docs extend this interface +export interface FirestoreLiftDocRoot { + id: string; + createdAtMS: number; + updatedAtMS: number; +} + +export interface FirestoreLiftRoot { + _setFirestoreLiftDisabledStatus: (status: boolean) => void; + _GetStats: () => FirestoreLiftStats; + _RawFirestore: firebase.firestore.Firestore; + _BatchRunner: BatchRunner; + _MagicDeleteValue: any; + _MagicIncrementValue: any; +} + +export interface CollectionConfig { + collection: string; + prefixIdWithCollectionName?: boolean; // true (default) + disableIdGeneration?: boolean; // false (default) +} + +export interface FirestoreLiftInitConfig { + collections: Record; + firebaseApp: firebase.app.App; + firestoreModule: typeof firebase.firestore; // We need to import the actual module since we often use firebase-admin on the server and need to access certain fields for delete and other things +} + +export interface FirestoreLiftStats { + summary: { + statsInitMS: number; + totalDocsFetched: number; + totalDocsWritten: number; + totalSubscriptionsOverTime: number; + totalActiveSubscriptions: number; + }; + byCollection: { [key: string]: FirestoreLiftCollectionStats }; +} + +export type Change = { doc: T; changeType: 'added' | 'modified' | 'removed' }[]; + +export type QueryResultSet = { + docs: DocModel[]; + nextQuery?: SimpleQuery; +}; + +export type QuerySubscriptionResultSet = { + docs: DocModel[]; + changes: Change[]; + metadata: firebase.firestore.SnapshotMetadata; +}; + +export type FirestoreLiftQuerySubscription = { + subscribe: ( + fn: (p: QuerySubscriptionResultSet) => void, + errorFn: (e: Error) => void + ) => { + unsubscribe: () => void; + }; +}; + +export type FirestoreLiftDocSubscription = { + subscribe: ( + fn: (p: OrNull) => void, + errorFn: (e: Error) => void + ) => { + unsubscribe: () => void; + }; +}; + +export type FirestoreLiftDocsSubscription = { + subscribe: ( + fn: (p: Array>) => void, + errorFn: (e: Error) => void + ) => { + unsubscribe: () => void; + }; +}; + +/*********** + ORIGINAL + ********** */ + +type WhereFilter = OptionalQuery; +type WhereFilterOp = '<' | '<=' | '==' | '>=' | '>' | 'in'; +type OrderByDirection = 'desc' | 'asc'; +export type startEndAtValueTypes = string | number; + +export interface SimpleQuery { + limit?: number; + where?: WhereFilter[]; + orderBy?: { pathObj: OptionalFlex; dir?: OrderByDirection }[]; + startAtValue?: startEndAtValueTypes[]; + startAfterValue?: startEndAtValueTypes[]; + endAtValue?: startEndAtValueTypes[]; + endBeforeValue?: startEndAtValueTypes[]; + _internalStartAfterDocId?: any; // Used for pagination. If defined then we ignore startAt +} + +export type OrNull = T | null; + +export type Optional = { [P in keyof T]?: Optional2 }; +type Optional2 = { [P in keyof T]?: Optional3 }; +type Optional3 = { [P in keyof T]?: Optional4 }; +type Optional4 = { [P in keyof T]?: Optional5 }; +type Optional5 = { [P in keyof T]?: Optional6 }; +type Optional6 = { [P in keyof T]?: Optional7 }; +type Optional7 = { [P in keyof T]?: Optional8 }; +type Optional8 = { [P in keyof T]?: any }; + +export type OptionalQuery = { [P in keyof T]?: [WhereFilterOp, T[P]] | ['in', T[P][]] | OptionalQuery2 }; +type OptionalQuery2 = { [P in keyof T]?: [WhereFilterOp, T[P]] | ['in', T[P][]] | OptionalQuery3 }; +type OptionalQuery3 = { [P in keyof T]?: [WhereFilterOp, T[P]] | ['in', T[P][]] | OptionalQuery4 }; +type OptionalQuery4 = { [P in keyof T]?: [WhereFilterOp, T[P]] | ['in', T[P][]] | OptionalQuery5 }; +type OptionalQuery5 = { [P in keyof T]?: [WhereFilterOp, T[P]] | ['in', T[P][]] | OptionalQuery6 }; +type OptionalQuery6 = { [P in keyof T]?: [WhereFilterOp, T[P]] | ['in', T[P][]] | OptionalQuery7 }; +type OptionalQuery7 = { [P in keyof T]?: [WhereFilterOp, T[P]] | ['in', T[P][]] | OptionalQuery8 }; +type OptionalQuery8 = { [P in keyof T]?: any }; + +// Allows you to create an object that mirrors the shape of a interface but you can put a boolean at any node. +// The object can then be used to extract the path +export type OptionalFlex = { [P in keyof T]?: boolean | OptionalFlex2 }; +type OptionalFlex2 = { [P in keyof T]?: boolean | OptionalFlex3 }; +type OptionalFlex3 = { [P in keyof T]?: boolean | OptionalFlex4 }; +type OptionalFlex4 = { [P in keyof T]?: boolean | OptionalFlex5 }; +type OptionalFlex5 = { [P in keyof T]?: boolean | OptionalFlex6 }; +type OptionalFlex6 = { [P in keyof T]?: boolean | OptionalFlex7 }; +type OptionalFlex7 = { [P in keyof T]?: boolean | OptionalFlex8 }; +type OptionalFlex8 = { [P in keyof T]?: any }; + +interface BatchTaskRoot { + collection: string; + id: string; +} + +export const MagicDeleteString = '____DELETE_DELETE_DELETE_DELETE____'; +export const MagicIncrementString = '____INCREMENT_INCREMENT_INCREMENT____'; +export const MagicServerTimestampString = '____SEVRVERTIMESTAMP_SEVRVERTIMESTAMP_SEVRVERTIMESTAMP____'; + +export interface BatchTaskAdd extends BatchTaskRoot { + type: 'add'; + doc: any; +} + +export interface BatchTaskEmpty extends BatchTaskRoot { + type: 'empty'; +} + +export interface BatchTaskSet extends BatchTaskRoot { + type: 'set'; + doc: any; +} + +export interface BatchTaskSetPath extends BatchTaskRoot { + type: 'setPath'; + pathObj: any; + value: any; +} + +export interface BatchTaskUpdate extends BatchTaskRoot { + type: 'update'; + doc: any; +} + +export interface BatchTaskDelete extends BatchTaskRoot { + type: 'delete'; +} + +export type BatchTask = + | BatchTaskAdd + | BatchTaskSetPath + | BatchTaskSet + | BatchTaskUpdate + | BatchTaskDelete + | BatchTaskEmpty; + +/*********** + LESS IMPORTANT ONES + ********** */ + +export interface ActiveSubscriptions { + [subscriptionId: string]: { + subscriptionDetails: string; + subscriberCount: number; + }; +} + +export interface FirestoreLiftCollectionStats { + statsInitMS: number; + docsFetched: number; + docsWritten: number; // Assumes the tasks were executed + totalSubscriptionsOverTime: number; + activeSubscriptions: ActiveSubscriptions; +} diff --git a/src/tests/firestore-tests.ts b/src/tests/firestore-tests.ts new file mode 100644 index 0000000..9450bea --- /dev/null +++ b/src/tests/firestore-tests.ts @@ -0,0 +1,590 @@ +import { describe, test, setOptions, otest, run } from 'nano-test-runner'; +import { getTestFirestoreLift, reset, Person } from './helpers'; +import * as _ from 'lodash'; +import * as assert from 'assert'; +import * as jsonStable from 'json-stable-stringify'; +import { SimpleQuery } from '../models'; + +setOptions({ runPattern: 'serial', suppressConsole: false }); + +const t = getTestFirestoreLift(); + +describe('Basic CRUD', () => { + const bookId = t.Book.generateId(); + + run(async () => await reset()); + test('create doc', async () => { + await reset(); + await t.Book.add({ + doc: { + id: bookId, + createdAtMS: Date.now(), + updatedAtMS: Date.now(), + title: 'The Cat and the Hat', + totalPages: 34 + } + }); + }); + + test('fetch by id', async () => { + let book = await t.Book.getDoc(bookId); + if (!book) { + throw 'No book found'; + } + }); + + test('update object', async () => { + const newTitle = 'Harry Potter'; + await t.Book.update({ id: bookId, doc: { title: newTitle } }); + let b1 = await t.Book.getDoc(bookId); + if (!b1) { + throw 'No object found'; + } + assert.deepEqual(b1.title, newTitle); + }); + + test('delete object', async () => { + await t.Book.delete({ id: bookId }); + let b1 = await t.Book.getDoc(bookId); + if (b1) { + throw 'Book should be deleted'; + } + }); + + const person1: Person = { + id: t.Person.generateId(), + age: 30, + createdAtMS: Date.now(), + favFoods: { american: 'a1', asian: 'a2' }, + name: 'Bob', + updatedAtMS: Date.now(), + weight: 100 + }; + run(async () => { + await reset(); + await t.Person.add({ + doc: { + id: person1.id, + age: 30, + createdAtMS: Date.now(), + favFoods: { american: 'a1', asian: 'a2' }, + name: 'Bob', + updatedAtMS: Date.now(), + weight: 100 + } + }); + }); + + test('set', async () => { + let p1: Person = { + age: 34, + createdAtMS: Date.now(), + favFoods: {}, + id: t.Person.generateId(), + name: 'Bob1', + updatedAtMS: Date.now(), + weight: 123 + }; + let p2: Person = { + age: 4, + createdAtMS: Date.now(), + favFoods: { american: 'cheese' }, + id: p1.id, + name: 'Bob2', + updatedAtMS: Date.now(), + weight: 100 + }; + await t.Person.set({ id: p1.id, doc: p1 }); + const r1Doc = await t.Person.getDoc(p1.id); + assert.deepEqual(jsonStable(r1Doc), jsonStable(p1)); + + await t.Person.set({ id: p2.id, doc: p2 }); + const r2Doc = await t.Person.getDoc(p2.id); + assert.deepEqual(jsonStable(r2Doc), jsonStable(p2)); + }); + + test('set path', async () => { + await t.Person.setPath({ id: person1.id, pathObj: { favFoods: true }, value: { favFoods: { italian: 'pizza' } } }); + const person = await t.Person.getDoc(person1.id); + // Make sure the other two nodes have been removed + if (person) { + assert.equal(Object.keys(person.favFoods).length, 1); + assert.equal(person.favFoods.italian, 'pizza'); + } else { + throw 'No person found'; + } + }); + + test('increment field', async () => { + await t.Person.update({ id: person1.id, doc: { age: t._MagicIncrementValue } }); + const person = await t.Person.getDoc(person1.id); + assert.equal(person?.age, person1.age + 1); + }); + + test('delete field', async () => { + await t.Person.update({ id: person1.id, doc: { name: t._MagicDeleteValue } }); + const person = await t.Person.getDoc(person1.id); + assert.equal(person && person.name === undefined, true); + }); +}); + +// Batches +// Check the metrics + +describe('Batches/Queries/Subscriptions', () => { + const people: Person[] = [ + { + id: t.Person.generateId(), + age: 36, + favFoods: { american: 'cheese burger', asian: 'sushi', italian: 'pizza' }, + name: 'Kevin', + weight: 220, + createdAtMS: Date.now(), + updatedAtMS: Date.now() + }, + { + id: t.Person.generateId(), + age: 3, + favFoods: { american: 'cheese burger', asian: 'rice', italian: 'cheese stick' }, + name: 'Henry', + weight: 33, + createdAtMS: Date.now(), + updatedAtMS: Date.now() + }, + { + id: t.Person.generateId(), + age: 33, + favFoods: { american: 'chicken', asian: 'sushi', italian: 'lasagna' }, + name: 'Karoline', + weight: 140, + createdAtMS: Date.now(), + updatedAtMS: Date.now() + }, + { + id: t.Person.generateId(), + age: 5, + favFoods: { american: 'cheese burger', asian: 'sushi', italian: 'lasagna' }, + name: 'Elaine', + weight: 45, + createdAtMS: Date.now(), + updatedAtMS: Date.now() + }, + { + id: t.Person.generateId(), + age: 1, + favFoods: { american: 'mac n cheese', asian: 'rice', italian: 'lasagna' }, + name: 'Hazel', + weight: 20, + createdAtMS: Date.now(), + updatedAtMS: Date.now() + } + ]; + run(async () => { + await reset(); + for (let i = 0; i < people.length; i++) { + await t.Person.add({ doc: people[i] }); + } + }); + + test('basic getDocs', async () => { + const r = await t.Person.getDocs([people[0].id, 'SHOULD_NOT_EXIST_ID', people[1].id]); + assert.deepEqual(r[0] ? r[0].id : '', people[0].id); + assert.deepEqual(r[1], null); + assert.deepEqual(r[2] ? r[2].id : '', people[1].id); + }); + + test('basic query', async () => { + const r = await t.Person.query({}); + assert.equal(r.docs.length, people.length); + }); + test('single where condition', async () => { + const expectedFood = 'cheese burger'; + const r = await t.Person.query({ where: [{ favFoods: { american: ['==', expectedFood] } }] }); + assert.equal(r.docs.length, people.filter((p) => p.favFoods.american === expectedFood).length); + }); + test('multiple where conditions', async () => { + const expectedFood = 'cheese burger'; + const maxExpectedAge = 10; + const r = await t.Person.query({ + where: [{ favFoods: { american: ['==', expectedFood] } }, { age: ['<=', maxExpectedAge] }] + }); + assert.equal( + r.docs.length, + people.filter((p) => p.favFoods.american === expectedFood && p.age <= maxExpectedAge).length + ); + }); + + test('where condition for IN', async () => { + const inArr: number[] = [1, 2, 3, 4]; + const r = await t.Person.query({ + where: [{ age: ['in', inArr] }] + }); + assert.equal(r.docs.length, people.filter((p) => inArr.includes(p.age)).length); + }); + + test('orderBy (asc)', async () => { + const r = await t.Person.query({ + orderBy: [{ pathObj: { age: true } }] + }); + assert.equal( + r.docs.map((p) => p.id).join(''), + people + .sort((a, b) => a.age - b.age) + .map((p) => p.id) + .join('') + ); + }); + test('orderBy (desc)', async () => { + const r = await t.Person.query({ + orderBy: [{ pathObj: { age: true }, dir: 'desc' }] + }); + assert.equal( + r.docs.map((p) => p.id).join(''), + people + .sort((a, b) => b.age - a.age) + .map((p) => p.id) + .join('') + ); + }); + + test('orderBy with startAfterValue', async () => { + const r = await t.Person.query({ + orderBy: [{ pathObj: { age: true } }], + startAfterValue: [10] + }); + assert.equal( + r.docs.map((p) => p.id).join(''), + people + .sort((a, b) => a.age - b.age) + .filter((a) => a.age > 10) + .map((p) => p.id) + .join('') + ); + }); + + test('orderBy with startAtValue', async () => { + const r = await t.Person.query({ + orderBy: [{ pathObj: { age: true } }], + startAtValue: [5] + }); + assert.equal( + r.docs.map((p) => p.id).join(''), + people + .sort((a, b) => a.age - b.age) + .filter((a) => a.age >= 5) + .map((p) => p.id) + .join('') + ); + }); + + test('orderBy with endBeforeValue', async () => { + const r = await t.Person.query({ + orderBy: [{ pathObj: { age: true } }], + endBeforeValue: [10] + }); + assert.equal( + r.docs.map((p) => p.id).join(''), + people + .sort((a, b) => a.age - b.age) + .filter((a) => a.age < 10) + .map((p) => p.id) + .join('') + ); + }); + + test('orderBy with endAtValue', async () => { + const r = await t.Person.query({ + orderBy: [{ pathObj: { age: true } }], + endAtValue: [5] + }); + assert.equal( + r.docs.map((p) => p.id).join(''), + people + .sort((a, b) => a.age - b.age) + .filter((a) => a.age <= 5) + .map((p) => p.id) + .join('') + ); + }); + + test('limit', async () => { + const r = await t.Person.query({ + limit: 2 + }); + assert.equal(r.docs.length, 2); + }); + + test('pagination', async () => { + const result: Person[] = []; + let batchSize = 2; + let totalResultSets = 0; + let pendingQuery: SimpleQuery = { + limit: batchSize + }; + while (true) { + totalResultSets += 1; + const r = await t.Person.query(pendingQuery); + result.push(...r.docs); + if (r.nextQuery) { + pendingQuery = r.nextQuery; + } else { + break; + } + } + + assert.equal(result.length, people.length); + assert.equal(totalResultSets, Math.ceil(people.length / batchSize)); + }); + + test('multi query', async () => { + const expectedFood = 'cheese burger'; + const r = await t.Person.multiQuery({ + queries: [{ where: [{ age: ['==', 1] }] }, { where: [{ favFoods: { american: ['==', 'cheese burger'] } }] }] + }); + + const q1 = people.filter((p) => p.age === 1); + const q2 = people.filter((p) => p.favFoods.american === 'cheese burger'); + assert.equal( + r.docs + .map((d) => d.id) + .sort() + .join(), + [...q1, ...q2] + .map((p) => p.id) + .sort() + .join() + ); + }); + + test('multi query with sort', async () => { + const r = await t.Person.multiQuery({ + queries: [{ where: [{ age: ['>=', 3] }] }], + mergeProcess: { + orderBy: { sortKey: 'age', dir: 'asc' } + } + }); + + assert.equal( + r.docs.map((d) => d.id).join(), + _.sortBy( + people.filter((p) => p.age >= 3), + 'age' + ) + .map((p) => p.id) + .join() + ); + }); + + test('multi query with no dedupe', async () => { + const r = await t.Person.multiQuery({ + queries: [{ where: [{ age: ['>=', 3] }] }, { where: [{ age: ['>=', 10] }] }] + }); + assert.equal(r.docs.length, [...people.filter((p) => p.age >= 3), ...people.filter((p) => p.age >= 10)].length); + }); + + test('multi query with dedupe', async () => { + const r = await t.Person.multiQuery({ + queries: [{ where: [{ age: ['>=', 3] }] }, { where: [{ age: ['>=', 10] }] }], + mergeProcess: { + runDedupe: true + } + }); + assert.equal( + r.docs.length, + _.uniqBy([...people.filter((p) => p.age >= 3), ...people.filter((p) => p.age >= 10)], 'id').length + ); + }); + + test('doc subscription', () => { + let pass = 0; + return new Promise(async (resolve, reject) => { + let person: Person = { + age: 23, + createdAtMS: Date.now(), + favFoods: {}, + id: t.Person.generateId(), + name: 'Bob', + weight: 34, + updatedAtMS: Date.now() + }; + await t.Person.add({ doc: person }); + + const subRef = t.Person.docSubscription(person.id); + + subRef.subscribe( + async (r) => { + if (!r) { + return; + } + pass += 1; + if (pass === 1) { + try { + assert.deepEqual(jsonStable(r), jsonStable(person)); + await t.Person.update({ id: person.id, doc: { age: 100 } }); + } catch (e) { + reject(e); + } + } else if (pass === 2) { + assert.deepEqual(r.age, 100); + await t.Person.delete({ id: person.id }); + resolve(); + } + }, + (e) => { + reject(e); + } + ); + }); + }); + + test('docs subscription', () => { + return new Promise(async (resolve, reject) => { + const ref = t.Person.docsSubscription([people[0].id, 'SHOULD_NOT_EXIST_ID', people[1].id]); + let pass = 0; + ref.subscribe( + async (docs) => { + pass += 1; + if (pass === 1) { + assert.deepEqual(docs[0] ? docs[0].id : '', people[0].id); + assert.deepEqual(docs[1], null); + assert.deepEqual(docs[2] ? docs[2].id : '', people[1].id); + await t.Person.update({ id: people[0].id, doc: { name: 'Heber' } }); + } else if (pass === 2) { + assert.deepEqual(docs[0] ? docs[0].name : '', 'Heber'); + resolve(); + } + }, + (e) => { + reject(e); + } + ); + }); + }); + + test('query subscription', () => { + let pass = 0; + return new Promise(async (resolve, reject) => { + let extraPerson: Person = { + age: 44, + createdAtMS: Date.now(), + favFoods: {}, + id: t.Person.generateId(), + name: 'Bob', + updatedAtMS: Date.now(), + weight: 100 + }; + try { + assert.deepEqual(0, Object.keys(t.Book._stats.activeSubscriptions).length); + + let ref = t.Person.querySubscription({ where: [{ age: ['>=', 10] }] }); + let sub = ref.subscribe( + async (val) => { + pass += 1; + try { + if (pass === 1) { + let x = val.docs + .map((d) => d.id) + .sort() + .join(''); + let y = people + .filter((p) => p.age >= 10) + .map((p) => p.id) + .sort() + .join(''); + // Check initial subscription + assert.deepEqual(x, y); + + await t.Person.add({ + doc: extraPerson + }); + } else if (pass === 2) { + let x = val.docs + .map((d) => d.id) + .sort() + .join(''); + let y = [...people, extraPerson] + .filter((p) => p.age >= 10) + .map((p) => p.id) + .sort() + .join(''); + assert.deepEqual(x, y); + sub.unsubscribe(); + await t.Person.delete({ id: extraPerson.id }); + resolve(); + } + } catch (e) { + reject(e); + } + }, + (e) => reject(e) + ); + } catch (e) { + reject(e); + } + }); + }); + + test('multi query subscription', () => { + let pass = 0; + return new Promise(async (resolve, reject) => { + let extraPerson: Person = { + age: 44, + createdAtMS: Date.now(), + favFoods: {}, + id: t.Person.generateId(), + name: 'Bob', + updatedAtMS: Date.now(), + weight: 100 + }; + try { + assert.deepEqual(0, Object.keys(t.Book._stats.activeSubscriptions).length); + + let ref = t.Person.multiQuerySubscription({ + queries: [{ where: [{ age: ['>=', 10] }] }, { where: [{ age: ['<=', 1] }] }] + }); + let sub = ref.subscribe( + async (val) => { + pass += 1; + try { + if (pass === 1) { + let x = val.docs + .map((d) => d.id) + .sort() + .join(''); + let y = people + .filter((p) => p.age >= 10 || p.age <= 1) + .map((p) => p.id) + .sort() + .join(''); + // Check initial subscription + assert.deepEqual(x, y); + + await t.Person.add({ + doc: extraPerson + }); + } else if (pass === 2) { + let x = val.docs + .map((d) => d.id) + .sort() + .join(''); + let y = [...people, extraPerson] + .filter((p) => p.age >= 10 || p.age <= 1) + .map((p) => p.id) + .sort() + .join(''); + assert.deepEqual(x, y); + sub.unsubscribe(); + resolve(); + } + } catch (e) { + reject(e); + } + }, + (e) => reject(e) + ); + } catch (e) { + reject(e); + } + }); + }); +}); diff --git a/src/tests/helpers.ts b/src/tests/helpers.ts new file mode 100644 index 0000000..512d569 --- /dev/null +++ b/src/tests/helpers.ts @@ -0,0 +1,71 @@ +import { FirestoreLiftDocRoot, FirestoreLiftInitConfig } from '../models'; +import { FirestoreLiftCollection } from '../FirestoreLiftCollection'; +import * as firebase from 'firebase'; +import { createFirestoreLift } from '../FirestoreLift'; +import { clearFirestoreData } from '@firebase/testing'; +import { TypedFirebaseObjectOrPrimativeRefGenerator, createRtdbLift } from '../RTDB'; + +/* ***************** + Demo Models + *****************/ + +export interface Person extends FirestoreLiftDocRoot { + name: string; + age: number; + weight: number; + favFoods: { + asian?: string; + italian?: string; + american?: string; + }; +} + +export interface Book extends FirestoreLiftDocRoot { + title: string; + totalPages: number; +} + +interface ExampleFirestore { + Person: FirestoreLiftCollection; + Book: FirestoreLiftCollection; +} + +const testFirebaseConfig = { projectId: 'fir-lift', databaseURL: 'http://localhost:9000/?ns=fir-lift' }; + +export async function reset() { + await clearFirestoreData(testFirebaseConfig); +} + +let app: firebase.app.App; + +export function init() { + app = firebase.initializeApp(testFirebaseConfig); + const db = app.firestore(); + db.settings({ host: 'localhost:8080', ssl: false }); +} + +export function getTestFirestoreLift() { + const c: FirestoreLiftInitConfig = { + collections: { + Person: { + collection: 'person' + }, + Book: { + collection: 'book' + } + }, + firebaseApp: app, + firestoreModule: firebase.firestore + }; + + return createFirestoreLift(c); +} + +export function getTestRtdbLift() { + const nodes = { + account: (null as unknown) as TypedFirebaseObjectOrPrimativeRefGenerator, + book: (null as unknown) as TypedFirebaseObjectOrPrimativeRefGenerator + }; + + return createRtdbLift({ firebaseApp: app, nodes }); +} diff --git a/src/tests/index.ts b/src/tests/index.ts new file mode 100644 index 0000000..086236b --- /dev/null +++ b/src/tests/index.ts @@ -0,0 +1,4 @@ +import { init } from './helpers'; +init(); +import './firestore-tests'; +import './rtdb-tests'; diff --git a/src/tests/rtdb-tests.ts b/src/tests/rtdb-tests.ts new file mode 100644 index 0000000..a93fee9 --- /dev/null +++ b/src/tests/rtdb-tests.ts @@ -0,0 +1,52 @@ +import { describe, test, setOptions, otest, run } from 'nano-test-runner'; +import { getTestRtdbLift, Person } from './helpers'; +import * as assert from 'assert'; +import * as jsonStable from 'json-stable-stringify'; + +describe('Rtdb tests', () => { + const t = getTestRtdbLift(); + + const p1: Person = { + age: 23, + createdAtMS: Date.now(), + favFoods: { american: 'burger' }, + id: 'a1', + name: 'Bob', + updatedAtMS: Date.now(), + weight: 123 + }; + + run(async () => { + await t._RawRtdb.ref('/').remove(); + }); + + test('object read/write', async () => { + const ref = t.account('account/a1'); + await ref.set(p1); + const v1 = (await ref.once('value')).val(); + assert.deepEqual(jsonStable(v1), jsonStable(p1)); + }); + + test('object subscription/update', () => { + return new Promise(async (resolve, reject) => { + const ref = t.account('account/a2'); + await ref.set(p1); + let pass = 0; + ref.on('value', async (snap) => { + try { + pass += 1; + const newName = 'Kevin'; + if (pass === 1) { + await ref.update({ name: newName }); + } else if (pass === 2) { + const v1 = snap.val(); + assert.deepEqual(v1 && v1.name, newName); + resolve(); + } + } catch (e) { + reject(e); + } + }); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4eed6a9 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "lib": ["es2015", "es2017"], + "target": "es2017", + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "strict": true, + "module": "commonjs", + "outDir": "dist" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/*.test.ts", "**/*.spec.ts"] +} diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..e3017f6 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,912 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@firebase/analytics-types@0.3.1": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@firebase/analytics-types/-/analytics-types-0.3.1.tgz#3c5f5d71129c88295e17e914e34b391ffda1723c" + integrity sha512-63vVJ5NIBh/JF8l9LuPrQYSzFimk7zYHySQB4Dk9rVdJ8kV/vGQoVTvRu1UW05sEc2Ug5PqtEChtTHU+9hvPcA== + +"@firebase/analytics@0.3.5": + version "0.3.5" + resolved "https://registry.yarnpkg.com/@firebase/analytics/-/analytics-0.3.5.tgz#f7327637281dd7c9cd74152e2f6b8cc64c1330dc" + integrity sha512-p+h1s9A8EjGWfjAObu8ei46JoN2Ogbtl1RzqW7HjcPuclOIOmPTXKEXXCEXgO79OLxnzzezVeBtHPSx6r6gxJA== + dependencies: + "@firebase/analytics-types" "0.3.1" + "@firebase/component" "0.1.12" + "@firebase/installations" "0.4.10" + "@firebase/logger" "0.2.4" + "@firebase/util" "0.2.47" + tslib "1.11.1" + +"@firebase/app-types@0.6.1": + version "0.6.1" + resolved "https://registry.yarnpkg.com/@firebase/app-types/-/app-types-0.6.1.tgz#dcbd23030a71c0c74fc95d4a3f75ba81653850e9" + integrity sha512-L/ZnJRAq7F++utfuoTKX4CLBG5YR7tFO3PLzG1/oXXKEezJ0kRL3CMRoueBEmTCzVb/6SIs2Qlaw++uDgi5Xyg== + +"@firebase/app@0.6.4": + version "0.6.4" + resolved "https://registry.yarnpkg.com/@firebase/app/-/app-0.6.4.tgz#5b857f2ba1cc286c88a3a7e1e6a1a7cc36308bee" + integrity sha512-E1Zw6yeZYdYYFurMnklKPvE+q/xleHXs7bmcVgyhgAEg3Gv6/qXI4+4GdWh+iF7wmQ3Liesh51xqfdpvHBwAMQ== + dependencies: + "@firebase/app-types" "0.6.1" + "@firebase/component" "0.1.12" + "@firebase/logger" "0.2.4" + "@firebase/util" "0.2.47" + dom-storage "2.1.0" + tslib "1.11.1" + xmlhttprequest "1.8.0" + +"@firebase/auth-interop-types@0.1.5": + version "0.1.5" + resolved "https://registry.yarnpkg.com/@firebase/auth-interop-types/-/auth-interop-types-0.1.5.tgz#9fc9bd7c879f16b8d1bb08373a0f48c3a8b74557" + integrity sha512-88h74TMQ6wXChPA6h9Q3E1Jg6TkTHep2+k63OWg3s0ozyGVMeY+TTOti7PFPzq5RhszQPQOoCi59es4MaRvgCw== + +"@firebase/auth-types@0.10.1": + version "0.10.1" + resolved "https://registry.yarnpkg.com/@firebase/auth-types/-/auth-types-0.10.1.tgz#7815e71c9c6f072034415524b29ca8f1d1770660" + integrity sha512-/+gBHb1O9x/YlG7inXfxff/6X3BPZt4zgBv4kql6HEmdzNQCodIRlEYnI+/da+lN+dha7PjaFH7C7ewMmfV7rw== + +"@firebase/auth@0.14.6": + version "0.14.6" + resolved "https://registry.yarnpkg.com/@firebase/auth/-/auth-0.14.6.tgz#3d419d9b3553f17f94ceea1aaf2cecfdac26c778" + integrity sha512-7gaEUWhUubWBGfOXAZvpTpJqBJT9KyG83RXC6VnjSQIfNUaarHZ485WkzERil43A6KvIl+f4kHxfZShE6ZCK3A== + dependencies: + "@firebase/auth-types" "0.10.1" + +"@firebase/component@0.1.12": + version "0.1.12" + resolved "https://registry.yarnpkg.com/@firebase/component/-/component-0.1.12.tgz#deb511c92c5c6f60995207312b7ede8c7076fdb2" + integrity sha512-03w800MxR/EW1m7N0Q46WNcngwdDIHDWpFPHTdbZEI6U/HuLks5RJQlBxWqb1P73nYPkN8YP3U8gTdqrDpqY3Q== + dependencies: + "@firebase/util" "0.2.47" + tslib "1.11.1" + +"@firebase/database-types@0.5.1": + version "0.5.1" + resolved "https://registry.yarnpkg.com/@firebase/database-types/-/database-types-0.5.1.tgz#fab2f3fb48eec374a9f435ed21e138635cb9b71c" + integrity sha512-onQxom1ZBYBJ648w/VNRzUewovEDAH7lvnrrpCd69ukkyrMk6rGEO/PQ9BcNEbhlNtukpsqRS0oNOFlHs0FaSA== + dependencies: + "@firebase/app-types" "0.6.1" + +"@firebase/database@0.6.3": + version "0.6.3" + resolved "https://registry.yarnpkg.com/@firebase/database/-/database-0.6.3.tgz#d85471938a0a9d9169f1f37f8f9d2dec7ef32f8b" + integrity sha512-gHoCISHQVLoq+rGu+PorYxMkhsjhXov3ocBxz/0uVdznNhrbKkAZaEKF+dIAsUPDlwSYeZuwWuik7xcV3DtRaw== + dependencies: + "@firebase/auth-interop-types" "0.1.5" + "@firebase/component" "0.1.12" + "@firebase/database-types" "0.5.1" + "@firebase/logger" "0.2.4" + "@firebase/util" "0.2.47" + faye-websocket "0.11.3" + tslib "1.11.1" + +"@firebase/firestore-types@1.10.2": + version "1.10.2" + resolved "https://registry.yarnpkg.com/@firebase/firestore-types/-/firestore-types-1.10.2.tgz#b2286332c25dbe15becb9153ba3eedf7ab6c2a88" + integrity sha512-T1GttZezQ+gUpdDgLeLOvgS3KMeeIuodQ+JBBEd6M11zdilfTHsEHhmli15c6V3g/PfuFzyKDKExe05lPuYe4w== + +"@firebase/firestore@1.14.5": + version "1.14.5" + resolved "https://registry.yarnpkg.com/@firebase/firestore/-/firestore-1.14.5.tgz#0d7526ab8bec3e45726daad3c7c34b6aa09a71e2" + integrity sha512-BZD3RqlAEnq15i8Y53VUFsuWkbujslGaQIcuEnt6bOENzlKiLBwESmt/uGKRIsdQjc1krG2qdoPmaSMqULR0dA== + dependencies: + "@firebase/component" "0.1.12" + "@firebase/firestore-types" "1.10.2" + "@firebase/logger" "0.2.4" + "@firebase/util" "0.2.47" + "@firebase/webchannel-wrapper" "0.2.41" + "@grpc/grpc-js" "0.8.1" + "@grpc/proto-loader" "^0.5.0" + tslib "1.11.1" + +"@firebase/functions-types@0.3.17": + version "0.3.17" + resolved "https://registry.yarnpkg.com/@firebase/functions-types/-/functions-types-0.3.17.tgz#348bf5528b238eeeeeae1d52e8ca547b21d33a94" + integrity sha512-DGR4i3VI55KnYk4IxrIw7+VG7Q3gA65azHnZxo98Il8IvYLr2UTBlSh72dTLlDf25NW51HqvJgYJDKvSaAeyHQ== + +"@firebase/functions@0.4.44": + version "0.4.44" + resolved "https://registry.yarnpkg.com/@firebase/functions/-/functions-0.4.44.tgz#a0f87a16be9916f930d7e9533b4befdf7640eec2" + integrity sha512-Nbw+V/jYqfgq7wscsSDidqIzx8TrnmA2wRD1auCFNmf+gSJg8o+gNyCDdNHZI407jvrZcxp3nG1eMbqwmmnp7Q== + dependencies: + "@firebase/component" "0.1.12" + "@firebase/functions-types" "0.3.17" + "@firebase/messaging-types" "0.4.5" + isomorphic-fetch "2.2.1" + tslib "1.11.1" + +"@firebase/installations-types@0.3.4": + version "0.3.4" + resolved "https://registry.yarnpkg.com/@firebase/installations-types/-/installations-types-0.3.4.tgz#589a941d713f4f64bf9f4feb7f463505bab1afa2" + integrity sha512-RfePJFovmdIXb6rYwtngyxuEcWnOrzdZd9m7xAW0gRxDIjBT20n3BOhjpmgRWXo/DAxRmS7bRjWAyTHY9cqN7Q== + +"@firebase/installations@0.4.10": + version "0.4.10" + resolved "https://registry.yarnpkg.com/@firebase/installations/-/installations-0.4.10.tgz#3686be433a176e6e2798b674486255e6749a0b8c" + integrity sha512-Nf7VK9++0eQzjdvBkBNNaOdxPjFiKD0EllLCIQycHozF97BmuFUqb2Ik5L2JaWspWg7vxLNacLHvW48nPGx4Zw== + dependencies: + "@firebase/component" "0.1.12" + "@firebase/installations-types" "0.3.4" + "@firebase/util" "0.2.47" + idb "3.0.2" + tslib "1.11.1" + +"@firebase/logger@0.2.4": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@firebase/logger/-/logger-0.2.4.tgz#276e7c482f462b5b24b188257cf162b3b2cd3ad6" + integrity sha512-akHkOU7izYB1okp/B5sxClGjjw6KvZdSHyjNM5pKd67Zg5W6PsbkI/GFNv21+y6LkUkJwDRbdeDgJoYXWT3mMA== + +"@firebase/messaging-types@0.4.5": + version "0.4.5" + resolved "https://registry.yarnpkg.com/@firebase/messaging-types/-/messaging-types-0.4.5.tgz#452572d3c5b7fa83659fdb1884450477229f5dc4" + integrity sha512-sux4fgqr/0KyIxqzHlatI04Ajs5rc3WM+WmtCpxrKP1E5Bke8xu/0M+2oy4lK/sQ7nov9z15n3iltAHCgTRU3Q== + +"@firebase/messaging@0.6.16": + version "0.6.16" + resolved "https://registry.yarnpkg.com/@firebase/messaging/-/messaging-0.6.16.tgz#72ee968c25de5d376b48556ef36b8fc69aba2316" + integrity sha512-TAPISK5y3xbxUw81HxLDP6YPsRryU6Nl8Z7AjNnem13BoN9LJ2/wCi9RDMfPnQhAn0h0N+mpxy/GB+0IlEARlg== + dependencies: + "@firebase/component" "0.1.12" + "@firebase/installations" "0.4.10" + "@firebase/messaging-types" "0.4.5" + "@firebase/util" "0.2.47" + idb "3.0.2" + tslib "1.11.1" + +"@firebase/performance-types@0.0.13": + version "0.0.13" + resolved "https://registry.yarnpkg.com/@firebase/performance-types/-/performance-types-0.0.13.tgz#58ce5453f57e34b18186f74ef11550dfc558ede6" + integrity sha512-6fZfIGjQpwo9S5OzMpPyqgYAUZcFzZxHFqOyNtorDIgNXq33nlldTL/vtaUZA8iT9TT5cJlCrF/jthKU7X21EA== + +"@firebase/performance@0.3.4": + version "0.3.4" + resolved "https://registry.yarnpkg.com/@firebase/performance/-/performance-0.3.4.tgz#d2e4f33c3ddc35dd090ba86da4dc6087b083d996" + integrity sha512-VDoqJSB+2RuXlyyP7oSvBPEmoznG84HmEtb8DQWsAHeVkf+qlec1OTZR8IjktlIv+8Pg8MMuYoB0crx5g7xU5A== + dependencies: + "@firebase/component" "0.1.12" + "@firebase/installations" "0.4.10" + "@firebase/logger" "0.2.4" + "@firebase/performance-types" "0.0.13" + "@firebase/util" "0.2.47" + tslib "1.11.1" + +"@firebase/polyfill@0.3.36": + version "0.3.36" + resolved "https://registry.yarnpkg.com/@firebase/polyfill/-/polyfill-0.3.36.tgz#c057cce6748170f36966b555749472b25efdb145" + integrity sha512-zMM9oSJgY6cT2jx3Ce9LYqb0eIpDE52meIzd/oe/y70F+v9u1LDqk5kUF5mf16zovGBWMNFmgzlsh6Wj0OsFtg== + dependencies: + core-js "3.6.5" + promise-polyfill "8.1.3" + whatwg-fetch "2.0.4" + +"@firebase/remote-config-types@0.1.9": + version "0.1.9" + resolved "https://registry.yarnpkg.com/@firebase/remote-config-types/-/remote-config-types-0.1.9.tgz#fe6bbe4d08f3b6e92fce30e4b7a9f4d6a96d6965" + integrity sha512-G96qnF3RYGbZsTRut7NBX0sxyczxt1uyCgXQuH/eAfUCngxjEGcZQnBdy6mvSdqdJh5mC31rWPO4v9/s7HwtzA== + +"@firebase/remote-config@0.1.21": + version "0.1.21" + resolved "https://registry.yarnpkg.com/@firebase/remote-config/-/remote-config-0.1.21.tgz#72261a53a7564453f484278e14ba39d81da99833" + integrity sha512-EwDNU1mT+8Jn66IUwwNP5SM8AbaI7wmCXjp7djZtTXNrpPoh3xqzSRM1vTgp4Uu/mHffEDfbydsoJAIftADIfQ== + dependencies: + "@firebase/component" "0.1.12" + "@firebase/installations" "0.4.10" + "@firebase/logger" "0.2.4" + "@firebase/remote-config-types" "0.1.9" + "@firebase/util" "0.2.47" + tslib "1.11.1" + +"@firebase/storage-types@0.3.12": + version "0.3.12" + resolved "https://registry.yarnpkg.com/@firebase/storage-types/-/storage-types-0.3.12.tgz#79540761fb3ad8d674c98712633284d81b268e0f" + integrity sha512-DDV6Fs6aYoGw3w/zZZTkqiipxihnsvHf6znbeZYjIIHit3tr1uLJdGPDPiCTfZcTGPpg2ux6ZmvNDvVgJdHALw== + +"@firebase/storage@0.3.34": + version "0.3.34" + resolved "https://registry.yarnpkg.com/@firebase/storage/-/storage-0.3.34.tgz#410ac7988f49259572e0e15c600548982ec5e904" + integrity sha512-vuR1PpGdaCk25D2dT2trfmZZjpdfOn0rPTksvoqg7TAPLeoVsVoDyT2LgF3Arna/jqx52sAIRx1HLrlvzE1pgA== + dependencies: + "@firebase/component" "0.1.12" + "@firebase/storage-types" "0.3.12" + "@firebase/util" "0.2.47" + tslib "1.11.1" + +"@firebase/testing@^0.19.5": + version "0.19.5" + resolved "https://registry.yarnpkg.com/@firebase/testing/-/testing-0.19.5.tgz#96cd2ecc7dd07f3a803a02486f01a55dc0050f3a" + integrity sha512-2hMdeQYx7RYeuFkmiQ+BS1BGv6uPmzXlCPRRspotkAgOg6bXoBb/Tx8Hut3RaKFFwvP0uUXb95EKpDJSvhI9Hw== + dependencies: + "@firebase/logger" "0.2.4" + "@firebase/util" "0.2.47" + "@types/request" "2.48.4" + firebase "7.14.5" + request "2.88.2" + +"@firebase/util@0.2.47": + version "0.2.47" + resolved "https://registry.yarnpkg.com/@firebase/util/-/util-0.2.47.tgz#c5e02bbba7aa0786f29cc516b2e2ee17e0c1f4a4" + integrity sha512-RjcIvcfswyxYhf0OMXod+qeI/933wl9FGLIszf0/O1yMZ/s8moXcse7xnOpMjmQPRLB9vHzCMoxW5X90kKg/bQ== + dependencies: + tslib "1.11.1" + +"@firebase/webchannel-wrapper@0.2.41": + version "0.2.41" + resolved "https://registry.yarnpkg.com/@firebase/webchannel-wrapper/-/webchannel-wrapper-0.2.41.tgz#4e470c25a99fa0b1f629f1c5ef180a318d399fd0" + integrity sha512-XcdMT5PSZHiuf7LJIhzKIe+RyYa25S3LHRRvLnZc6iFjwXkrSDJ8J/HWO6VT8d2ZTbawp3VcLEjRF/VN8glCrA== + +"@grpc/grpc-js@0.8.1": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-0.8.1.tgz#3003a422577da39e7113566f2fdd4872f31e6090" + integrity sha512-e8gSjRZnOUefsR3obOgxG9RtYW2Mw83hh7ogE2ByCdgRhoX0mdnJwBcZOami3E0l643KCTZvORFwfSEi48KFIQ== + dependencies: + semver "^6.2.0" + +"@grpc/proto-loader@^0.5.0": + version "0.5.4" + resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.5.4.tgz#038a3820540f621eeb1b05d81fbedfb045e14de0" + integrity sha512-HTM4QpI9B2XFkPz7pjwMyMgZchJ93TVkL3kWPW8GDMDKYxsMnmf4w2TNMJK7+KNiYHS5cJrCEAFlF+AwtXWVPA== + dependencies: + lodash.camelcase "^4.3.0" + protobufjs "^6.8.6" + +"@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" + integrity sha1-m4sMxmPWaafY9vXQiToU00jzD78= + +"@protobufjs/base64@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/base64/-/base64-1.1.2.tgz#4c85730e59b9a1f1f349047dbf24296034bb2735" + integrity sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg== + +"@protobufjs/codegen@^2.0.4": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.4.tgz#7ef37f0d010fb028ad1ad59722e506d9262815cb" + integrity sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg== + +"@protobufjs/eventemitter@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz#355cbc98bafad5978f9ed095f397621f1d066b70" + integrity sha1-NVy8mLr61ZePntCV85diHx0Ga3A= + +"@protobufjs/fetch@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.0.tgz#ba99fb598614af65700c1619ff06d454b0d84c45" + integrity sha1-upn7WYYUr2VwDBYZ/wbUVLDYTEU= + dependencies: + "@protobufjs/aspromise" "^1.1.1" + "@protobufjs/inquire" "^1.1.0" + +"@protobufjs/float@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1" + integrity sha1-Xp4avctz/Ap8uLKR33jIy9l7h9E= + +"@protobufjs/inquire@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/inquire/-/inquire-1.1.0.tgz#ff200e3e7cf2429e2dcafc1140828e8cc638f089" + integrity sha1-/yAOPnzyQp4tyvwRQIKOjMY48Ik= + +"@protobufjs/path@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/path/-/path-1.1.2.tgz#6cc2b20c5c9ad6ad0dccfd21ca7673d8d7fbf68d" + integrity sha1-bMKyDFya1q0NzP0hynZz2Nf79o0= + +"@protobufjs/pool@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/pool/-/pool-1.1.0.tgz#09fd15f2d6d3abfa9b65bc366506d6ad7846ff54" + integrity sha1-Cf0V8tbTq/qbZbw2ZQbWrXhG/1Q= + +"@protobufjs/utf8@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" + integrity sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA= + +"@types/caseless@*": + version "0.12.2" + resolved "https://registry.yarnpkg.com/@types/caseless/-/caseless-0.12.2.tgz#f65d3d6389e01eeb458bd54dc8f52b95a9463bc8" + integrity sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w== + +"@types/json-stable-stringify@^1.0.32": + version "1.0.32" + resolved "https://registry.yarnpkg.com/@types/json-stable-stringify/-/json-stable-stringify-1.0.32.tgz#121f6917c4389db3923640b2e68de5fa64dda88e" + integrity sha512-q9Q6+eUEGwQkv4Sbst3J4PNgDOvpuVuKj79Hl/qnmBMEIPzB5QoFRUtjcgcg2xNUZyYUGXBk5wYIBKHt0A+Mxw== + +"@types/lodash@^4.14.158": + version "4.14.158" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.158.tgz#b38ea8b6fe799acd076d7a8d7ab71c26ef77f785" + integrity sha512-InCEXJNTv/59yO4VSfuvNrZHt7eeNtWQEgnieIA+mIC+MOWM9arOWG2eQ8Vhk6NbOre6/BidiXhkZYeDY9U35w== + +"@types/long@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9" + integrity sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w== + +"@types/md5@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@types/md5/-/md5-2.2.0.tgz#cd82e16b95973f94bb03dee40c5b6be4a7fb7fb4" + integrity sha512-JN8OVL/wiDlCWTPzplsgMPu0uE9Q6blwp68rYsfk2G8aokRUQ8XD9MEhZwihfAiQvoyE+m31m6i3GFXwYWomKQ== + dependencies: + "@types/node" "*" + +"@types/node@*": + version "14.0.5" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.0.5.tgz#3d03acd3b3414cf67faf999aed11682ed121f22b" + integrity sha512-90hiq6/VqtQgX8Sp0EzeIsv3r+ellbGj4URKj5j30tLlZvRUpnAe9YbYnjl3pJM93GyXU0tghHhvXHq+5rnCKA== + +"@types/node@^13.7.0": + version "13.13.9" + resolved "https://registry.yarnpkg.com/@types/node/-/node-13.13.9.tgz#79df4ae965fb76d31943b54a6419599307a21394" + integrity sha512-EPZBIGed5gNnfWCiwEIwTE2Jdg4813odnG8iNPMQGrqVxrI+wL68SPtPeCX+ZxGBaA6pKAVc6jaKgP/Q0QzfdQ== + +"@types/request@2.48.4": + version "2.48.4" + resolved "https://registry.yarnpkg.com/@types/request/-/request-2.48.4.tgz#df3d43d7b9ed3550feaa1286c6eabf0738e6cf7e" + integrity sha512-W1t1MTKYR8PxICH+A4HgEIPuAC3sbljoEVfyZbeFJJDbr30guDspJri2XOaM2E+Un7ZjrihaDi7cf6fPa2tbgw== + dependencies: + "@types/caseless" "*" + "@types/node" "*" + "@types/tough-cookie" "*" + form-data "^2.5.0" + +"@types/shortid@^0.0.29": + version "0.0.29" + resolved "https://registry.yarnpkg.com/@types/shortid/-/shortid-0.0.29.tgz#8093ee0416a6e2bf2aa6338109114b3fbffa0e9b" + integrity sha1-gJPuBBam4r8qpjOBCRFLP7/6Dps= + +"@types/tough-cookie@*": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.0.tgz#fef1904e4668b6e5ecee60c52cc6a078ffa6697d" + integrity sha512-I99sngh224D0M7XgW1s120zxCt3VYQ3IQsuw3P3jbq5GG4yc79+ZjyKznyOGIQrflfylLgcfekeZW/vk0yng6A== + +ajv@^6.5.5: + version "6.12.2" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.2.tgz#c629c5eced17baf314437918d2da88c99d5958cd" + integrity sha512-k+V+hzjm5q/Mr8ef/1Y9goCmlsK4I6Sm74teeyGvFk1XrOsbsKLjEdrvny42CZ+a8sXbk8KWpY/bDwS+FLL2UQ== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +asn1@~0.2.3: + version "0.2.4" + resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136" + integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg== + dependencies: + safer-buffer "~2.1.0" + +assert-plus@1.0.0, assert-plus@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" + integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU= + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= + +aws-sign2@~0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" + integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg= + +aws4@^1.8.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.10.0.tgz#a17b3a8ea811060e74d47d306122400ad4497ae2" + integrity sha512-3YDiu347mtVtjpyV3u5kVqQLP242c06zwDOgpeRnybmXlYYsLbtTrUBUm8i8srONt+FWobl5aibnU1030PeeuA== + +bcrypt-pbkdf@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" + integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4= + dependencies: + tweetnacl "^0.14.3" + +caseless@~0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" + integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= + +charenc@~0.0.1: + version "0.0.2" + resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667" + integrity sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc= + +combined-stream@^1.0.6, combined-stream@~1.0.6: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + +core-js@3.6.5: + version "3.6.5" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.6.5.tgz#7395dc273af37fb2e50e9bd3d9fe841285231d1a" + integrity sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA== + +core-util-is@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" + integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= + +crypt@~0.0.1: + version "0.0.2" + resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b" + integrity sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs= + +dashdash@^1.12.0: + version "1.14.1" + resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" + integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA= + dependencies: + assert-plus "^1.0.0" + +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= + +dom-storage@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/dom-storage/-/dom-storage-2.1.0.tgz#00fb868bc9201357ea243c7bcfd3304c1e34ea39" + integrity sha512-g6RpyWXzl0RR6OTElHKBl7nwnK87GUyZMYC7JWsB/IA73vpqK2K6LT39x4VepLxlSsWBFrPVLnsSR5Jyty0+2Q== + +ecc-jsbn@~0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" + integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk= + dependencies: + jsbn "~0.1.0" + safer-buffer "^2.1.0" + +encoding@^0.1.11: + version "0.1.12" + resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.12.tgz#538b66f3ee62cd1ab51ec323829d1f9480c74beb" + integrity sha1-U4tm8+5izRq1HsMjgp0flIDHS+s= + dependencies: + iconv-lite "~0.4.13" + +extend@~3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== + +extsprintf@1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" + integrity sha1-lpGEQOMEGnpBT4xS48V06zw+HgU= + +extsprintf@^1.2.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" + integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8= + +fast-deep-equal@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz#545145077c501491e33b15ec408c294376e94ae4" + integrity sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA== + +fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +faye-websocket@0.11.3: + version "0.11.3" + resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.3.tgz#5c0e9a8968e8912c286639fde977a8b209f2508e" + integrity sha512-D2y4bovYpzziGgbHYtGCMjlJM36vAl/y+xUyn1C+FVx8szd1E+86KwVw6XvYSzOP8iMpm1X0I4xJD+QtUb36OA== + dependencies: + websocket-driver ">=0.5.1" + +firebase@7.14.5, firebase@^7.14.5: + version "7.14.5" + resolved "https://registry.yarnpkg.com/firebase/-/firebase-7.14.5.tgz#cf1be9c7f0603c6c2f45f65c7d817f6b22114a4b" + integrity sha512-1vrC1UZIVhaT7owaElQoEseP81xqRt6tHQmxRJRojn0yI3JNXrdWCFsD+26xA1eQQCwodJuMsYJLzQSScgjHuQ== + dependencies: + "@firebase/analytics" "0.3.5" + "@firebase/app" "0.6.4" + "@firebase/app-types" "0.6.1" + "@firebase/auth" "0.14.6" + "@firebase/database" "0.6.3" + "@firebase/firestore" "1.14.5" + "@firebase/functions" "0.4.44" + "@firebase/installations" "0.4.10" + "@firebase/messaging" "0.6.16" + "@firebase/performance" "0.3.4" + "@firebase/polyfill" "0.3.36" + "@firebase/remote-config" "0.1.21" + "@firebase/storage" "0.3.34" + "@firebase/util" "0.2.47" + +forever-agent@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" + integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= + +form-data@^2.5.0: + version "2.5.1" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.5.1.tgz#f2cbec57b5e59e23716e128fe44d4e5dd23895f4" + integrity sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.6" + mime-types "^2.1.12" + +form-data@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" + integrity sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.6" + mime-types "^2.1.12" + +getpass@^0.1.1: + version "0.1.7" + resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" + integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo= + dependencies: + assert-plus "^1.0.0" + +har-schema@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" + integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI= + +har-validator@~5.1.3: + version "5.1.3" + resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.3.tgz#1ef89ebd3e4996557675eed9893110dc350fa080" + integrity sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g== + dependencies: + ajv "^6.5.5" + har-schema "^2.0.0" + +http-parser-js@>=0.5.1: + version "0.5.2" + resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.2.tgz#da2e31d237b393aae72ace43882dd7e270a8ff77" + integrity sha512-opCO9ASqg5Wy2FNo7A0sxy71yGbbkJJXLdgMK04Tcypw9jr2MgWbyubb0+WdmDmGnFflO7fRbqbaihh/ENDlRQ== + +http-signature@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" + integrity sha1-muzZJRFHcvPZW2WmCruPfBj7rOE= + dependencies: + assert-plus "^1.0.0" + jsprim "^1.2.2" + sshpk "^1.7.0" + +iconv-lite@~0.4.13: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +idb@3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/idb/-/idb-3.0.2.tgz#c8e9122d5ddd40f13b60ae665e4862f8b13fa384" + integrity sha512-+FLa/0sTXqyux0o6C+i2lOR0VoS60LU/jzUo5xjfY6+7sEEgy4Gz1O7yFBXvjd7N0NyIGWIRg8DcQSLEG+VSPw== + +is-buffer@~1.1.1: + version "1.1.6" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" + integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== + +is-stream@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" + integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= + +is-typedarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" + integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= + +isomorphic-fetch@2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz#611ae1acf14f5e81f729507472819fe9733558a9" + integrity sha1-YRrhrPFPXoH3KVB0coGf6XM1WKk= + dependencies: + node-fetch "^1.0.1" + whatwg-fetch ">=0.10.0" + +isstream@~0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" + integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= + +jsbn@~0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" + integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-schema@0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" + integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM= + +json-stable-stringify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz#9a759d39c5f2ff503fd5300646ed445f88c4f9af" + integrity sha1-mnWdOcXy/1A/1TAGRu1EX4jE+a8= + dependencies: + jsonify "~0.0.0" + +json-stringify-safe@~5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" + integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= + +jsonify@~0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73" + integrity sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM= + +jsprim@^1.2.2: + version "1.4.1" + resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" + integrity sha1-MT5mvB5cwG5Di8G3SZwuXFastqI= + dependencies: + assert-plus "1.0.0" + extsprintf "1.3.0" + json-schema "0.2.3" + verror "1.10.0" + +lodash.camelcase@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" + integrity sha1-soqmKIorn8ZRA1x3EfZathkDMaY= + +lodash@^4.17.19: + version "4.17.19" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b" + integrity sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ== + +long@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28" + integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA== + +md5@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/md5/-/md5-2.2.1.tgz#53ab38d5fe3c8891ba465329ea23fac0540126f9" + integrity sha1-U6s41f48iJG6RlMp6iP6wFQBJvk= + dependencies: + charenc "~0.0.1" + crypt "~0.0.1" + is-buffer "~1.1.1" + +mime-db@1.44.0: + version "1.44.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.44.0.tgz#fa11c5eb0aca1334b4233cb4d52f10c5a6272f92" + integrity sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg== + +mime-types@^2.1.12, mime-types@~2.1.19: + version "2.1.27" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.27.tgz#47949f98e279ea53119f5722e0f34e529bec009f" + integrity sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w== + dependencies: + mime-db "1.44.0" + +nano-test-runner@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/nano-test-runner/-/nano-test-runner-1.2.0.tgz#fe571f1f0287310a18a2b23af2e9915a2a0b5f39" + integrity sha512-/kEFiYv2aLtlG3SoNrJVv0uW7Y8XCzJ6dSkwcZbCEShS+dGzLXp2PJCkVlJw2AtjG8FoVflREGB8JvBvYDHviQ== + +nanoid@^2.1.0: + version "2.1.11" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-2.1.11.tgz#ec24b8a758d591561531b4176a01e3ab4f0f0280" + integrity sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA== + +node-fetch@^1.0.1: + version "1.7.3" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef" + integrity sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ== + dependencies: + encoding "^0.1.11" + is-stream "^1.0.1" + +oauth-sign@~0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" + integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== + +performance-now@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" + integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= + +prettier@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.0.5.tgz#d6d56282455243f2f92cc1716692c08aa31522d4" + integrity sha512-7PtVymN48hGcO4fGjybyBSIWDsLU4H4XlvOHfq91pz9kkGlonzwTfYkaIEwiRg/dAJF9YlbsduBAgtYLi+8cFg== + +promise-polyfill@8.1.3: + version "8.1.3" + resolved "https://registry.yarnpkg.com/promise-polyfill/-/promise-polyfill-8.1.3.tgz#8c99b3cf53f3a91c68226ffde7bde81d7f904116" + integrity sha512-MG5r82wBzh7pSKDRa9y+vllNHz3e3d4CNj1PQE4BQYxLme0gKYYBm9YENq+UkEikyZ0XbiGWxYlVw3Rl9O/U8g== + +protobufjs@^6.8.6: + version "6.9.0" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-6.9.0.tgz#c08b2bf636682598e6fabbf0edb0b1256ff090bd" + integrity sha512-LlGVfEWDXoI/STstRDdZZKb/qusoAWUnmLg9R8OLSO473mBLWHowx8clbX5/+mKDEI+v7GzjoK9tRPZMMcoTrg== + dependencies: + "@protobufjs/aspromise" "^1.1.2" + "@protobufjs/base64" "^1.1.2" + "@protobufjs/codegen" "^2.0.4" + "@protobufjs/eventemitter" "^1.1.0" + "@protobufjs/fetch" "^1.1.0" + "@protobufjs/float" "^1.0.2" + "@protobufjs/inquire" "^1.1.0" + "@protobufjs/path" "^1.1.2" + "@protobufjs/pool" "^1.1.0" + "@protobufjs/utf8" "^1.1.0" + "@types/long" "^4.0.1" + "@types/node" "^13.7.0" + long "^4.0.0" + +psl@^1.1.28: + version "1.8.0" + resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24" + integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ== + +punycode@^2.1.0, punycode@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" + integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== + +qs@~6.5.2: + version "6.5.2" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" + integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== + +request@2.88.2: + version "2.88.2" + resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" + integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw== + dependencies: + aws-sign2 "~0.7.0" + aws4 "^1.8.0" + caseless "~0.12.0" + combined-stream "~1.0.6" + extend "~3.0.2" + forever-agent "~0.6.1" + form-data "~2.3.2" + har-validator "~5.1.3" + http-signature "~1.2.0" + is-typedarray "~1.0.0" + isstream "~0.1.2" + json-stringify-safe "~5.0.1" + mime-types "~2.1.19" + oauth-sign "~0.9.0" + performance-now "^2.1.0" + qs "~6.5.2" + safe-buffer "^5.1.2" + tough-cookie "~2.5.0" + tunnel-agent "^0.6.0" + uuid "^3.3.2" + +safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.2: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +semver@^6.2.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" + integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== + +shortid@^2.2.15: + version "2.2.15" + resolved "https://registry.yarnpkg.com/shortid/-/shortid-2.2.15.tgz#2b902eaa93a69b11120373cd42a1f1fe4437c122" + integrity sha512-5EaCy2mx2Jgc/Fdn9uuDuNIIfWBpzY4XIlhoqtXF6qsf+/+SGZ+FxDdX/ZsMZiWupIWNqAEmiNY4RC+LSmCeOw== + dependencies: + nanoid "^2.1.0" + +sshpk@^1.7.0: + version "1.16.1" + resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877" + integrity sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg== + dependencies: + asn1 "~0.2.3" + assert-plus "^1.0.0" + bcrypt-pbkdf "^1.0.0" + dashdash "^1.12.0" + ecc-jsbn "~0.1.1" + getpass "^0.1.1" + jsbn "~0.1.0" + safer-buffer "^2.0.2" + tweetnacl "~0.14.0" + +tough-cookie@~2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" + integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g== + dependencies: + psl "^1.1.28" + punycode "^2.1.1" + +tslib@1.11.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.11.1.tgz#eb15d128827fbee2841549e171f45ed338ac7e35" + integrity sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA== + +tunnel-agent@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" + integrity sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0= + dependencies: + safe-buffer "^5.0.1" + +tweetnacl@^0.14.3, tweetnacl@~0.14.0: + version "0.14.5" + resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" + integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q= + +typescript@^3.9.3: + version "3.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.3.tgz#d3ac8883a97c26139e42df5e93eeece33d610b8a" + integrity sha512-D/wqnB2xzNFIcoBG9FG8cXRDjiqSTbG2wd8DMZeQyJlP1vfTkIxH4GKveWaEBYySKIg+USu+E+EDIR47SqnaMQ== + +uri-js@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0" + integrity sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ== + dependencies: + punycode "^2.1.0" + +uuid@^3.3.2: + version "3.4.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" + integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== + +verror@1.10.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" + integrity sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA= + dependencies: + assert-plus "^1.0.0" + core-util-is "1.0.2" + extsprintf "^1.2.0" + +websocket-driver@>=0.5.1: + version "0.7.4" + resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.4.tgz#89ad5295bbf64b480abcba31e4953aca706f5760" + integrity sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg== + dependencies: + http-parser-js ">=0.5.1" + safe-buffer ">=5.1.0" + websocket-extensions ">=0.1.1" + +websocket-extensions@>=0.1.1: + version "0.1.3" + resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.3.tgz#5d2ff22977003ec687a4b87073dfbbac146ccf29" + integrity sha512-nqHUnMXmBzT0w570r2JpJxfiSD1IzoI+HGVdd3aZ0yNi3ngvQ4jv1dtHt5VGxfI2yj5yqImPhOK4vmIh2xMbGg== + +whatwg-fetch@2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz#dde6a5df315f9d39991aa17621853d720b85566f" + integrity sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng== + +whatwg-fetch@>=0.10.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz#fc804e458cc460009b1a2b966bc8817d2578aefb" + integrity sha512-9GSJUgz1D4MfyKU7KRqwOjXCXTqWdFNvEr7eUBYchQiVc744mqK/MzXPNR2WsPkmkOa4ywfg8C2n8h+13Bey1Q== + +xmlhttprequest@1.8.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/xmlhttprequest/-/xmlhttprequest-1.8.0.tgz#67fe075c5c24fef39f9d65f5f7b7fe75171968fc" + integrity sha1-Z/4HXFwk/vOfnWX197f+dRcZaPw=