ran-boilerplate
Version:
React . Apollo (GraphQL) . Next.js Toolkit
632 lines (554 loc) • 18.3 kB
JavaScript
/*!
* Copyright 2017 Google Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict';
const assert = require('assert');
const is = require('is');
/*!
* Injected.
*
* @see DocumentSnapshot
*/
let DocumentSnapshot;
/*!
* Injected.
*
* @see DocumentMask
*/
let DocumentMask;
/*!
* Injected.
*
* @see DocumentTransform
*/
let DocumentTransform;
/*
* @see FieldPath
*/
const FieldPath = require('./path').FieldPath;
/*!
* Injected.
*
* @see Firestore
*/
let Firestore;
/*!
* Injected.
*
* @see Precondition
*/
let Precondition;
/*! Injected. */
let validate;
/*!
* Google Cloud Functions terminates idle connections after two minutes. After
* longer periods of idleness, we issue transactional commits to allow for
* retries.
*
* @type {number}
*/
const GCF_IDLE_TIMEOUT_MS = 110 * 1000;
/**
* A WriteResult wraps the write time set by the Firestore servers on sets(),
* updates(), and creates().
*
* @class
*/
class WriteResult {
/**
* @private
* @hideconstructor
*
* @param {string} writeTime - The ISO 8601 write time.
*/
constructor(writeTime) {
this._writeTime = writeTime;
}
/**
* The write time as set by the Firestore servers. Formatted as an ISO-8601
* string.
*
* @type {string}
* @name WriteResult#writeTime
* @readonly
*
* @example
* let documentRef = firestore.doc('col/doc');
*
* documentRef.set({foo: 'bar'}).then(writeResult => {
* console.log(`Document written at: ${writeResult.writeTime}`);
* });
*/
get writeTime() {
return this._writeTime;
}
}
/**
* A Firestore WriteBatch that can be used to atomically commit multiple write
* operations at once.
*
* @class
*/
class WriteBatch {
/**
* @private
* @hideconstructor
*
* @param {Firestore} firestore - The Firestore Database client.
*/
constructor(firestore) {
this._firestore = firestore;
this._api = firestore.api;
this._writes = [];
this._committed = false;
}
/**
* Checks if this write batch has any pending operations.
*
* @private
* @returns {boolean}
*/
get isEmpty() {
return this._writes.length === 0;
}
/**
* Throws an error if this batch has already been committed.
*
* @private
*/
verifyNotCommitted() {
if (this._committed) {
throw new Error('Cannot modify a WriteBatch that has been committed.');
}
}
/**
* Create a document with the provided object values. This will fail the batch
* if a document exists at its location.
*
* @param {DocumentReference} documentRef - A reference to the
* document to be created.
* @param {DocumentData} data - The object to serialize as the document.
* @returns {WriteBatch} This WriteBatch instance. Used for chaining
* method calls.
*
* @example
* let writeBatch = firestore.batch();
* let documentRef = firestore.collection('col').doc();
*
* writeBatch.create(documentRef, {foo: 'bar'});
*
* writeBatch.commit().then(() => {
* console.log('Successfully executed batch.');
* });
*/
create(documentRef, data) {
validate.isDocumentReference('documentRef', documentRef);
validate.isDocument('data', data);
this.verifyNotCommitted();
const document = DocumentSnapshot.fromObject(documentRef, data);
const transform = DocumentTransform.fromObject(documentRef, data);
const precondition = new Precondition({exists: false});
this._writes.push({
write: !document.isEmpty || transform.isEmpty ? document.toProto() : null,
transform: transform.isEmpty ? null : transform.toProto(),
precondition: precondition.toProto(),
});
return this;
}
/**
* Deletes a document from the database.
*
* @param {DocumentReference} documentRef - A reference to the
* document to be deleted.
* @param {Precondition=} precondition - A precondition to enforce for this
* delete.
* @param {string=} precondition.lastUpdateTime If set, enforces that the
* document was last updated at lastUpdateTime (as ISO 8601 string). Fails the
* batch if the document doesn't exist or was last updated at a different
* time.
* @returns {WriteBatch} This WriteBatch instance. Used for chaining
* method calls.
*
* @example
* let writeBatch = firestore.batch();
* let documentRef = firestore.doc('col/doc');
*
* writeBatch.delete(documentRef);
*
* writeBatch.commit().then(() => {
* console.log('Successfully executed batch.');
* });
*/
delete(documentRef, precondition) {
validate.isDocumentReference('documentRef', documentRef);
validate.isOptionalDeletePrecondition('precondition', precondition);
this.verifyNotCommitted();
const conditions = new Precondition(precondition);
this._writes.push({
write: {
delete: documentRef.formattedName,
},
precondition: conditions.isEmpty ? null : conditions.toProto(),
});
return this;
}
/**
* Write to the document referred to by the provided
* [DocumentReference]{@link DocumentReference}.
* If the document does not exist yet, it will be created. If you pass
* [SetOptions]{@link SetOptions}., the provided data can be merged
* into the existing document.
*
* @param {DocumentReference} documentRef - A reference to the
* document to be set.
* @param {DocumentData} data - The object to serialize as the document.
* @param {SetOptions=} options - An object to configure the set behavior.
* @param {boolean=} options.merge - If true, set() only replaces the
* values specified in its data argument. Fields omitted from this set() call
* remain untouched.
* @returns {WriteBatch} This WriteBatch instance. Used for chaining
* method calls.
*
* @example
* let writeBatch = firestore.batch();
* let documentRef = firestore.doc('col/doc');
*
* writeBatch.set(documentRef, {foo: 'bar'});
*
* writeBatch.commit().then(() => {
* console.log('Successfully executed batch.');
* });
*/
set(documentRef, data, options) {
const merge = options && options.merge;
validate.isDocumentReference('documentRef', documentRef);
validate.isDocument('data', data, {
allowNestedDeletes: merge,
allowEmpty: !merge,
});
validate.isOptionalSetOptions('options', options);
this.verifyNotCommitted();
const document = DocumentSnapshot.fromObject(documentRef, data);
const transform = DocumentTransform.fromObject(documentRef, data);
const documentMask = DocumentMask.fromObject(data);
let write;
if (!merge) {
write = document.toProto();
} else if (!document.isEmpty || !documentMask.isEmpty) {
write = document.toProto();
write.updateMask = documentMask.toProto();
}
this._writes.push({
write,
transform: transform.isEmpty ? null : transform.toProto(),
});
return this;
}
/**
* Update fields of the document referred to by the provided
* [DocumentReference]{@link DocumentReference}. If the document
* doesn't yet exist, the update fails and the entire batch will be rejected.
*
* The update() method accepts either an object with field paths encoded as
* keys and field values encoded as values, or a variable number of arguments
* that alternate between field paths and field values. Nested fields can be
* updated by providing dot-separated field path strings or by providing
* FieldPath objects.
*
* A Precondition restricting this update can be specified as the last
* argument.
*
* @param {DocumentReference} documentRef - A reference to the
* document to be updated.
* @param {UpdateData|string|FieldPath} dataOrField - An object
* containing the fields and values with which to update the document
* or the path of the first field to update.
* @param {
* ...(Precondition|*|string|FieldPath)} preconditionOrValues -
* An alternating list of field paths and values to update or a Precondition
* to restrict this update.
* @returns {WriteBatch} This WriteBatch instance. Used for chaining
* method calls.
*
* @example
* let writeBatch = firestore.batch();
* let documentRef = firestore.doc('col/doc');
*
* writeBatch.update(documentRef, {foo: 'bar'});
*
* writeBatch.commit().then(() => {
* console.log('Successfully executed batch.');
* });
*/
update(documentRef, dataOrField, preconditionOrValues) {
validate.minNumberOfArguments('update', arguments, 2);
validate.isDocumentReference('documentRef', documentRef);
this.verifyNotCommitted();
const updateMap = new Map();
let precondition = new Precondition({exists: true});
const argumentError =
'Update() requires either a single JavaScript ' +
'object or an alternating list of field/value pairs that can be ' +
'followed by an optional precondition.';
let usesVarargs =
is.string(dataOrField) || is.instance(dataOrField, FieldPath);
if (usesVarargs) {
try {
for (let i = 1; i < arguments.length; i += 2) {
if (is.string(arguments[i]) || is.instance(arguments[i], FieldPath)) {
validate.isFieldPath(i, arguments[i]);
validate.minNumberOfArguments('update', arguments, i + 1);
validate.isFieldValue(i, arguments[i + 1], {
allowDeletes: true,
});
updateMap.set(
FieldPath.fromArgument(arguments[i]),
arguments[i + 1]
);
} else {
validate.isUpdatePrecondition(i, arguments[i]);
validate.maxNumberOfArguments('update', arguments, i + 1);
precondition = new Precondition(arguments[i]);
}
}
} catch (err) {
Firestore.log('WriteBatch.update', 'Varargs validation failed:', err);
// We catch the validation error here and re-throw to provide a better
// error message.
throw new Error(`${argumentError} ${err.message}`);
}
} else {
try {
validate.isDocument('dataOrField', dataOrField, {
allowDeletes: true,
allowEmpty: false,
});
validate.maxNumberOfArguments('update', arguments, 3);
Object.keys(dataOrField).forEach(key => {
validate.isFieldPath(key, key);
validate.isFieldValue(key, dataOrField[key], {
allowDeletes: true,
});
updateMap.set(FieldPath.fromArgument(key), dataOrField[key]);
});
if (is.defined(preconditionOrValues)) {
validate.isUpdatePrecondition(
'preconditionOrValues',
preconditionOrValues
);
precondition = new Precondition(preconditionOrValues);
}
} catch (err) {
Firestore.log(
'WriteBatch.update',
'Non-varargs validation failed:',
err
);
// We catch the validation error here and prefix the error with a custom
// message to describe the usage of update() better.
throw new Error(`${argumentError} ${err.message}`);
}
}
validate.isUpdateMap('dataOrField', updateMap);
let document = DocumentSnapshot.fromUpdateMap(documentRef, updateMap);
let documentMask = DocumentMask.fromUpdateMap(updateMap);
let write = null;
if (!document.isEmpty || !documentMask.isEmpty) {
write = document.toProto();
write.updateMask = documentMask.toProto();
}
let transform = DocumentTransform.fromUpdateMap(documentRef, updateMap);
this._writes.push({
write: write,
transform: transform.isEmpty ? null : transform.toProto(),
precondition: precondition.toProto(),
});
return this;
}
/**
* Atomically commits all pending operations to the database and verifies all
* preconditions. Fails the entire write if any precondition is not met.
*
* @returns {Promise.<Array.<WriteResult>>} A Promise that resolves
* when this batch completes.
*
* @example
* let writeBatch = firestore.batch();
* let documentRef = firestore.doc('col/doc');
*
* writeBatch.set(documentRef, {foo: 'bar'});
*
* writeBatch.commit().then(() => {
* console.log('Successfully executed batch.');
* });
*/
commit() {
return this.commit_();
}
/**
* Commit method that takes an optional transaction ID.
*
* @private
* @param {object=} commitOptions Options to use for this commit.
* @param {bytes=} commitOptions.transactionId The transaction ID of this
* commit.
* @returns {Promise.<Array.<WriteResult>>} A Promise that resolves
* when this batch completes.
*/
commit_(commitOptions) {
// Note: We don't call `verifyNotCommitted()` to allow for retries.
let explicitTransaction = commitOptions && commitOptions.transactionId;
let request = {
database: this._firestore.formattedName,
};
// On GCF, we periodically force transactional commits to allow for
// request retries in case GCF closes our backend connection.
if (!explicitTransaction && this._shouldCreateTransaction()) {
Firestore.log('WriteBatch.commit', 'Using transaction for commit');
return this._firestore
.request(
this._api.Firestore.beginTransaction.bind(this._api.Firestore),
request,
/* allowRetries= */ true
)
.then(resp => {
return this.commit_({transactionId: resp.transaction});
});
}
request.writes = [];
for (let req of this._writes) {
assert(
req.write || req.transform,
'Either a write or transform must be set'
);
if (req.precondition) {
(req.write || req.transform).currentDocument = req.precondition;
}
if (req.write) {
request.writes.push(req.write);
}
if (req.transform) {
request.writes.push(req.transform);
}
}
Firestore.log(
'WriteBatch.commit',
'Sending %d writes',
request.writes.length
);
if (explicitTransaction) {
request.transaction = explicitTransaction;
}
this._committed = true;
return this._firestore
.request(this._api.Firestore.commit.bind(this._api.Firestore), request)
.then(resp => {
const commitTime = DocumentSnapshot.toISOTime(resp.commitTime);
const writeResults = [];
if (resp.writeResults) {
assert(
request.writes.length === resp.writeResults.length,
`Expected one write result per operation, but got ${
resp.writeResults.length
} results for ${request.writes.length} operations.`
);
let offset = 0;
for (let i = 0; i < this._writes.length; ++i) {
let writeRequest = this._writes[i];
// Don't return two write results for a write that contains a
// transform, as the fact that we have to split one write operation
// into two distinct write requests is an implementation detail.
if (writeRequest.write && writeRequest.transform) {
// The document transform is always sent last and produces the
// latest update time.
++offset;
}
let writeResult = resp.writeResults[i + offset];
writeResults.push(
new WriteResult(
DocumentSnapshot.toISOTime(writeResult.updateTime) || commitTime
)
);
}
}
return writeResults;
});
}
/**
* Determines whether we should issue a transactional commit. On GCF, this
* happens after two minutes of idleness.
*
* @private
* @returns {boolean} Whether to use a transaction.
*/
_shouldCreateTransaction() {
if (!this._firestore._preferTransactions) {
return false;
}
if (this._firestore._lastSuccessfulRequest) {
let now = new Date().getTime();
return now - this._firestore._lastSuccessfulRequest > GCF_IDLE_TIMEOUT_MS;
}
return true;
}
}
/*!
* Validates that the update data does not contain any ambiguous field
* definitions (such as 'a.b' and 'a').
*
* @param {Map.<FieldPath, *>} data - An update map with field/value pairs.
* @returns {boolean} 'true' if the input is a valid update map.
*/
function validateUpdateMap(data) {
const fields = [];
data.forEach((value, key) => {
fields.push(key);
});
fields.sort((left, right) => left.compareTo(right));
for (let i = 1; i < fields.length; ++i) {
if (fields[i - 1].isPrefixOf(fields[i])) {
throw new Error(`Field "${fields[i - 1]}" has conflicting definitions.`);
}
}
return true;
}
module.exports = (
FirestoreType,
DocumentReferenceType,
validateDocumentReference
) => {
let document = require('./document')(DocumentReferenceType);
Firestore = FirestoreType;
DocumentMask = document.DocumentMask;
DocumentSnapshot = document.DocumentSnapshot;
DocumentTransform = document.DocumentTransform;
Precondition = document.Precondition;
validate = require('./validate')({
Document: document.validateDocumentData,
DocumentReference: validateDocumentReference,
FieldValue: document.validateFieldValue,
FieldPath: FieldPath.validateFieldPath,
UpdatePrecondition: precondition =>
document.validatePrecondition(precondition, /* allowExists= */ false),
DeletePrecondition: precondition =>
document.validatePrecondition(precondition, /* allowExists= */ true),
SetOptions: document.validateSetOptions,
UpdateMap: validateUpdateMap,
});
return {
WriteBatch,
WriteResult,
};
};