UNPKG

@google-cloud/datastore

Version:
667 lines 25.8 kB
"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