diff --git a/.github/workflows/system-tests-against-emulator.yaml b/.github/workflows/system-tests-against-emulator.yaml index b9785c922..9a07e1d4b 100644 --- a/.github/workflows/system-tests-against-emulator.yaml +++ b/.github/workflows/system-tests-against-emulator.yaml @@ -16,7 +16,7 @@ jobs: - 9020:9020 steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4 + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 - uses: actions/setup-node@v4 with: node-version: 14 diff --git a/CHANGELOG.md b/CHANGELOG.md index cfef2cbc1..c8ac9cf08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ [1]: https://www.npmjs.com/package/nodejs-spanner?activeTab=versions +## [7.11.0](https://github.com/googleapis/nodejs-spanner/compare/v7.10.0...v7.11.0) (2024-07-29) + + +### Features + +* Add support for blind writes ([#2065](https://github.com/googleapis/nodejs-spanner/issues/2065)) ([62fc0a4](https://github.com/googleapis/nodejs-spanner/commit/62fc0a47327017c115466b9e89e53dbd778579af)) +* **spanner:** Add samples for instance partitions ([#2083](https://github.com/googleapis/nodejs-spanner/issues/2083)) ([b91e284](https://github.com/googleapis/nodejs-spanner/commit/b91e2849056df9894e0590cb71e21c13319e6d70)) + ## [7.10.0](https://github.com/googleapis/nodejs-spanner/compare/v7.9.1...v7.10.0) (2024-07-19) diff --git a/README.md b/README.md index 14f4c8c8d..8e88ef2e6 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,7 @@ Samples are in the [`samples/`](https://github.com/googleapis/nodejs-spanner/tre | Deletes a user-managed instance configuration. | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/instance-config-delete.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-spanner&page=editor&open_in_editor=samples/instance-config-delete.js,samples/README.md) | | Lists the instance configuration operations. | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/instance-config-get-operations.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-spanner&page=editor&open_in_editor=samples/instance-config-get-operations.js,samples/README.md) | | Updates a user-managed instance configuration. | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/instance-config-update.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-spanner&page=editor&open_in_editor=samples/instance-config-update.js,samples/README.md) | +| Creates a new instance partition | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/instance-partition-create.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-spanner&page=editor&open_in_editor=samples/instance-partition-create.js,samples/README.md) | | Creates a instance with autoscaling config. | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/instance-with-autoscaling-config.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-spanner&page=editor&open_in_editor=samples/instance-with-autoscaling-config.js,samples/README.md) | | Instance-with-processing-units | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/instance-with-processing-units.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-spanner&page=editor&open_in_editor=samples/instance-with-processing-units.js,samples/README.md) | | Instance | [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/instance.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-spanner&page=editor&open_in_editor=samples/instance.js,samples/README.md) | diff --git a/package.json b/package.json index f3e59edc1..a4bbbbf62 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@google-cloud/spanner", "description": "Cloud Spanner Client Library for Node.js", - "version": "7.10.0", + "version": "7.11.0", "license": "Apache-2.0", "author": "Google Inc.", "engines": { diff --git a/samples/README.md b/samples/README.md index 17d42d085..5276e8710 100644 --- a/samples/README.md +++ b/samples/README.md @@ -57,6 +57,7 @@ and automatic, synchronous replication for high availability. * [Deletes a user-managed instance configuration.](#deletes-a-user-managed-instance-configuration.) * [Lists the instance configuration operations.](#lists-the-instance-configuration-operations.) * [Updates a user-managed instance configuration.](#updates-a-user-managed-instance-configuration.) + * [Creates a new instance partition](#creates-a-new-instance-partition) * [Creates a instance with autoscaling config.](#creates-a-instance-with-autoscaling-config.) * [Instance-with-processing-units](#instance-with-processing-units) * [Instance](#instance) @@ -865,6 +866,23 @@ __Usage:__ +### Creates a new instance partition + +View the [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/instance-partition-create.js). + +[![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/nodejs-spanner&page=editor&open_in_editor=samples/instance-partition-create.js,samples/README.md) + +__Usage:__ + + +`node instance-partition-create.js ` + + +----- + + + + ### Creates a instance with autoscaling config. View the [source code](https://github.com/googleapis/nodejs-spanner/blob/main/samples/instance-with-autoscaling-config.js). diff --git a/samples/instance-partition-create.js b/samples/instance-partition-create.js new file mode 100644 index 000000000..b7536fe93 --- /dev/null +++ b/samples/instance-partition-create.js @@ -0,0 +1,84 @@ +/** + * Copyright 2024 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. + */ + +// sample-metadata: +// title: Creates a new instance partition +// usage: node instance-partition-create.js + +'use strict'; + +function main( + instanceId = 'my-instance', + instancePartitionId = 'my-instance-partition', + projectId = 'my-project-id' +) { + async function createInstancePartition() { + // [START spanner_create_instance_partition] + // Imports the Google Cloud client library + const {Spanner} = require('@google-cloud/spanner'); + + /** + * TODO(developer): Uncomment the following lines before running the sample. + */ + // const projectId = 'my-project-id'; + // const instanceId = 'my-instance'; + // const instancePartitionId = 'my-instance-partition'; + + // Creates a client + const spanner = new Spanner({ + projectId: projectId, + }); + + // Get the instance admin client + const instanceAdminClient = spanner.getInstanceAdminClient(); + + // Creates a new instance partition + try { + console.log( + `Creating instance partition ${instanceAdminClient.instancePartitionPath( + projectId, + instanceId, + instancePartitionId + )}.` + ); + const [operation] = await instanceAdminClient.createInstancePartition({ + instancePartitionId: instancePartitionId, + parent: instanceAdminClient.instancePath(projectId, instanceId), + instancePartition: { + config: instanceAdminClient.instanceConfigPath(projectId, 'nam3'), + nodeCount: 1, + displayName: 'Test instance partition', + }, + }); + + console.log( + `Waiting for operation on ${instancePartitionId} to complete...` + ); + await operation.promise(); + + console.log(`Created instance partition ${instancePartitionId}.`); + } catch (err) { + console.error('ERROR:', err); + } + // [END spanner_create_instance_partition] + } + createInstancePartition(); +} + +process.on('unhandledRejection', err => { + console.error(err.message); + process.exitCode = 1; +}); +main(...process.argv.slice(2)); diff --git a/samples/package.json b/samples/package.json index 2114e3dd6..e07a78984 100644 --- a/samples/package.json +++ b/samples/package.json @@ -17,7 +17,7 @@ "dependencies": { "@google-cloud/kms": "^4.0.0", "@google-cloud/precise-date": "^4.0.0", - "@google-cloud/spanner": "^7.10.0", + "@google-cloud/spanner": "^7.11.0", "yargs": "^17.0.0", "protobufjs": "^7.0.0" }, diff --git a/samples/system-test/spanner.test.js b/samples/system-test/spanner.test.js index a5e27a174..1208b012b 100644 --- a/samples/system-test/spanner.test.js +++ b/samples/system-test/spanner.test.js @@ -1732,6 +1732,44 @@ describe('Autogenerated Admin Clients', () => { }); }); + describe('instance partition', () => { + before(async () => { + const instance = spanner.instance(SAMPLE_INSTANCE_ID); + const [, operation] = await instance.create({ + config: 'regional-us-central1', + nodes: 1, + displayName: 'Instance partitions test', + labels: { + ['cloud_spanner_samples']: 'true', + created: Math.round(Date.now() / 1000).toString(), // current time + }, + }); + await operation.promise(); + }); + + after(async () => { + const instance = spanner.instance(SAMPLE_INSTANCE_ID); + await instance.delete(); + }); + + // create_instance_partition + it('should create an instance partition', async () => { + const output = execSync( + `node instance-partition-create.js "${SAMPLE_INSTANCE_ID}" "my-instance-partition" "${PROJECT_ID}"` + ); + assert.match( + output, + new RegExp( + 'Waiting for operation on my-instance-partition to complete...' + ) + ); + assert.match( + output, + new RegExp('Created instance partition my-instance-partition.') + ); + }); + }); + describe('postgreSQL', () => { before(async () => { const instance = spanner.instance(SAMPLE_INSTANCE_ID); diff --git a/src/database.ts b/src/database.ts index fb85e053e..b3b38a239 100644 --- a/src/database.ts +++ b/src/database.ts @@ -61,8 +61,11 @@ import { import {CreateTableCallback, CreateTableResponse, Table} from './table'; import { BatchWriteOptions, + CommitCallback, + CommitResponse, ExecuteSqlRequest, MutationGroup, + MutationSet, RunCallback, RunResponse, RunUpdateCallback, @@ -3346,6 +3349,95 @@ class Database extends common.GrpcServiceObject { return proxyStream as NodeJS.ReadableStream; } + /** + * Write mutations using a single RPC invocation without replay protection. + * + * writeAtLeastOnce writes mutations to Spanner using a single Commit RPC. + * These requests are not replay protected, meaning that it may apply mutations more + * than once, if the mutations are not idempotent, this may lead to a failure being + * reported when the mutation was applied once. Replays non-idempotent mutations may + * have undesirable effects. For example, replays of an insert mutation may produce an + * already exists error. For this reason, most users of the library will prefer to use + * {@link runTransaction} instead. + * + * However, {@link writeAtLeastOnce()} requires only a single RPC, whereas {@link runTransaction()} + * requires two RPCs (one of which may be performed in advance), and so this method may be + * appropriate for latency sensitive and/or high throughput blind writing. + * + * We recommend structuring your mutation set to be idempotent to avoid this issue. + * + * @param {MutationSet} [mutations] Set of Mutations to be applied. + * @param {CallOptions} [options] Options object for blind write request. + * @param {CommitCallback} [callback] Callback function for blind write request. + * + * @returns {Promise} + * + * @example + * ``` + * const {Spanner} = require('@google-cloud/spanner'); + * const spanner = new Spanner(); + * + * const instance = spanner.instance('my-instance'); + * const database = instance.database('my-database'); + * const mutations = new MutationSet(); + * mutations.upsert('Singers', { + * SingerId: 1, + * FirstName: 'Scarlet', + * LastName: 'Terry', + * }); + * mutations.upsert('Singers', { + * SingerId: 2, + * FirstName: 'Marc', + * LastName: 'Richards', + * }); + * + * try { + * const [response, err] = await database.writeAtLeastOnce(mutations, {}); + * console.log(response.commitTimestamp); + * } catch(err) { + * console.log("Error: ", err); + * } + * ``` + */ + writeAtLeastOnce(mutations: MutationSet): Promise; + writeAtLeastOnce( + mutations: MutationSet, + options: CallOptions + ): Promise; + writeAtLeastOnce(mutations: MutationSet, callback: CommitCallback): void; + writeAtLeastOnce( + mutations: MutationSet, + options: CallOptions, + callback: CommitCallback + ): void; + writeAtLeastOnce( + mutations: MutationSet, + optionsOrCallback?: CallOptions | CommitCallback, + callback?: CommitCallback + ): void | Promise { + const cb = + typeof optionsOrCallback === 'function' + ? (optionsOrCallback as CommitCallback) + : callback; + const options = + typeof optionsOrCallback === 'object' && optionsOrCallback + ? (optionsOrCallback as CallOptions) + : {}; + this.pool_.getSession((err, session?, transaction?) => { + if (err && isSessionNotFoundError(err as grpc.ServiceError)) { + this.writeAtLeastOnce(mutations, options, cb!); + return; + } + if (err) { + cb!(err as grpc.ServiceError); + return; + } + this._releaseOnEnd(session!, transaction!); + transaction?.setQueuedMutations(mutations.proto()); + return transaction?.commit(options, cb!); + }); + } + /** * Create a Session object. * @@ -3674,6 +3766,7 @@ callbackifyAll(Database, { 'batchCreateSessions', 'batchTransaction', 'batchWriteAtLeastOnce', + 'writeAtLeastOnce', 'close', 'createBatchTransaction', 'createSession', diff --git a/src/index.ts b/src/index.ts index d6107aed1..983d7f9ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -72,6 +72,7 @@ import {SessionPool} from './session-pool'; import {Table} from './table'; import { MutationGroup, + MutationSet, PartitionedDml, Snapshot, Transaction, @@ -2025,6 +2026,15 @@ export {Transaction}; */ export {MutationGroup}; +/** + * {@link MutationSet} class. + * + * @name Spanner.MutationSet + * @see MutationSet + * @type {Constructor} + */ +export {MutationSet}; + /** * @type {object} * @property {constructor} DatabaseAdminClient diff --git a/src/transaction.ts b/src/transaction.ts index 35d5e90f7..5617b4a3c 100644 --- a/src/transaction.ts +++ b/src/transaction.ts @@ -1854,6 +1854,17 @@ export class Transaction extends Dml { return undefined; } + /** + * This method updates the _queuedMutations property of the transaction. + * + * @public + * + * @param {spannerClient.spanner.v1.Mutation[]} [mutation] + */ + setQueuedMutations(mutation: spannerClient.spanner.v1.Mutation[]): void { + this._queuedMutations = mutation; + } + /** * @typedef {object} CommitOptions * @property {google.spanner.v1.IRequestOptions} requestOptions The request options to include @@ -2530,6 +2541,110 @@ function buildDeleteMutation( return mutation as spannerClient.spanner.v1.Mutation; } +/** + * MutationSet represent a set of changes to be applied atomically to a Cloud Spanner + * database with a {@link Transaction}. + * Mutations are used to insert, update, upsert(insert or update), replace, or + * delete rows within tables. + * + * Mutations are added to a {@link Transaction} and are not executed until the + * transaction is committed via {@link Transaction#commit}. + * + * If the transaction is rolled back or encounters an error, the mutations are + * discarded. + * + * @example + * ``` + * const {Spanner, Mutation} = require('@google-cloud/spanner'); + * const spanner = new Spanner(); + * + * const instance = spanner.instance('my-instance'); + * const database = instance.database('my-database'); + * + * const mutations = new MutationSet(); + * mutations.insert('Singers', {SingerId: '123', FirstName: 'David'}); + * mutations.update('Singers', {SingerId: '123', FirstName: 'Marc'}); + * + * try { + * database.writeAtLeastOnce(mutations, (err, res) => { + * console.log("RESPONSE: ", res); + * }); + * } catch(err) { + * console.log("ERROR: ", err); + * } + * ``` + */ +export class MutationSet { + /** + * An array to store the mutations. + */ + private _queuedMutations: spannerClient.spanner.v1.Mutation[]; + + /** + * Creates a new Mutation object. + */ + constructor() { + this._queuedMutations = []; + } + + /** + * Adds an insert operation to the mutation set. + * @param {string} table. The name of the table to insert into. + * @param {object|object[]} rows. A single row object or an array of row objects to insert. + */ + insert(table: string, rows: object | object[]): void { + this._queuedMutations.push(buildMutation('insert', table, rows)); + } + + /** + * Adds an update operation to the mutation set. + * @param {string} table. The name of the table to update. + * @param {object|object[]} rows. A single row object or an array of row objects to update. + * Each row object must contain the primary key values to indentify the row to update. + */ + update(table: string, rows: object | object[]): void { + this._queuedMutations.push(buildMutation('update', table, rows)); + } + + /** + * Adds an upsert operation to the mutation set. + * An upsert will insert a new row if it does not exist or update an existing row if it does. + * @param {string} table. The name of the table to upsert. + * @param {object|object[]} rows. A single row object or an array of row objects to upsert. + */ + upsert(table: string, rows: object | object[]): void { + this._queuedMutations.push(buildMutation('insertOrUpdate', table, rows)); + } + + /** + * Adds a replace operation to the mutation set. + * A replace operation deletes the existing row (if it exists) and inserts the new row. + * @param {string} table. The name of the table to replace. + * @param {object|object[]} rows. A single row object or an array of row objects to replace. + */ + replace(table: string, rows: object | object[]): void { + this._queuedMutations.push(buildMutation('replace', table, rows)); + } + + /** + * Adds a deleteRows operation to the mutation set. + * This operation deletes rows from the specified table based on their primary keys. + * @param {string} table. The name of the table to deleteRows from. + * @param {key[]} key. An array of key objects, each represeting the primary key of a row to delete. + */ + deleteRows(table: string, keys: Key[]): void { + this._queuedMutations.push(buildDeleteMutation(table, keys)); + } + + /** + * Returns the internal representation of the queued mutations as a protobuf message. + * @returns {spannerClient.spanner.v1.Mutation[]}. The protobuf message representing the mutations. + */ + proto(): spannerClient.spanner.v1.Mutation[] { + return this._queuedMutations; + } +} + /** * A group of mutations to be committed together. * Related mutations should be placed in a group. diff --git a/test/database.ts b/test/database.ts index 059f704fe..e061b0ef5 100644 --- a/test/database.ts +++ b/test/database.ts @@ -40,7 +40,13 @@ import {protos} from '../src'; import * as inst from '../src/instance'; import RequestOptions = google.spanner.v1.RequestOptions; import EncryptionType = google.spanner.admin.database.v1.RestoreDatabaseEncryptionConfig.EncryptionType; -import {BatchWriteOptions} from '../src/transaction'; +import { + BatchWriteOptions, + CommitCallback, + CommitOptions, + MutationSet, +} from '../src/transaction'; +import {error} from 'is'; let promisified = false; const fakePfy = extend({}, pfy, { @@ -125,10 +131,12 @@ class FakeTable { class FakeTransaction extends EventEmitter { calledWith_: IArguments; _options!: google.spanner.v1.ITransactionOptions; + private _queuedMutations: google.spanner.v1.Mutation[]; constructor(options) { super(); this._options = options; this.calledWith_ = arguments; + this._queuedMutations = []; } begin() {} end() {} @@ -136,6 +144,18 @@ class FakeTransaction extends EventEmitter { return through.obj(); } runUpdate() {} + setQueuedMutations(mutation) { + this._queuedMutations = mutation; + } + commit( + options?: CommitOptions, + callback?: CommitCallback + ): void | Promise { + if (callback) { + callback(null, {commitTimestamp: {seconds: 1, nanos: 0}}); + } + return Promise.resolve({commitTimestamp: {seconds: 1, nanos: 0}}); + } } let fakeTransactionRunner: FakeTransactionRunner; @@ -762,6 +782,89 @@ describe('Database', () => { }); }); + describe('writeAtLeastOnce', () => { + const mutations = new MutationSet(); + mutations.insert('MyTable', { + Key: 'k3', + Thing: 'xyz', + }); + + const SESSION = new FakeSession(); + const RESPONSE = {commitTimestamp: {seconds: 1, nanos: 0}}; + const TRANSACTION = new FakeTransaction( + {} as google.spanner.v1.TransactionOptions.ReadWrite + ); + + let pool: FakeSessionPool; + + beforeEach(() => { + pool = database.pool_; + (sandbox.stub(pool, 'getSession') as sinon.SinonStub).callsFake( + callback => { + callback(null, SESSION, TRANSACTION); + } + ); + }); + + it('should return any errors getting a session', done => { + const fakeErr = new Error('err'); + + (pool.getSession as sinon.SinonStub).callsFake(callback => + callback(fakeErr, null, null) + ); + + database.writeAtLeastOnce(mutations, err => { + assert.deepStrictEqual(err, fakeErr); + done(); + }); + }); + + it('should return successful CommitResponse when passing an empty mutation', done => { + const fakeMutations = new MutationSet(); + try { + database.writeAtLeastOnce(fakeMutations, (err, response) => { + assert.ifError(err); + assert.deepStrictEqual( + response.commitTimestamp, + RESPONSE.commitTimestamp + ); + }); + done(); + } catch (error) { + assert(error instanceof Error); + } + }); + + it('should return an error when passing null mutation', done => { + const fakeError = new Error('err'); + try { + database.writeAtLeastOnce(null, (err, res) => {}); + } catch (err) { + (err as grpc.ServiceError).message.includes( + "Cannot read properties of null (reading 'proto')" + ); + done(); + } + }); + + it('should return CommitResponse on successful write using Callback', done => { + database.writeAtLeastOnce(mutations, (err, res) => { + assert.deepStrictEqual(err, null); + assert.deepStrictEqual(res, RESPONSE); + done(); + }); + }); + + it('should return CommitResponse on successful write using await', async () => { + sinon.stub(database, 'writeAtLeastOnce').resolves([RESPONSE]); + const [response, err] = await database.writeAtLeastOnce(mutations, {}); + assert.deepStrictEqual( + response.commitTimestamp, + RESPONSE.commitTimestamp + ); + }); + }); + describe('close', () => { const FAKE_ID = 'a/c/b/d'; diff --git a/test/spanner.ts b/test/spanner.ts index e2e715710..4acb15fdf 100644 --- a/test/spanner.ts +++ b/test/spanner.ts @@ -20,6 +20,7 @@ import {grpc, Status, ServiceError} from 'google-gax'; import { Database, Instance, + MutationSet, SessionPool, Snapshot, Spanner, @@ -3260,6 +3261,46 @@ describe('Spanner with mock server', () => { assert.strictEqual(commitRequest.mutations.length, 1); }); + it('should apply blind writes only once with mutations', async () => { + const database = newTestDatabase(); + const mutations = new MutationSet(); + mutations.upsert('Singers', { + SingerId: 1, + FirstName: 'Scarlet', + LastName: 'Terry', + }); + mutations.upsert('Singers', { + SingerId: 2, + FirstName: 'Marc', + }); + await database.writeAtLeastOnce(mutations, {}); + await database.close(); + + // Verify that we don't have a BeginTransaction request for the transaction. + const beginTxnRequest = spannerMock.getRequests().find(val => { + return (val as v1.BeginTransactionRequest).options?.readWrite; + }) as v1.BeginTransactionRequest; + assert.deepStrictEqual(beginTxnRequest, undefined); + + // Verify that we have a single Commit request, and that the Commit request + // contains only two mutations and uses a single-use read/write transaction. + assert.strictEqual( + 1, + spannerMock.getRequests().filter(val => { + return (val as v1.CommitRequest).mutations; + }).length + ); + const commitRequest = spannerMock.getRequests().find(val => { + const request = val as v1.CommitRequest; + return request.mutations || request.singleUseTransaction?.readWrite; + }) as v1.CommitRequest; + assert.ok(commitRequest, 'Commit was called'); + assert.strictEqual(commitRequest.mutations.length, 2); + assert.deepStrictEqual(commitRequest.singleUseTransaction?.readWrite, { + readLockMode: 'READ_LOCK_MODE_UNSPECIFIED', + }); + }); + it('should apply blind writes only once with excludeTxnFromChangeStreams option', async () => { const database = newTestDatabase(); await database.runTransactionAsync(