UNPKG

@taraai/read-write

Version:

Synchronous NoSQL/Firestore for React

332 lines (280 loc) 9.51 kB
import chunk from 'lodash/chunk'; import cloneDeep from 'lodash/cloneDeep'; import flatten from 'lodash/flatten'; import isFunction from 'lodash/isFunction'; import isPlainObject from 'lodash/isPlainObject'; import mapValues from 'lodash/mapValues'; import isEmpty from 'lodash/isEmpty'; import has from 'lodash/has'; import debug from 'debug'; import { firestoreRef } from './query'; import mark from './profiling'; import { increment as incrementFS, arrayUnion as arrayUnionFS, arrayRemove as arrayRemoveFS, serverTimestamp as serverTimestampFS, deleteField as deleteFieldFS, } from 'firebase/firestore'; const info = debug('readwrite:mutate'); const docRef = (firestore, collection, doc) => firestore.doc(`${collection}/${doc}`); const promiseAllObject = async (object) => Object.fromEntries( await Promise.all( Object.entries(object).map(([key, promise]) => promise.then((value) => [key, value]), ), ), ); const isAsync = (fnc) => fnc.constructor.name === 'AsyncFunction'; const isBatchedWrite = (operations) => Array.isArray(operations); export const isDocRead = ({ doc, id } = {}) => typeof id === 'string' || typeof doc === 'string'; export const isProviderRead = (read) => has(read, '::w3Provider') || isFunction(read); export const getRead = (read) => { if (has(read, '::w3Provider')) return read['::w3Provider']; if (isFunction(read)) return read(); return read; }; const isSingleWrite = ({ collection, path } = {}) => typeof path === 'string' || typeof collection === 'string'; const hasNothing = (snapshot) => !snapshot || (has(snapshot, 'empty') && snapshot.empty()) || (has(snapshot, 'exists') && snapshot.exists); // ----- FieldValue support ----- const primaryValue = (arr) => Array.isArray(arr) && typeof arr[0] === 'string' && arr[0].indexOf('::') === 0 ? null : arr; const arrayUnion = (firebase, key, ...val) => { if (key !== '::arrayUnion') return null; return arrayUnionFS(...val); }; const arrayRemove = (firebase, key, ...val) => { if (key !== '::arrayRemove') return null; return arrayRemoveFS(...val); }; const increment = (firebase, key, val) => key === '::increment' && typeof val === 'number' && incrementFS(val); const deleteField = (firebase, key) => key === '::delete' && deleteFieldFS(); const serverTimestamp = (firebase, key) => key === '::serverTimestamp' && serverTimestampFS(); /** * Process Mutation to a vanilla JSON * @param {object} firebase - firebase * @param {*} operation - payload mutation * @returns */ function atomize(firebase, operation) { let requiresUpdate = false; return [ Object.keys(operation).reduce((data, key) => { const clone = { ...data }; const val = clone[key]; if (key.includes('.')) { requiresUpdate = true; } if (!val) return clone; const value = primaryValue(val) || serverTimestamp(firebase, val[0]) || arrayUnion(firebase, val[0], val[1]) || arrayRemove(firebase, val[0], val[1]) || increment(firebase, val[0], val[1]) || deleteField(firebase, val[0], val[1]); if (Array.isArray(val) && val.length > 0 && isPlainObject(val[0])) { clone[key] = val.map((obj) => { const [object, update] = atomize(firebase, obj); if (update) requiresUpdate = true; return object; }); } else if (Array.isArray(val) && val.length > 0) { // eslint-disable-next-line no-param-reassign clone[key] = value; } else if (isPlainObject(val)) { const [object, update] = atomize(firebase, val); clone[key] = object; if (update) requiresUpdate = true; } return clone; }, cloneDeep(operation)), requiresUpdate, ]; } // ----- write functions ----- /** * For details between set & udpate see: * https://firebase.google.com/docs/reference/js/firebase.firestore.Transaction#update * @param {object} firebase * @param {Mutation_v1 | Mutation_v2} operation * @param {Batch | Transaction} writer * @returns {Promise | Doc} - Batch & Transaction .set/update change internal state & returns null */ function write(firebase, operation = {}, writer = null) { if (isEmpty(operation)) return Promise.resolve(); const { collection, path, doc, id, data, ...rest } = operation; const ref = docRef(firebase.firestore(), path || collection, id || doc); const [changes, requiresUpdate = false] = atomize(firebase, data || rest); if (writer) { const writeType = writer.commit ? 'Batching' : 'Transaction.set'; if (info.enabled) { /* istanbul ignore next */ info( writeType, JSON.stringify( { id: ref.id, path: ref.parent.path, ...changes }, null, 2, ), ); } if (requiresUpdate) { writer.update(ref, changes); } else { writer.set(ref, changes, { merge: true }); } return { id: ref.id, path: ref.parent.path, ...changes }; } if (info.enabled) { /* istanbul ignore next */ info( 'Writing', JSON.stringify( { id: ref.id, path: ref.parent.path, ...changes }, null, 2, ), ); } if (requiresUpdate) { return ref.update(changes); } return ref.set(changes, { merge: true }); } /** * @param {object} firebase * @param {object} operations * @returns {Promise} */ function writeSingle(firebase, operations) { const done = mark('mutate.writeSingle'); const promise = write(firebase, operations); done(); return promise; } const MAX_BATCH_COUNT = 500; /** * @param {object} firebase * @param {object} operations * @returns {Promise} */ async function writeInBatch(firebase, operations) { const done = mark('mutate.writeInBatch'); const committedBatchesPromised = chunk(operations, MAX_BATCH_COUNT).map( (operationsChunk) => { const batch = firebase.firestore().batch(); const writesBatched = operationsChunk.map((operation) => write(firebase, operation, batch), ); return batch.commit().then(() => writesBatched); }, ); done(); return Promise.all(committedBatchesPromised).then(flatten); } /** * @param {object} firebase * @param {object} operations * @returns {Promise} */ async function writeInTransaction(firebase, operations) { return firebase.firestore().runTransaction(async (transaction) => { const serialize = (doc) => !doc ? null : { ...doc.data(), id: doc.ref.id, path: doc.ref.parent.path }; const getter = (ref) => { if (info.enabled) { /* istanbul ignore next */ info('Transaction.get ', { id: ref.id, path: ref.parent.path }); } return transaction.get(ref); }; const done = mark('mutate.writeInTransaction:reads'); const readsPromised = mapValues(operations.reads, async (read) => { if (isProviderRead(read)) { return getRead(read); } if (isDocRead(read)) { const doc = firestoreRef(firebase, read); const snapshot = await getter(doc); return serialize(hasNothing(snapshot) ? null : snapshot); } // else query const query = firestoreRef(firebase, read); // (As of 7/2021, client-side Firestore doesn't include queries in transactions) const nonTransactionQuery = await query.get(); if ( hasNothing(nonTransactionQuery) || nonTransactionQuery.docs.length === 0 ) return []; // followed by transactional get on each document in the result const transactionDocs = await Promise.all( nonTransactionQuery.docs.map(({ ref }) => getter(ref)), ); return transactionDocs.map(serialize); }); done(); const reads = await promiseAllObject(readsPromised); const writes = []; operations.writes.forEach((writeFnc) => { if (isAsync(writeFnc)) throw new Error('Writes must be synchronous, unary functions.'); const complete = mark('mutate.writeInTransaction:writes'); const operation = typeof writeFnc === 'function' ? writeFnc(reads) : writeFnc; if (Array.isArray(operation)) { operation.map((op) => write(firebase, op, transaction)); writes.push(operation); } else { writes.push(write(firebase, operation, transaction)); } complete(); }); // Firestore Transaction return null. // Instead we'll return the results of all read data & writes. return { reads, writes }; }); } export function convertReadProviders(mutations) { const shouldMakeProvidesIdempotent = mutations && mutations.reads; if (!shouldMakeProvidesIdempotent) return; Object.keys(mutations.reads).forEach((key) => { const read = mutations.reads[key]; const isReadProvider = isFunction(read); if (isAsync(read)) throw new Error('Read Providers must be synchronous, nullary functions.'); mutations.reads[key] = isReadProvider ? { '::w3Provider': read() } : read; }, mutations); } /** * @public * Write any data to Firestore. * @param {object} firestore * @param {object} operations * @returns {Promise} */ export default function mutate(firestore, operations) { if (isSingleWrite(operations)) { return writeSingle(firestore, operations); } if (isBatchedWrite(operations)) { return writeInBatch(firestore, operations); } convertReadProviders(operations); return writeInTransaction(firestore, operations); }