UNPKG

abstracted-firebase

Version:

Core functional library supporting 'abstracted-admin' and 'abstracted-client'

515 lines (514 loc) 19.7 kB
// tslint:disable: member-ordering // tslint:disable:no-implicit-dependencies import { wait } from "common-types"; import * as convert from "typed-conversions"; import { SerializedQuery } from "serialized-query"; import { slashNotation } from "./util"; import { FileDepthExceeded } from "./errors/FileDepthExceeded"; import { UndefinedAssignment } from "./errors/UndefinedAssignment"; import { WatcherEventWrapper } from "./WatcherEventWrapper"; import { PermissionDenied } from "./errors"; import { AbstractedProxyError } from "./errors/AbstractedProxyError"; import { isMockConfig } from "."; import { AbstractedError } from "./errors/AbstractedError"; /** time by which the dynamically loaded mock library should be loaded */ export const MOCK_LOADING_TIMEOUT = 2000; export class RealTimeDB { constructor(config = {}) { this._isAdminApi = false; /** how many miliseconds before the attempt to connect to DB is timed out */ this.CONNECTION_TIMEOUT = 5000; this._isConnected = false; this._mockLoadingState = "not-applicable"; this._waitingForConnection = []; this._debugging = false; this._mocking = false; this._allowMocking = false; this._onConnected = []; this._onDisconnected = []; this._config = config; if (config.timeout) { this.CONNECTION_TIMEOUT = config.timeout; } } get isMockDb() { return this._mocking; } get isAdminApi() { return this._isAdminApi; } /** * **getPushKey** * * Get's a push-key from the server at a given path. This ensures that multiple * client's who are writing to the database will use the server's time rather than * their own local time. * * @param path the path in the database where the push-key will be pushed to */ async getPushKey(path) { const key = await this.ref(path).push().key; return key; } get mock() { if (!this._mocking && !this._allowMocking) { const e = new Error("You can not mock the database without setting mocking in the constructor"); e.name = "AbstractedFirebase::NotAllowed"; throw e; } if (this._mockLoadingState === "loading") { const e = new Error(`Loading the mock library is an asynchronous task; typically it takes very little time but it is currently in process. You can listen to "waitForConnection()" to ensure the mock library is ready.`); e.name = "AbstractedFirebase::AsyncError"; throw e; } if (!this._mock) { const e = new Error(`Attempting to reference mock() on DB but _mock is not set [ mocking: ${this._mocking} ]!`); e.name = "AbstractedFirebase::NotAllowed"; throw e; } return this._mock; } get isConnected() { return this._isConnected; } get config() { return this._config; } /** * called by `client` and `admin` at end of constructor */ initialize(config = {}) { this._mocking = config.mocking ? true : false; this.connectToFirebase(config).then(() => this.listenForConnectionStatus()); } /** * watch * * Watch for firebase events based on a DB path or `SerializedQuery` (path plus query elements) * * @param target a database path or a SerializedQuery * @param events an event type or an array of event types (e.g., "value", "child_added") * @param cb the callback function to call when event triggered */ watch(target, events, cb) { if (!Array.isArray(events)) { events = [events]; } try { events.map((evt) => { const dispatch = WatcherEventWrapper({ eventType: evt, targetType: "path", })(cb); if (typeof target === "string") { this.ref(slashNotation(target)).on(evt, dispatch); } else { target.setDB(this).deserialize(this).on(evt, dispatch); } }); } catch (e) { console.warn(`abstracted-firebase: failure trying to watch event ${JSON.stringify(events)}`); throw new AbstractedProxyError(e); } } unWatch(events, cb) { try { if (!Array.isArray(events)) { events = [events]; } if (!events) { this.ref().off(); return; } events.map((evt) => { if (cb) { this.ref().off(evt, cb); } else { this.ref().off(evt); } }); } catch (e) { e.name = e.code.includes("abstracted-firebase") ? "AbstractedFirebase" : e.code; e.code = "abstracted-firebase/unWatch"; throw e; } } /** * Get a Firebase SerializedQuery reference * * @param path path for query */ query(path) { return SerializedQuery.path(path); } /** Get a DB reference for a given path in Firebase */ ref(path = "/") { return this._mocking ? this.mock.ref(path) : this._database.ref(path); } /** * Provides a promise-based way of waiting for the connection to be * established before resolving */ async waitForConnection() { const config = this._config; if (isMockConfig(config)) { // MOCKING await this.getFireMock({ db: config.mockData, auth: config.mockAuth }); } else { // NON-MOCKING if (this._isConnected) { return; } const connectionEvent = () => { try { return new Promise((resolve, reject) => { this._eventManager.once("connection", (state) => { if (state) { resolve(); } else { reject(new AbstractedError(`While waiting for a connection received a disconnect message instead`, `no-connection`)); } }); }); } catch (e) { throw e; } }; const timeout = async () => { await wait(this.CONNECTION_TIMEOUT); throw new AbstractedError(`The database didn't connect after the allocated period of ${this.CONNECTION_TIMEOUT}ms`, "connection-timeout"); }; await Promise.race([connectionEvent(), timeout()]); this._isConnected = true; return this; } this._onConnected.map((i) => i.cb(this, i.ctx)); } /** * get a notification when DB is connected; returns a unique id * which can be used to remove the callback. You may, optionally, * state a unique id of your own. * * By default the callback will receive the database connection as it's * `this`/context. This means that any locally defined variables will be * dereferenced an unavailable. If you want to retain a connection to this * state you should include the optional _context_ parameter and your * callback will get a parameter passed back with this context available. */ notifyWhenConnected(cb, id, /** * additional context/pointers for your callback to use when activated */ ctx) { if (!id) { id = Math.random().toString(36).substr(2, 10); } else { if (this._onConnected.map((i) => i.id).includes(id)) { throw new AbstractedError(`Request for onConnect() notifications was done with an explicit key [ ${id} ] which is already in use!`, `duplicate-listener`); } } this._onConnected = this._onConnected.concat({ id, cb, ctx }); return id; } /** * removes a callback notification previously registered */ removeNotificationOnConnection(id) { this._onConnected = this._onConnected.filter((i) => i.id !== id); return this; } /** set a "value" in the database at a given path */ async set(path, value) { // return new Promise((resolve, reject)) try { const results = await this.ref(path).set(value); } catch (e) { if (e.code === "PERMISSION_DENIED") { throw new PermissionDenied(e, `The attempt to set a value at path "${path}" failed due to incorrect permissions.`); } if (e.message.indexOf("path specified exceeds the maximum depth that can be written") !== -1) { throw new FileDepthExceeded(e); } if (e.message.indexOf("First argument includes undefined in property") !== -1) { e.name = "FirebaseUndefinedValueAssignment"; throw new UndefinedAssignment(e); } throw new AbstractedProxyError(e, "unknown", JSON.stringify({ path, value })); } } /** * **multiPathSet** * * Equivalent to Firebase's traditional "multi-path updates" which are * in behaviour are really "multi-path SETs". The basic idea is that * all the _keys_ are database paths and the _values_ are **destructive** values. * * An example of * what you might might look like: * * ```json * { * "path/to/my/data": "my destructive data", * "another/path/to/write/to": "everyone loves monkeys" * } * ``` * * When we say "destructive" we mean that whatever value you put at the give path will * _overwrite_ the data that was there rather than "update" it. This not hard to * understand because we've given this function a name with "SET" in the name but * in the real-time database this actual translates into an alternative use of the * "update" command which is described here: * [Introducing Multi-Location Updates.](https://firebase.googleblog.com/2015/09/introducing-multi-location-updates-and_86.html) * * This functionality, in the end, is SUPER useful as it provides a means to achieve * transactional functionality (aka, either all paths are written to or none are). * * **Note:** because _dot notation_ for paths is not uncommon you can notate * the paths with `.` instead of `/` */ async multiPathSet(updates) { const fixed = Object.keys(updates).reduce((acc, path) => { const slashPath = path.replace(/\./g, "/").slice(0, 1) === "/" ? path.replace(/\./g, "/") : "/" + path.replace(/\./g, "/"); acc[slashPath] = updates[path]; return acc; }, {}); await this.ref("/").update(fixed); } /** * **update** * * Update the database at a given path. Note that this operation is * **non-destructive**, so assuming that the value you are passing in * a POJO/object then the properties sent in will be updated but if * properties that exist in the DB, but not in the value passed in, * then these properties will _not_ be changed. * * [API Docs](https://firebase.google.com/docs/reference/js/firebase.database.Reference#update) */ async update(path, value) { try { await this.ref(path).update(value); } catch (e) { if (e.code === "PERMISSION_DENIED") { throw new PermissionDenied(e, `The attempt to update a value at path "${path}" failed due to incorrect permissions.`); } else { throw new AbstractedProxyError(e, undefined, `While updating the path "${path}", an error occurred`); } } } /** * **remove** * * Removes a path from the database. By default if you attempt to * remove a path in the database which _didn't_ exist it will throw * a `abstracted-firebase/remove` error. If you'd prefer for this * error to be ignored than you can pass in **true** to the `ignoreMissing` * parameter. * * [API Docs](https://firebase.google.com/docs/reference/js/firebase.database.Reference#remove) */ async remove(path, ignoreMissing = false) { const ref = this.ref(path); try { const result = await ref.remove(); return result; } catch (e) { if (e.code === "PERMISSION_DENIED") { throw new PermissionDenied(e, `The attempt to remove a value at path "${path}" failed due to incorrect permissions.`); } else { throw new AbstractedProxyError(e, undefined, `While removing the path "${path}", an error occurred`); } } } /** * **getSnapshot** * * returns the Firebase snapshot at a given path in the database */ async getSnapshot(path) { try { const response = await (typeof path === "string" ? this.ref(slashNotation(path)).once("value") : path.setDB(this).execute()); return response; } catch (e) { console.warn(`There was a problem trying to get a snapshot from the database [ path parameter was of type "${typeof path}", fn: "getSnapshot()" ]:`, e.message); throw new AbstractedProxyError(e); } } /** * **getValue** * * Returns the JS value at a given path in the database. This method is a * typescript _generic_ which defaults to `any` but you can set the type to * whatever value you expect at that path in the database. */ async getValue(path) { try { const snap = await this.getSnapshot(path); return snap.val(); } catch (e) { throw new AbstractedProxyError(e); } } /** * **getRecord** * * Gets a snapshot from a given path in the Firebase DB * and converts it to a JS object where the snapshot's key * is included as part of the record (as `id` by default) */ async getRecord(path, idProp = "id") { try { const snap = await this.getSnapshot(path); let object = snap.val(); if (typeof object !== "object") { object = { value: snap.val() }; } return Object.assign(Object.assign({}, object), { [idProp]: snap.key }); } catch (e) { throw new AbstractedProxyError(e); } } /** * **getList** * * Get a list of a given type (defaults to _any_). Assumes that the * "key" for the record is the `id` property but that can be changed * with the optional `idProp` parameter. * * @param path the path in the database to * @param idProp */ async getList(path, idProp = "id") { try { const snap = await this.getSnapshot(path); return snap.val() ? convert.snapshotToArray(snap, idProp) : []; } catch (e) { throw new AbstractedProxyError(e); } } /** * **getSortedList** * * getSortedList() will return the sorting order that was defined in the Firebase * Query. This _can_ be useful but often the sort orders * really intended for the server only (so that filteration * is done on the right set of data before sending to client). * * @param query Firebase "query ref" * @param idProp what property name should the Firebase key be converted to (default is "id") */ async getSortedList(query, idProp = "id") { try { return this.getSnapshot(query).then((snap) => { return convert.snapshotToArray(snap, idProp); }); } catch (e) { throw new AbstractedProxyError(e); } } /** * **push** * * Pushes a value (typically a hash) under a given path in the * database but allowing Firebase to insert a unique "push key" * to ensure the value is placed into a Dictionary/Hash structure * of the form of `/{path}/{pushkey}/{value}` * * Note, the pushkey will be generated on the Firebase side and * Firebase keys are guarenteed to be unique and embedded into the * UUID is precise time-based information so you _can_ count on * the keys to have a natural time based sort order. */ async push(path, value) { try { this.ref(path).push(value); } catch (e) { if (e.code === "PERMISSION_DENIED") { throw new PermissionDenied(e, `The attempt to push a value to path "${path}" failed due to incorrect permissions.`); } else { throw new AbstractedProxyError(e, undefined, `While pushing to the path "${path}", an error occurred`); } } } /** * **exists** * * Validates the existance of a path in the database */ async exists(path) { return this.getSnapshot(path).then((snap) => (snap.val() ? true : false)); } /** * monitorConnection * * allows interested parties to hook into event messages when the * DB connection either connects or disconnects */ _monitorConnection(snap) { this._isConnected = snap.val(); // call active listeners if (this._isConnected) { if (this._eventManager.connection) { this._eventManager.connection(this._isConnected); } this._onConnected.forEach((listener) => listener.ctx ? listener.cb.bind(listener.ctx)(this) : listener.cb.bind(this)()); } else { this._onDisconnected.forEach((listener) => listener.cb(this)); } } /** * When using the **Firebase** Authentication solution, the primary API * resides off the `db.auth()` call but each _provider_ also has an API * that can be useful and this has links to various providers. */ get authProviders() { throw new Error(`The authProviders getter is intended to provide access to various auth providers but it is NOT implemented in the connection library you are using!`); } /** * **getFireMock** * * Asynchronously imports both `FireMock` and the `Faker` libraries * then sets `isConnected` to **true** */ async getFireMock(config = {}) { try { this._mocking = true; this._mockLoadingState = "loading"; const FireMock = await import(/* webpackChunkName: "firemock" */ "firemock"); this._mockLoadingState = "loaded"; try { this._mock = await FireMock.Mock.prepare(config); } catch (e) { console.info("There was an error trying to produce a mock object but because this requires the Faker library there are reasonable use cases where this may have been intentionally blocked\n\n", e.message); } this._isConnected = true; } catch (e) { throw new AbstractedProxyError(e, "abstracted-firebase/firemock-load-failure", `Failed to load the FireMock library asynchronously. The config passed in was ${JSON.stringify(config, null, 2)}`); } } }