@google-cloud/datastore
Version:
Cloud Datastore Client Library for Node.js
667 lines • 25.8 kB
JavaScript
"use strict";
/*!
* Copyright 2014 Google LLC.
*
* 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.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.Transaction = void 0;
const promisify_1 = require("@google-cloud/promisify");
const _1 = require(".");
const entity_1 = require("./entity");
const request_1 = require("./request");
const async_mutex_1 = require("async-mutex");
const arrify = require("arrify");
/**
* A transaction is a set of Datastore operations on one or more entities. Each
* transaction is guaranteed to be atomic, which means that transactions are
* never partially applied. Either all of the operations in the transaction are
* applied, or none of them are applied.
*
* @see {@link https://cloud.google.com/datastore/docs/concepts/transactions| Transactions Reference}
*
* @class
* @extends {Request}
* @param {Datastore} datastore A Datastore instance.
* @mixes module:datastore/request
*
* @example
* ```
* const {Datastore} = require('@google-cloud/datastore');
* const datastore = new Datastore();
* const transaction = datastore.transaction();
* ```
*/
class Transaction extends request_1.DatastoreRequest {
namespace;
readOnly;
request;
modifiedEntities_;
skipCommit;
#mutex = new async_mutex_1.Mutex();
constructor(datastore, options) {
super();
/**
* @name Transaction#datastore
* @type {Datastore}
*/
this.datastore = datastore;
/**
* @name Transaction#namespace
* @type {string}
*/
this.namespace = datastore.namespace;
options = options || {};
this.id = options.id;
this.readOnly = options.readOnly === true;
this.request = datastore.request_.bind(datastore);
// A queue for entity modifications made during the transaction.
this.modifiedEntities_ = [];
// Queue the callbacks that process the API responses.
this.requestCallbacks_ = [];
// Queue the requests to make when we send the transactional commit.
this.requests_ = [];
this.state = request_1.TransactionState.NOT_STARTED;
}
commit(gaxOptionsOrCallback, cb) {
const callback = typeof gaxOptionsOrCallback === 'function'
? gaxOptionsOrCallback
: typeof cb === 'function'
? cb
: () => { };
const gaxOptions = typeof gaxOptionsOrCallback === 'object' ? gaxOptionsOrCallback : {};
if (this.state === request_1.TransactionState.EXPIRED) {
callback(new Error(request_1.transactionExpiredError));
return;
}
// This ensures that the transaction is started before calling runCommit
this.#withBeginTransaction(gaxOptions, () => {
void this.#runCommit(gaxOptions, callback);
}, callback);
}
createQuery(namespaceOrKind, kind) {
return this.datastore.createQuery.call(this, namespaceOrKind, kind);
}
/**
* Create an aggregation query from the query specified. See {module:datastore/query} for all
* of the available methods.
*
* @param {Query} query A Query object
*/
createAggregationQuery(query) {
return this.datastore.createAggregationQuery.call(this, query);
}
/**
* Delete all entities identified with the specified key(s) in the current
* transaction.
*
* @param {Key|Key[]} key Datastore key object(s).
*
* @example
* ```
* const {Datastore} = require('@google-cloud/datastore');
* const datastore = new Datastore();
* const transaction = datastore.transaction();
*
* transaction.run((err) => {
* if (err) {
* // Error handling omitted.
* }
*
* // Delete a single entity.
* transaction.delete(datastore.key(['Company', 123]));
*
* // Delete multiple entities at once.
* transaction.delete([
* datastore.key(['Company', 123]),
* datastore.key(['Product', 'Computer'])
* ]);
*
* transaction.commit((err) => {
* if (!err) {
* // Transaction committed successfully.
* }
* });
* });
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete(entities) {
arrify(entities).forEach((ent) => {
this.modifiedEntities_.push({
entity: {
key: ent,
},
method: 'delete',
args: [ent],
});
});
}
get(keys, optionsOrCallback, cb) {
const options = typeof optionsOrCallback === 'object' && optionsOrCallback
? optionsOrCallback
: {};
const callback = typeof optionsOrCallback === 'function' ? optionsOrCallback : cb;
// This ensures that the transaction is started before calling get
this.#blockWithMutex(() => {
super.get(keys, options, callback);
});
}
/**
* Maps to {@link https://cloud.google.com/nodejs/docs/reference/datastore/latest/datastore/transaction#_google_cloud_datastore_Transaction_save_member_1_|Datastore#save}, forcing the method to be `insert`.
*
* @param {object|object[]} entities Datastore key object(s).
* @param {Key} entities.key Datastore key object.
* @param {string[]} [entities.excludeFromIndexes] Exclude properties from
* indexing using a simple JSON path notation. See the examples in
* {@link Datastore#save} to see how to target properties at different
* levels of nesting within your entity.
* @param {object} entities.data Data to save with the provided key.
*/
insert(entities) {
entities = arrify(entities)
.map(request_1.DatastoreRequest.prepareEntityObject_)
.map((x) => {
x.method = 'insert';
return x;
});
this.save(entities);
}
rollback(gaxOptionsOrCallback, cb) {
const gaxOptions = typeof gaxOptionsOrCallback === 'object' ? gaxOptionsOrCallback : {};
const callback = typeof gaxOptionsOrCallback === 'function' ? gaxOptionsOrCallback : cb;
if (this.state === request_1.TransactionState.EXPIRED) {
callback(new Error(request_1.transactionExpiredError));
return;
}
if (this.state === request_1.TransactionState.NOT_STARTED) {
callback(new Error('Transaction is not started'));
return;
}
this.request_({
client: 'DatastoreClient',
method: 'rollback',
gaxOpts: gaxOptions || {},
}, (err, resp) => {
this.skipCommit = true;
this.state = request_1.TransactionState.EXPIRED;
callback(err || null, resp);
});
}
run(optionsOrCallback, cb) {
const options = typeof optionsOrCallback === 'object' ? optionsOrCallback : {};
const callback = typeof optionsOrCallback === 'function' ? optionsOrCallback : cb;
void this.#mutex.runExclusive(async () => {
if (this.state === request_1.TransactionState.NOT_STARTED) {
const runResults = await this.#beginTransactionAsync(options);
this.#processBeginResults(runResults, callback);
}
else {
process.emitWarning('run has already been called and should not be called again.');
callback(null, this, { transaction: this.id });
}
});
}
/**
* This function is a pass-through for the transaction.commit method
* It contains the business logic used for committing a transaction
*
* @param {object} [gaxOptions] Request configuration options, outlined here:
* https://googleapis.github.io/gax-nodejs/global.html#CallOptions.
* @param {function} callback The callback function.
* @private
*/
#runCommit(gaxOptions, callback) {
if (this.skipCommit) {
setImmediate(callback);
return;
}
const keys = {};
this.modifiedEntities_
// Reverse the order of the queue to respect the "last queued request
// wins" behavior.
.reverse()
// Limit the operations we're going to send through to only the most
// recently queued operations. E.g., if a user tries to save with the
// same key they just asked to be deleted, the delete request will be
// ignored, giving preference to the save operation.
.filter((modifiedEntity) => {
const key = modifiedEntity.entity.key;
if (!entity_1.entity.isKeyComplete(key))
return true;
const stringifiedKey = JSON.stringify(modifiedEntity.entity.key);
if (!keys[stringifiedKey]) {
keys[stringifiedKey] = true;
return true;
}
return false;
})
// Group entities together by method: `save` mutations, then `delete`.
// Note: `save` mutations being first is required to maintain order when
// assigning IDs to incomplete keys.
.sort((a, b) => {
return a.method < b.method ? 1 : a.method > b.method ? -1 : 0;
})
// Group arguments together so that we only make one call to each
// method. This is important for `DatastoreRequest.save`, especially, as
// that method handles assigning auto-generated IDs to the original keys
// passed in. When we eventually execute the `save` method's API
// callback, having all the keys together is necessary to maintain
// order.
.reduce((acc, entityObject) => {
const lastEntityObject = acc[acc.length - 1];
const sameMethod = lastEntityObject && entityObject.method === lastEntityObject.method;
if (!lastEntityObject || !sameMethod) {
acc.push(entityObject);
}
else {
lastEntityObject.args = lastEntityObject.args.concat(entityObject.args);
}
return acc;
}, [])
// Call each of the mutational methods (DatastoreRequest[save,delete])
// to build up a `req` array on this instance. This will also build up a
// `callbacks` array, that is the same callback that would run if we
// were using `save` and `delete` outside of a transaction, to process
// the response from the API.
.forEach((modifiedEntity) => {
const method = modifiedEntity.method;
const args = modifiedEntity.args.reverse();
_1.Datastore.prototype[method].call(this, args, () => { });
});
// Take the `req` array built previously, and merge them into one request to
// send as the final transactional commit.
const reqOpts = {
mutations: this.requests_
.map((x) => x.mutations)
.reduce((a, b) => a.concat(b), []),
};
this.request_({
client: 'DatastoreClient',
method: 'commit',
reqOpts,
gaxOpts: gaxOptions || {},
}, (err, resp) => {
if (err) {
// Rollback automatically for the user.
this.rollback(() => {
// Provide the error & API response from the failed commit to the
// user. Even a failed rollback should be transparent. RE:
// https://github.com/GoogleCloudPlatform/google-cloud-node/pull/1369#discussion_r66833976
callback(err, resp);
});
return;
}
this.state = request_1.TransactionState.EXPIRED;
// The `callbacks` array was built previously. These are the callbacks
// that handle the API response normally when using the
// DatastoreRequest.save and .delete methods.
this.requestCallbacks_.forEach((cb) => {
cb(null, resp);
});
callback(null, resp);
});
}
/**
* This function parses results from a beginTransaction call
*
* @param {BeginAsyncResponse} [response]
* The response data from a call to begin a transaction.
* @param {RunCallback} [callback] A callback that accepts an error and a
* response as arguments.
*
**/
#processBeginResults(runResults, callback) {
const err = runResults.err;
const resp = runResults.resp;
if (err) {
callback(err, null, resp);
}
else {
this.parseTransactionResponse(resp);
callback(null, this, resp);
}
}
/**
* This async function makes a beginTransaction call and returns a promise with
* the information returned from the call that was made.
*
* @param {RunOptions} options The options used for a beginTransaction call.
* @returns {Promise<RequestPromiseReturnType>}
*
*
**/
async #beginTransactionAsync(options) {
return new Promise((resolve) => {
const reqOpts = {
transactionOptions: (0, request_1.getTransactionRequest)(this, options),
};
this.request_({
client: 'DatastoreClient',
method: 'beginTransaction',
reqOpts,
gaxOpts: options.gaxOptions,
},
// Always use resolve because then this function can return both the error and the response
(err, resp) => {
resolve({
err,
resp,
});
});
});
}
runAggregationQuery(query, optionsOrCallback, cb) {
const options = typeof optionsOrCallback === 'object' && optionsOrCallback
? optionsOrCallback
: {};
const callback = typeof optionsOrCallback === 'function' ? optionsOrCallback : cb;
// This ensures that the transaction is started before calling runAggregationQuery
this.#blockWithMutex(() => {
super.runAggregationQuery(query, options, callback);
});
}
runQuery(query, optionsOrCallback, cb) {
const options = typeof optionsOrCallback === 'object' && optionsOrCallback
? optionsOrCallback
: {};
const callback = typeof optionsOrCallback === 'function' ? optionsOrCallback : cb;
// This ensures that the transaction is started before calling runQuery
this.#blockWithMutex(() => {
super.runQuery(query, options, callback);
});
}
/**
* Insert or update the specified object(s) in the current transaction. If a
* key is incomplete, its associated object is inserted and the original Key
* object is updated to contain the generated ID.
*
* This method will determine the correct Datastore method to execute
* (`upsert`, `insert`, or `update`) by using the key(s) provided. For
* example, if you provide an incomplete key (one without an ID), the request
* will create a new entity and have its ID automatically assigned. If you
* provide a complete key, the entity will be updated with the data specified.
*
* By default, all properties are indexed. To prevent a property from being
* included in *all* indexes, you must supply an `excludeFromIndexes` array.
* See below for an example.
*
* @param {object|object[]} entities Datastore key object(s).
* @param {Key} entities.key Datastore key object.
* @param {string[]} [entities.excludeFromIndexes] Exclude properties from
* indexing using a simple JSON path notation. See the example below to
* see how to target properties at different levels of nesting within your
* entity.
* @param {object} entities.data Data to save with the provided key.
*
* @example
* ```
* <caption>Save a single entity.</caption>
* const {Datastore} = require('@google-cloud/datastore');
* const datastore = new Datastore();
* const transaction = datastore.transaction();
*
* // Notice that we are providing an incomplete key. After the transaction is
* // committed, the Key object held by the `key` variable will be populated
* // with a path containing its generated ID.
* //-
* const key = datastore.key('Company');
*
* transaction.run((err) => {
* if (err) {
* // Error handling omitted.
* }
*
* transaction.save({
* key: key,
* data: {
* rating: '10'
* }
* });
*
* transaction.commit((err) => {
* if (!err) {
* // Data saved successfully.
* }
* });
* });
*
* ```
* @example
* ```
* const {Datastore} = require('@google-cloud/datastore');
* const datastore = new Datastore();
* const transaction = datastore.transaction();
*
* // Use an array, `excludeFromIndexes`, to exclude properties from indexing.
* // This will allow storing string values larger than 1500 bytes.
*
* transaction.run((err) => {
* if (err) {
* // Error handling omitted.
* }
*
* transaction.save({
* key: key,
* excludeFromIndexes: [
* 'description',
* 'embeddedEntity.description',
* 'arrayValue[].description'
* ],
* data: {
* description: 'Long string (...)',
* embeddedEntity: {
* description: 'Long string (...)'
* },
* arrayValue: [
* {
* description: 'Long string (...)'
* }
* ]
* }
* });
*
* transaction.commit((err) => {
* if (!err) {
* // Data saved successfully.
* }
* });
* });
*
* ```
* @example
* ```
* <caption>Save multiple entities at once.</caption>
* const {Datastore} = require('@google-cloud/datastore');
* const datastore = new Datastore();
* const transaction = datastore.transaction();
* const companyKey = datastore.key(['Company', 123]);
* const productKey = datastore.key(['Product', 'Computer']);
*
* transaction.run((err) => {
* if (err) {
* // Error handling omitted.
* }
*
* transaction.save([
* {
* key: companyKey,
* data: {
* HQ: 'Dallas, TX'
* }
* },
* {
* key: productKey,
* data: {
* vendor: 'Dell'
* }
* }
* ]);
*
* transaction.commit((err) => {
* if (!err) {
* // Data saved successfully.
* }
* });
* });
* ```
*/
save(entities) {
arrify(entities).forEach((ent) => {
this.modifiedEntities_.push({
entity: {
key: ent.key,
},
method: 'save',
args: [ent],
});
});
}
/**
* Maps to {@link https://cloud.google.com/nodejs/docs/reference/datastore/latest/datastore/transaction#_google_cloud_datastore_Transaction_save_member_1_|Datastore#save}, forcing the method to be `update`.
*
* @param {object|object[]} entities Datastore key object(s).
* @param {Key} entities.key Datastore key object.
* @param {string[]} [entities.excludeFromIndexes] Exclude properties from
* indexing using a simple JSON path notation. See the examples in
* {@link Datastore#save} to see how to target properties at different
* levels of nesting within your entity.
* @param {object} entities.data Data to save with the provided key.
*/
update(entities) {
entities = arrify(entities)
.map(request_1.DatastoreRequest.prepareEntityObject_)
.map((x) => {
x.method = 'update';
return x;
});
this.save(entities);
}
/**
* Maps to {@link https://cloud.google.com/nodejs/docs/reference/datastore/latest/datastore/transaction#_google_cloud_datastore_Transaction_save_member_1_|Datastore#save}, forcing the method to be `upsert`.
*
* @param {object|object[]} entities Datastore key object(s).
* @param {Key} entities.key Datastore key object.
* @param {string[]} [entities.excludeFromIndexes] Exclude properties from
* indexing using a simple JSON path notation. See the examples in
* {@link Datastore#save} to see how to target properties at different
* levels of nesting within your entity.
* @param {object} entities.data Data to save with the provided key.
*/
upsert(entities) {
entities = arrify(entities)
.map(request_1.DatastoreRequest.prepareEntityObject_)
.map((x) => {
x.method = 'upsert';
return x;
});
this.save(entities);
}
/**
* Some rpc calls require that the transaction has been started (i.e, has a
* valid id) before they can be sent. #withBeginTransaction acts as a wrapper
* over those functions.
*
* If the transaction has not begun yet, `#withBeginTransaction` will first
* send an rpc to begin the transaction, and then execute the wrapped
* function. If it has begun, the wrapped function will be called directly
* instead. If an error is encountered during the beginTransaction call, the
* callback will be executed instead of the wrapped function.
*
* @param {CallOptions | undefined} [gaxOptions] Gax options provided by the
* user that are used for the beginTransaction grpc call.
* @param {function} [fn] A function which is run after ensuring a
* transaction has begun.
* @param {function} [callback] A callback provided by the user that expects
* an error in the first argument and a custom data type for the rest of the
* arguments.
* @private
*/
#withBeginTransaction(gaxOptions, fn, callback) {
(async () => {
if (this.state === request_1.TransactionState.NOT_STARTED) {
try {
await this.#mutex.runExclusive(async () => {
if (this.state === request_1.TransactionState.NOT_STARTED) {
// This sends an rpc call to get the transaction id
const runResults = await this.#beginTransactionAsync({
gaxOptions,
});
if (runResults.err) {
// The rpc getting the id was unsuccessful.
// Do not call the wrapped function.
throw runResults.err;
}
this.parseTransactionResponse(runResults.resp);
// The rpc saving the transaction id was successful.
// Now the wrapped function fn will be called.
}
});
}
catch (err) {
// Handle an error produced by the beginTransactionAsync call
return callback(err);
}
}
return fn();
})().catch(err => {
throw err;
});
}
/*
* Some rpc calls require that the transaction has been started (i.e, has a
* valid id) before they can be sent. #withBeginTransaction acts as a wrapper
* over those functions.
*
* If the transaction has not begun yet, `#blockWithMutex` will call the
* wrapped function which will begin the transaction in the rpc call it sends.
* If the transaction has begun, the wrapped function will be called, but it
* will not begin a transaction.
*
* @param {function} [fn] A function which is run after ensuring a
* transaction has begun.
*/
#blockWithMutex(fn) {
(async () => {
if (this.state === request_1.TransactionState.NOT_STARTED) {
await this.#mutex.runExclusive(async () => {
fn();
});
}
else {
fn();
}
})().catch(err => {
throw err;
});
}
}
exports.Transaction = Transaction;
/*! Developer Documentation
*
* All async methods (except for streams) will return a Promise in the event
* that a callback is omitted.
*/
(0, promisify_1.promisifyAll)(Transaction, {
exclude: [
'createAggregationQuery',
'createQuery',
'delete',
'insert',
'#runAsync',
'save',
'update',
'upsert',
],
});
//# sourceMappingURL=transaction.js.map