From 3ed6a2adf552159bc8526b096ace59f0d5800c96 Mon Sep 17 00:00:00 2001 From: Neal Beeken Date: Thu, 13 Jun 2024 16:45:33 -0400 Subject: [PATCH] feat(NODE-6136): parse cursor responses on demand (#4112) Co-authored-by: Bailey Pearson --- src/bson.ts | 1 + src/client-side-encryption/auto_encrypter.ts | 79 +------ .../client_encryption.ts | 12 +- src/client-side-encryption/state_machine.ts | 34 +-- src/cmap/connection.ts | 96 ++++---- src/cmap/wire_protocol/on_demand/document.ts | 19 +- src/cmap/wire_protocol/responses.ts | 185 ++++++++++++---- src/constants.ts | 9 + src/cursor/abstract_cursor.ts | 122 +++++------ src/cursor/aggregation_cursor.ts | 25 ++- src/cursor/change_stream_cursor.ts | 54 ++--- src/cursor/find_cursor.ts | 42 ++-- src/cursor/list_collections_cursor.ts | 7 +- src/cursor/list_indexes_cursor.ts | 7 +- src/cursor/run_command_cursor.ts | 32 ++- src/error.ts | 44 ++-- src/index.ts | 13 +- src/operations/aggregate.ts | 17 +- src/operations/bulk_write.ts | 3 +- src/operations/command.ts | 20 +- src/operations/count_documents.ts | 18 +- src/operations/delete.ts | 4 +- src/operations/execute_operation.ts | 13 -- src/operations/find.ts | 10 +- src/operations/find_and_modify.ts | 2 +- src/operations/get_more.ts | 16 +- src/operations/indexes.ts | 10 +- src/operations/list_collections.ts | 11 +- src/operations/run_command.ts | 22 +- src/operations/update.ts | 3 +- src/sdam/server.ts | 9 +- src/utils.ts | 54 ++++- src/write_concern.ts | 18 ++ .../collection-management/collection.test.ts | 207 ++++++++---------- .../crud/abstract_operation.test.ts | 6 +- test/integration/crud/explain.test.ts | 3 + .../non-server-retryable_writes.test.ts | 15 +- .../retryable_writes.spec.prose.test.ts | 33 ++- .../unit/assorted/sessions_collection.test.js | 22 -- .../auto_encrypter.test.ts | 23 +- .../unit/cmap/wire_protocol/responses.test.ts | 90 ++++---- test/unit/error.test.ts | 78 ++++--- 42 files changed, 767 insertions(+), 721 deletions(-) diff --git a/src/bson.ts b/src/bson.ts index 7938a2b173..ce5bd486b7 100644 --- a/src/bson.ts +++ b/src/bson.ts @@ -27,6 +27,7 @@ export { UUID } from 'bson'; +/** @internal */ export type BSONElement = BSON.OnDemand['BSONElement']; export function parseToElementsToArray(bytes: Uint8Array, offset?: number): BSONElement[] { diff --git a/src/client-side-encryption/auto_encrypter.ts b/src/client-side-encryption/auto_encrypter.ts index e6334dc57d..65b5bf7166 100644 --- a/src/client-side-encryption/auto_encrypter.ts +++ b/src/client-side-encryption/auto_encrypter.ts @@ -6,6 +6,7 @@ import { import { deserialize, type Document, serialize } from '../bson'; import { type CommandOptions, type ProxyOptions } from '../cmap/connection'; +import { kDecorateResult } from '../constants'; import { getMongoDBClientEncryption } from '../deps'; import { MongoRuntimeError } from '../error'; import { MongoClient, type MongoClientOptions } from '../mongo_client'; @@ -212,15 +213,6 @@ export const AutoEncryptionLoggerLevel = Object.freeze({ export type AutoEncryptionLoggerLevel = (typeof AutoEncryptionLoggerLevel)[keyof typeof AutoEncryptionLoggerLevel]; -// Typescript errors if we index objects with `Symbol.for(...)`, so -// to avoid TS errors we pull them out into variables. Then we can type -// the objects (and class) that we expect to see them on and prevent TS -// errors. -/** @internal */ -const kDecorateResult = Symbol.for('@@mdb.decorateDecryptionResult'); -/** @internal */ -const kDecoratedKeys = Symbol.for('@@mdb.decryptedKeys'); - /** * @internal An internal class to be used by the driver for auto encryption * **NOTE**: Not meant to be instantiated directly, this is for internal use only. @@ -467,16 +459,18 @@ export class AutoEncrypter { proxyOptions: this._proxyOptions, tlsOptions: this._tlsOptions }); - return await stateMachine.execute(this, context); + + return deserialize(await stateMachine.execute(this, context), { + promoteValues: false, + promoteLongs: false + }); } /** * Decrypt a command response */ - async decrypt(response: Uint8Array | Document, options: CommandOptions = {}): Promise { - const buffer = Buffer.isBuffer(response) ? response : serialize(response, options); - - const context = this._mongocrypt.makeDecryptionContext(buffer); + async decrypt(response: Uint8Array, options: CommandOptions = {}): Promise { + const context = this._mongocrypt.makeDecryptionContext(response); context.id = this._contextCounter++; @@ -486,12 +480,7 @@ export class AutoEncrypter { tlsOptions: this._tlsOptions }); - const decorateResult = this[kDecorateResult]; - const result = await stateMachine.execute(this, context); - if (decorateResult) { - decorateDecryptionResult(result, response); - } - return result; + return await stateMachine.execute(this, context); } /** @@ -518,53 +507,3 @@ export class AutoEncrypter { return AutoEncrypter.getMongoCrypt().libmongocryptVersion; } } - -/** - * Recurse through the (identically-shaped) `decrypted` and `original` - * objects and attach a `decryptedKeys` property on each sub-object that - * contained encrypted fields. Because we only call this on BSON responses, - * we do not need to worry about circular references. - * - * @internal - */ -function decorateDecryptionResult( - decrypted: Document & { [kDecoratedKeys]?: Array }, - original: Document, - isTopLevelDecorateCall = true -): void { - if (isTopLevelDecorateCall) { - // The original value could have been either a JS object or a BSON buffer - if (Buffer.isBuffer(original)) { - original = deserialize(original); - } - if (Buffer.isBuffer(decrypted)) { - throw new MongoRuntimeError('Expected result of decryption to be deserialized BSON object'); - } - } - - if (!decrypted || typeof decrypted !== 'object') return; - for (const k of Object.keys(decrypted)) { - const originalValue = original[k]; - - // An object was decrypted by libmongocrypt if and only if it was - // a BSON Binary object with subtype 6. - if (originalValue && originalValue._bsontype === 'Binary' && originalValue.sub_type === 6) { - if (!decrypted[kDecoratedKeys]) { - Object.defineProperty(decrypted, kDecoratedKeys, { - value: [], - configurable: true, - enumerable: false, - writable: false - }); - } - // this is defined in the preceding if-statement - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - decrypted[kDecoratedKeys]!.push(k); - // Do not recurse into this decrypted value. It could be a sub-document/array, - // in which case there is no original value associated with its subfields. - continue; - } - - decorateDecryptionResult(decrypted[k], originalValue, false); - } -} diff --git a/src/client-side-encryption/client_encryption.ts b/src/client-side-encryption/client_encryption.ts index 1e8d8e3465..49747b6f6e 100644 --- a/src/client-side-encryption/client_encryption.ts +++ b/src/client-side-encryption/client_encryption.ts @@ -5,7 +5,7 @@ import type { MongoCryptOptions } from 'mongodb-client-encryption'; -import { type Binary, type Document, type Long, serialize, type UUID } from '../bson'; +import { type Binary, deserialize, type Document, type Long, serialize, type UUID } from '../bson'; import { type AnyBulkWriteOperation, type BulkWriteResult } from '../bulk/common'; import { type ProxyOptions } from '../cmap/connection'; import { type Collection } from '../collection'; @@ -202,7 +202,7 @@ export class ClientEncryption { tlsOptions: this._tlsOptions }); - const dataKey = await stateMachine.execute(this, context); + const dataKey = deserialize(await stateMachine.execute(this, context)) as DataKey; const { db: dbName, collection: collectionName } = MongoDBCollectionNamespace.fromString( this._keyVaultNamespace @@ -259,7 +259,7 @@ export class ClientEncryption { tlsOptions: this._tlsOptions }); - const { v: dataKeys } = await stateMachine.execute<{ v: DataKey[] }>(this, context); + const { v: dataKeys } = deserialize(await stateMachine.execute(this, context)); if (dataKeys.length === 0) { return {}; } @@ -640,7 +640,7 @@ export class ClientEncryption { tlsOptions: this._tlsOptions }); - const { v } = await stateMachine.execute<{ v: T }>(this, context); + const { v } = deserialize(await stateMachine.execute(this, context)); return v; } @@ -719,8 +719,8 @@ export class ClientEncryption { }); const context = this._mongoCrypt.makeExplicitEncryptionContext(valueBuffer, contextOptions); - const result = await stateMachine.execute<{ v: Binary }>(this, context); - return result.v; + const { v } = deserialize(await stateMachine.execute(this, context)); + return v; } } diff --git a/src/client-side-encryption/state_machine.ts b/src/client-side-encryption/state_machine.ts index ecdf49d513..f0ae19546a 100644 --- a/src/client-side-encryption/state_machine.ts +++ b/src/client-side-encryption/state_machine.ts @@ -114,6 +114,19 @@ export type CSFLEKMSTlsOptions = { [key: string]: ClientEncryptionTlsOptions | undefined; }; +/** + * This is kind of a hack. For `rewrapManyDataKey`, we have tests that + * guarantee that when there are no matching keys, `rewrapManyDataKey` returns + * nothing. We also have tests for auto encryption that guarantee for `encrypt` + * we return an error when there are no matching keys. This error is generated in + * subsequent iterations of the state machine. + * Some apis (`encrypt`) throw if there are no filter matches and others (`rewrapManyDataKey`) + * do not. We set the result manually here, and let the state machine continue. `libmongocrypt` + * will inform us if we need to error by setting the state to `MONGOCRYPT_CTX_ERROR` but + * otherwise we'll return `{ v: [] }`. + */ +let EMPTY_V; + /** * @internal * @@ -156,16 +169,13 @@ export class StateMachine { /** * Executes the state machine according to the specification */ - async execute( - executor: StateMachineExecutable, - context: MongoCryptContext - ): Promise { + async execute(executor: StateMachineExecutable, context: MongoCryptContext): Promise { const keyVaultNamespace = executor._keyVaultNamespace; const keyVaultClient = executor._keyVaultClient; const metaDataClient = executor._metaDataClient; const mongocryptdClient = executor._mongocryptdClient; const mongocryptdManager = executor._mongocryptdManager; - let result: T | null = null; + let result: Uint8Array | null = null; while (context.state !== MONGOCRYPT_CTX_DONE && context.state !== MONGOCRYPT_CTX_ERROR) { debug(`[context#${context.id}] ${stateToString.get(context.state) || context.state}`); @@ -213,16 +223,8 @@ export class StateMachine { const keys = await this.fetchKeys(keyVaultClient, keyVaultNamespace, filter); if (keys.length === 0) { - // This is kind of a hack. For `rewrapManyDataKey`, we have tests that - // guarantee that when there are no matching keys, `rewrapManyDataKey` returns - // nothing. We also have tests for auto encryption that guarantee for `encrypt` - // we return an error when there are no matching keys. This error is generated in - // subsequent iterations of the state machine. - // Some apis (`encrypt`) throw if there are no filter matches and others (`rewrapManyDataKey`) - // do not. We set the result manually here, and let the state machine continue. `libmongocrypt` - // will inform us if we need to error by setting the state to `MONGOCRYPT_CTX_ERROR` but - // otherwise we'll return `{ v: [] }`. - result = { v: [] } as any as T; + // See docs on EMPTY_V + result = EMPTY_V ??= serialize({ v: [] }); } for await (const key of keys) { context.addMongoOperationResponse(serialize(key)); @@ -254,7 +256,7 @@ export class StateMachine { const message = context.status.message || 'Finalization error'; throw new MongoCryptError(message); } - result = deserialize(finalizedContext, this.options) as T; + result = finalizedContext; break; } diff --git a/src/cmap/connection.ts b/src/cmap/connection.ts index c6420d8306..1e4afc7f38 100644 --- a/src/cmap/connection.ts +++ b/src/cmap/connection.ts @@ -1,14 +1,15 @@ import { type Readable, Transform, type TransformCallback } from 'stream'; import { clearTimeout, setTimeout } from 'timers'; -import type { BSONSerializeOptions, Document, ObjectId } from '../bson'; -import type { AutoEncrypter } from '../client-side-encryption/auto_encrypter'; +import { type BSONSerializeOptions, deserialize, type Document, type ObjectId } from '../bson'; +import { type AutoEncrypter } from '../client-side-encryption/auto_encrypter'; import { CLOSE, CLUSTER_TIME_RECEIVED, COMMAND_FAILED, COMMAND_STARTED, COMMAND_SUCCEEDED, + kDecorateResult, PINNED, UNPINNED } from '../constants'; @@ -19,8 +20,7 @@ import { MongoNetworkTimeoutError, MongoParseError, MongoServerError, - MongoUnexpectedServerResponseError, - MongoWriteConcernError + MongoUnexpectedServerResponseError } from '../error'; import type { ServerApi, SupportedNodeConnectionOptions } from '../mongo_client'; import { type MongoClientAuthProviders } from '../mongo_client_auth_providers'; @@ -33,6 +33,7 @@ import { BufferPool, calculateDurationInMs, type Callback, + decorateDecryptionResult, HostAddress, maxWireVersion, type MongoDBNamespace, @@ -63,7 +64,7 @@ import { StreamDescription, type StreamDescriptionOptions } from './stream_descr import { type CompressorName, decompressResponse } from './wire_protocol/compression'; import { onData } from './wire_protocol/on_data'; import { - isErrorResponse, + CursorResponse, MongoDBResponse, type MongoDBResponseConstructor } from './wire_protocol/responses'; @@ -448,12 +449,7 @@ export class Connection extends TypedEventEmitter { this.socket.setTimeout(0); const bson = response.parse(); - const document = - responseType == null - ? new MongoDBResponse(bson) - : isErrorResponse(bson) - ? new MongoDBResponse(bson) - : new responseType(bson); + const document = (responseType ?? MongoDBResponse).make(bson); yield document; this.throwIfAborted(); @@ -517,12 +513,7 @@ export class Connection extends TypedEventEmitter { this.emit(Connection.CLUSTER_TIME_RECEIVED, document.$clusterTime); } - if (document.has('writeConcernError')) { - object ??= document.toObject(bsonOptions); - throw new MongoWriteConcernError(object.writeConcernError, object); - } - - if (document.isError) { + if (document.ok === 0) { throw new MongoServerError((object ??= document.toObject(bsonOptions))); } @@ -552,40 +543,25 @@ export class Connection extends TypedEventEmitter { } } catch (error) { if (this.shouldEmitAndLogCommand) { - if (error.name === 'MongoWriteConcernError') { - this.emitAndLogCommand( - this.monitorCommands, - Connection.COMMAND_SUCCEEDED, - message.databaseName, - this.established, - new CommandSucceededEvent( - this, - message, - options.noResponse ? undefined : (object ??= document?.toObject(bsonOptions)), - started, - this.description.serverConnectionId - ) - ); - } else { - this.emitAndLogCommand( - this.monitorCommands, - Connection.COMMAND_FAILED, - message.databaseName, - this.established, - new CommandFailedEvent( - this, - message, - error, - started, - this.description.serverConnectionId - ) - ); - } + this.emitAndLogCommand( + this.monitorCommands, + Connection.COMMAND_FAILED, + message.databaseName, + this.established, + new CommandFailedEvent(this, message, error, started, this.description.serverConnectionId) + ); } throw error; } } + public async command( + ns: MongoDBNamespace, + command: Document, + options: CommandOptions | undefined, + responseType: T + ): Promise>; + public async command( ns: MongoDBNamespace, command: Document, @@ -749,7 +725,7 @@ export class CryptoConnection extends Connection { ns: MongoDBNamespace, cmd: Document, options?: CommandOptions, - _responseType?: T | undefined + responseType?: T | undefined ): Promise { const { autoEncrypter } = this; if (!autoEncrypter) { @@ -763,7 +739,7 @@ export class CryptoConnection extends Connection { const serverWireVersion = maxWireVersion(this); if (serverWireVersion === 0) { // This means the initial handshake hasn't happened yet - return await super.command(ns, cmd, options, undefined); + return await super.command(ns, cmd, options, responseType); } if (serverWireVersion < 8) { @@ -797,8 +773,28 @@ export class CryptoConnection extends Connection { } } - const response = await super.command(ns, encrypted, options, undefined); + const encryptedResponse = await super.command( + ns, + encrypted, + options, + // Eventually we want to require `responseType` which means we would satisfy `T` as the return type. + // In the meantime, we want encryptedResponse to always be _at least_ a MongoDBResponse if not a more specific subclass + // So that we can ensure we have access to the on-demand APIs for decorate response + responseType ?? MongoDBResponse + ); + + const result = await autoEncrypter.decrypt(encryptedResponse.toBytes(), options); + + const decryptedResponse = responseType?.make(result) ?? deserialize(result, options); + + if (autoEncrypter[kDecorateResult]) { + if (responseType == null) { + decorateDecryptionResult(decryptedResponse, encryptedResponse.toObject(), true); + } else if (decryptedResponse instanceof CursorResponse) { + decryptedResponse.encryptedResponse = encryptedResponse; + } + } - return await autoEncrypter.decrypt(response, options); + return decryptedResponse; } } diff --git a/src/cmap/wire_protocol/on_demand/document.ts b/src/cmap/wire_protocol/on_demand/document.ts index 638946d647..944916f10b 100644 --- a/src/cmap/wire_protocol/on_demand/document.ts +++ b/src/cmap/wire_protocol/on_demand/document.ts @@ -66,9 +66,11 @@ export class OnDemandDocument { /** The start of the document */ private readonly offset = 0, /** If this is an embedded document, indicates if this was a BSON array */ - public readonly isArray = false + public readonly isArray = false, + /** If elements was already calculated */ + elements?: BSONElement[] ) { - this.elements = parseToElementsToArray(this.bson, offset); + this.elements = elements ?? parseToElementsToArray(this.bson, offset); } /** Only supports basic latin strings */ @@ -78,8 +80,13 @@ export class OnDemandDocument { if (name.length !== nameLength) return false; - for (let i = 0; i < name.length; i++) { - if (this.bson[nameOffset + i] !== name.charCodeAt(i)) return false; + const nameEnd = nameOffset + nameLength; + for ( + let byteIndex = nameOffset, charIndex = 0; + charIndex < name.length && byteIndex < nameEnd; + charIndex++, byteIndex++ + ) { + if (this.bson[byteIndex] !== name.charCodeAt(charIndex)) return false; } return true; @@ -125,7 +132,7 @@ export class OnDemandDocument { const element = this.elements[index]; // skip this element if it has already been associated with a name - if (!this.indexFound[index] && this.isElementName(name, element)) { + if (!(index in this.indexFound) && this.isElementName(name, element)) { const cachedElement = { element, value: undefined }; this.cache[name] = cachedElement; this.indexFound[index] = true; @@ -247,7 +254,7 @@ export class OnDemandDocument { public get( name: string | number, as: T, - required?: false | undefined + required?: boolean | undefined ): JSTypeOf[T] | null; /** `required` will make `get` throw if name does not exist or is null/undefined */ diff --git a/src/cmap/wire_protocol/responses.ts b/src/cmap/wire_protocol/responses.ts index 65515cbb31..0ef048e8da 100644 --- a/src/cmap/wire_protocol/responses.ts +++ b/src/cmap/wire_protocol/responses.ts @@ -1,15 +1,17 @@ import { + type BSONElement, type BSONSerializeOptions, BSONType, type Document, Long, parseToElementsToArray, + pluckBSONSerializeOptions, type Timestamp } from '../../bson'; import { MongoUnexpectedServerResponseError } from '../../error'; import { type ClusterTime } from '../../sdam/common'; -import { type MongoDBNamespace, ns } from '../../utils'; -import { OnDemandDocument } from './on_demand/document'; +import { decorateDecryptionResult, ns } from '../../utils'; +import { type JSTypeOf, OnDemandDocument } from './on_demand/document'; // eslint-disable-next-line no-restricted-syntax const enum BSONElementOffset { @@ -30,8 +32,7 @@ const enum BSONElementOffset { * * @param bytes - BSON document returned from the server */ -export function isErrorResponse(bson: Uint8Array): boolean { - const elements = parseToElementsToArray(bson, 0); +export function isErrorResponse(bson: Uint8Array, elements: BSONElement[]): boolean { for (let eIdx = 0; eIdx < elements.length; eIdx++) { const element = elements[eIdx]; @@ -60,26 +61,49 @@ export function isErrorResponse(bson: Uint8Array): boolean { /** @internal */ export type MongoDBResponseConstructor = { new (bson: Uint8Array, offset?: number, isArray?: boolean): MongoDBResponse; + make(bson: Uint8Array): MongoDBResponse; }; /** @internal */ export class MongoDBResponse extends OnDemandDocument { + // Wrap error thrown from BSON + public override get( + name: string | number, + as: T, + required?: false | undefined + ): JSTypeOf[T] | null; + public override get( + name: string | number, + as: T, + required: true + ): JSTypeOf[T]; + public override get( + name: string | number, + as: T, + required?: boolean | undefined + ): JSTypeOf[T] | null { + try { + return super.get(name, as, required); + } catch (cause) { + throw new MongoUnexpectedServerResponseError(cause.message, { cause }); + } + } + static is(value: unknown): value is MongoDBResponse { return value instanceof MongoDBResponse; } + static make(bson: Uint8Array) { + const elements = parseToElementsToArray(bson, 0); + const isError = isErrorResponse(bson, elements); + return isError + ? new MongoDBResponse(bson, 0, false, elements) + : new this(bson, 0, false, elements); + } + // {ok:1} static empty = new MongoDBResponse(new Uint8Array([13, 0, 0, 0, 16, 111, 107, 0, 1, 0, 0, 0, 0])); - /** Indicates this document is a server error */ - public get isError() { - let isError = this.ok === 0; - isError ||= this.has('errmsg'); - isError ||= this.has('code'); - isError ||= this.has('$err'); // The '$err' field is used in OP_REPLY responses - return isError; - } - /** * Drivers can safely assume that the `recoveryToken` field is always a BSON document but drivers MUST NOT modify the * contents of the document. @@ -110,6 +134,7 @@ export class MongoDBResponse extends OnDemandDocument { return this.get('operationTime', BSONType.timestamp); } + /** Normalizes whatever BSON value is "ok" to a JS number 1 or 0. */ public get ok(): 0 | 1 { return this.getNumber('ok') ? 1 : 0; } @@ -144,13 +169,7 @@ export class MongoDBResponse extends OnDemandDocument { public override toObject(options?: BSONSerializeOptions): Record { const exactBSONOptions = { - useBigInt64: options?.useBigInt64, - promoteLongs: options?.promoteLongs, - promoteValues: options?.promoteValues, - promoteBuffers: options?.promoteBuffers, - bsonRegExp: options?.bsonRegExp, - raw: options?.raw ?? false, - fieldsAsRaw: options?.fieldsAsRaw ?? {}, + ...pluckBSONSerializeOptions(options ?? {}), validation: this.parseBsonSerializationOptions(options) }; return super.toObject(exactBSONOptions); @@ -169,69 +188,145 @@ export class MongoDBResponse extends OnDemandDocument { /** @internal */ export class CursorResponse extends MongoDBResponse { + /** + * Devtools need to know which keys were encrypted before the driver automatically decrypted them. + * If decorating is enabled (`Symbol.for('@@mdb.decorateDecryptionResult')`), this field will be set, + * storing the original encrypted response from the server, so that we can build an object that has + * the list of BSON keys that were encrypted stored at a well known symbol: `Symbol.for('@@mdb.decryptedKeys')`. + */ + encryptedResponse?: MongoDBResponse; /** * This supports a feature of the FindCursor. * It is an optimization to avoid an extra getMore when the limit has been reached */ - static emptyGetMore = { id: new Long(0), length: 0, shift: () => null }; + static emptyGetMore: CursorResponse = { + id: new Long(0), + length: 0, + shift: () => null + } as unknown as CursorResponse; static override is(value: unknown): value is CursorResponse { return value instanceof CursorResponse || value === CursorResponse.emptyGetMore; } - public id: Long; - public ns: MongoDBNamespace | null = null; - public batchSize = 0; - - private batch: OnDemandDocument; + private _batch: OnDemandDocument | null = null; private iterated = 0; - constructor(bytes: Uint8Array, offset?: number, isArray?: boolean) { - super(bytes, offset, isArray); + get cursor() { + return this.get('cursor', BSONType.object, true); + } - const cursor = this.get('cursor', BSONType.object, true); + public get id(): Long { + try { + return Long.fromBigInt(this.cursor.get('id', BSONType.long, true)); + } catch (cause) { + throw new MongoUnexpectedServerResponseError(cause.message, { cause }); + } + } - const id = cursor.get('id', BSONType.long, true); - this.id = new Long(Number(id & 0xffff_ffffn), Number((id >> 32n) & 0xffff_ffffn)); + public get ns() { + const namespace = this.cursor.get('ns', BSONType.string); + if (namespace != null) return ns(namespace); + return null; + } + + public get length() { + return Math.max(this.batchSize - this.iterated, 0); + } - const namespace = cursor.get('ns', BSONType.string); - if (namespace != null) this.ns = ns(namespace); + private _encryptedBatch: OnDemandDocument | null = null; + get encryptedBatch() { + if (this.encryptedResponse == null) return null; + if (this._encryptedBatch != null) return this._encryptedBatch; - if (cursor.has('firstBatch')) this.batch = cursor.get('firstBatch', BSONType.array, true); - else if (cursor.has('nextBatch')) this.batch = cursor.get('nextBatch', BSONType.array, true); + const cursor = this.encryptedResponse?.get('cursor', BSONType.object); + if (cursor?.has('firstBatch')) + this._encryptedBatch = cursor.get('firstBatch', BSONType.array, true); + else if (cursor?.has('nextBatch')) + this._encryptedBatch = cursor.get('nextBatch', BSONType.array, true); else throw new MongoUnexpectedServerResponseError('Cursor document did not contain a batch'); - this.batchSize = this.batch.size(); + return this._encryptedBatch; } - get length() { - return Math.max(this.batchSize - this.iterated, 0); + private get batch() { + if (this._batch != null) return this._batch; + const cursor = this.cursor; + if (cursor.has('firstBatch')) this._batch = cursor.get('firstBatch', BSONType.array, true); + else if (cursor.has('nextBatch')) this._batch = cursor.get('nextBatch', BSONType.array, true); + else throw new MongoUnexpectedServerResponseError('Cursor document did not contain a batch'); + return this._batch; + } + + public get batchSize() { + return this.batch?.size(); } - shift(options?: BSONSerializeOptions): any { + public get postBatchResumeToken() { + return ( + this.cursor.get('postBatchResumeToken', BSONType.object)?.toObject({ + promoteValues: false, + promoteLongs: false, + promoteBuffers: false + }) ?? null + ); + } + + public shift(options?: BSONSerializeOptions): any { if (this.iterated >= this.batchSize) { return null; } const result = this.batch.get(this.iterated, BSONType.object, true) ?? null; + const encryptedResult = this.encryptedBatch?.get(this.iterated, BSONType.object, true) ?? null; + this.iterated += 1; if (options?.raw) { return result.toBytes(); } else { - return result.toObject(options); + const object = result.toObject(options); + if (encryptedResult) { + decorateDecryptionResult(object, encryptedResult.toObject(options), true); + } + return object; } } - clear() { + public clear() { this.iterated = this.batchSize; } +} + +/** + * Explain responses have nothing to do with cursor responses + * This class serves to temporarily avoid refactoring how cursors handle + * explain responses which is to detect that the response is not cursor-like and return the explain + * result as the "first and only" document in the "batch" and end the "cursor" + */ +export class ExplainedCursorResponse extends CursorResponse { + isExplain = true; + + override get id(): Long { + return Long.fromBigInt(0n); + } + + override get batchSize() { + return 0; + } + + override get ns() { + return null; + } - pushMany() { - throw new Error('pushMany Unsupported method'); + _length = 1; + override get length(): number { + return this._length; } - push() { - throw new Error('push Unsupported method'); + override shift(options?: BSONSerializeOptions | undefined) { + if (this._length === 0) return null; + this._length -= 1; + return this.toObject(options); } } diff --git a/src/constants.ts b/src/constants.ts index 293749cad8..abb69509f9 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -165,3 +165,12 @@ export const LEGACY_HELLO_COMMAND = 'ismaster'; * The legacy hello command that was deprecated in MongoDB 5.0. */ export const LEGACY_HELLO_COMMAND_CAMEL_CASE = 'isMaster'; + +// Typescript errors if we index objects with `Symbol.for(...)`, so +// to avoid TS errors we pull them out into variables. Then we can type +// the objects (and class) that we expect to see them on and prevent TS +// errors. +/** @internal */ +export const kDecorateResult = Symbol.for('@@mdb.decorateDecryptionResult'); +/** @internal */ +export const kDecoratedKeys = Symbol.for('@@mdb.decryptedKeys'); diff --git a/src/cursor/abstract_cursor.ts b/src/cursor/abstract_cursor.ts index c6e45a91fc..cac7e8f493 100644 --- a/src/cursor/abstract_cursor.ts +++ b/src/cursor/abstract_cursor.ts @@ -1,7 +1,7 @@ import { Readable, Transform } from 'stream'; import { type BSONSerializeOptions, type Document, Long, pluckBSONSerializeOptions } from '../bson'; -import { CursorResponse } from '../cmap/wire_protocol/responses'; +import { type CursorResponse } from '../cmap/wire_protocol/responses'; import { MongoAPIError, MongoCursorExhaustedError, @@ -11,15 +11,33 @@ import { MongoTailableCursorError } from '../error'; import type { MongoClient } from '../mongo_client'; -import { type TODO_NODE_3286, TypedEventEmitter } from '../mongo_types'; -import { executeOperation, type ExecutionResult } from '../operations/execute_operation'; +import { TypedEventEmitter } from '../mongo_types'; +import { executeOperation } from '../operations/execute_operation'; import { GetMoreOperation } from '../operations/get_more'; import { KillCursorsOperation } from '../operations/kill_cursors'; import { ReadConcern, type ReadConcernLike } from '../read_concern'; import { ReadPreference, type ReadPreferenceLike } from '../read_preference'; import type { Server } from '../sdam/server'; import { ClientSession, maybeClearPinnedConnection } from '../sessions'; -import { List, type MongoDBNamespace, ns, squashError } from '../utils'; +import { type MongoDBNamespace, squashError } from '../utils'; + +/** + * @internal + * TODO(NODE-2882): A cursor's getMore commands must be run on the same server it was started on + * and the same session must be used for the lifetime of the cursor. This object serves to get the + * server and session (along with the response) out of executeOperation back to the AbstractCursor. + * + * There may be a better design for communicating these values back to the cursor, currently an operation + * MUST store the selected server on itself so it can be read after executeOperation has returned. + */ +export interface InitialCursorResponse { + /** The server selected for the operation */ + server: Server; + /** The session used for this operation, may be implicitly created */ + session?: ClientSession; + /** The raw server response for the operation */ + response: CursorResponse; +} /** @public */ export const CURSOR_FLAGS = [ @@ -118,13 +136,7 @@ export abstract class AbstractCursor< /** @internal */ private cursorNamespace: MongoDBNamespace; /** @internal */ - private documents: { - length: number; - shift(bsonOptions?: any): TSchema | null; - clear(): void; - pushMany(many: Iterable): void; - push(item: TSchema): void; - }; + private documents: CursorResponse | null = null; /** @internal */ private cursorClient: MongoClient; /** @internal */ @@ -155,7 +167,6 @@ export abstract class AbstractCursor< this.cursorClient = client; this.cursorNamespace = namespace; this.cursorId = null; - this.documents = new List(); this.initialized = false; this.isClosed = false; this.isKilled = false; @@ -252,16 +263,19 @@ export abstract class AbstractCursor< /** Returns current buffered documents length */ bufferedCount(): number { - return this.documents.length; + return this.documents?.length ?? 0; } /** Returns current buffered documents */ readBufferedDocuments(number?: number): TSchema[] { const bufferedDocs: TSchema[] = []; - const documentsToRead = Math.min(number ?? this.documents.length, this.documents.length); + const documentsToRead = Math.min( + number ?? this.documents?.length ?? 0, + this.documents?.length ?? 0 + ); for (let count = 0; count < documentsToRead; count++) { - const document = this.documents.shift(this.cursorOptions); + const document = this.documents?.shift(this.cursorOptions); if (document != null) { bufferedDocs.push(document); } @@ -269,7 +283,6 @@ export abstract class AbstractCursor< return bufferedDocs; } - async *[Symbol.asyncIterator](): AsyncGenerator { if (this.isClosed) { return; @@ -281,11 +294,11 @@ export abstract class AbstractCursor< return; } - if (this.isClosed && this.documents.length === 0) { + if (this.closed && (this.documents?.length ?? 0) === 0) { return; } - if (this.cursorId != null && this.isDead && this.documents.length === 0) { + if (this.cursorId != null && this.isDead && (this.documents?.length ?? 0) === 0) { return; } @@ -347,11 +360,11 @@ export abstract class AbstractCursor< } do { - if (this.documents.length !== 0) { + if ((this.documents?.length ?? 0) !== 0) { return true; } await this.fetchBatch(); - } while (!this.isDead || this.documents.length !== 0); + } while (!this.isDead || (this.documents?.length ?? 0) !== 0); return false; } @@ -363,13 +376,13 @@ export abstract class AbstractCursor< } do { - const doc = this.documents.shift(); + const doc = this.documents?.shift(this.cursorOptions); if (doc != null) { if (this.transform != null) return await this.transformDocument(doc); return doc; } await this.fetchBatch(); - } while (!this.isDead || this.documents.length !== 0); + } while (!this.isDead || (this.documents?.length ?? 0) !== 0); return null; } @@ -382,7 +395,7 @@ export abstract class AbstractCursor< throw new MongoCursorExhaustedError(); } - let doc = this.documents.shift(); + let doc = this.documents?.shift(this.cursorOptions); if (doc != null) { if (this.transform != null) return await this.transformDocument(doc); return doc; @@ -390,7 +403,7 @@ export abstract class AbstractCursor< await this.fetchBatch(); - doc = this.documents.shift(); + doc = this.documents?.shift(this.cursorOptions); if (doc != null) { if (this.transform != null) return await this.transformDocument(doc); return doc; @@ -591,7 +604,7 @@ export abstract class AbstractCursor< } this.cursorId = null; - this.documents.clear(); + this.documents?.clear(); this.isClosed = false; this.isKilled = false; this.initialized = false; @@ -615,10 +628,12 @@ export abstract class AbstractCursor< abstract clone(): AbstractCursor; /** @internal */ - protected abstract _initialize(session: ClientSession | undefined): Promise; + protected abstract _initialize( + session: ClientSession | undefined + ): Promise; /** @internal */ - async getMore(batchSize: number, useCursorResponse = false): Promise { + async getMore(batchSize: number): Promise { if (this.cursorId == null) { throw new MongoRuntimeError( 'Unexpected null cursor id. A cursor creating command should have set this' @@ -636,8 +651,7 @@ export abstract class AbstractCursor< { ...this.cursorOptions, session: this.cursorSession, - batchSize, - useCursorResponse + batchSize } ); @@ -656,27 +670,10 @@ export abstract class AbstractCursor< const state = await this._initialize(this.cursorSession); const response = state.response; this.selectedServer = state.server; - if (CursorResponse.is(response)) { - this.cursorId = response.id; - if (response.ns) this.cursorNamespace = response.ns; - this.documents = response; - } else if (response.cursor) { - // TODO(NODE-2674): Preserve int64 sent from MongoDB - this.cursorId = getCursorId(response); - if (response.cursor.ns) this.cursorNamespace = ns(response.cursor.ns); - this.documents.pushMany(response.cursor.firstBatch); - } - - if (this.cursorId == null) { - // When server responses return without a cursor document, we close this cursor - // and return the raw server response. This is the case for explain commands - this.cursorId = Long.ZERO; - // TODO(NODE-3286): ExecutionResult needs to accept a generic parameter - this.documents.push(state.response as TODO_NODE_3286); - } - - // the cursor is now initialized, even if it is dead - this.initialized = true; + this.cursorId = response.id; + this.cursorNamespace = response.ns ?? this.namespace; + this.documents = response; + this.initialized = true; // the cursor is now initialized, even if it is dead } catch (error) { // the cursor is now initialized, even if an error occurred this.initialized = true; @@ -708,7 +705,7 @@ export abstract class AbstractCursor< if (this.cursorId == null) { await this.cursorInit(); // If the cursor died or returned documents, return - if (this.documents.length !== 0 || this.isDead) return; + if ((this.documents?.length ?? 0) !== 0 || this.isDead) return; // Otherwise, run a getMore } @@ -717,16 +714,8 @@ export abstract class AbstractCursor< try { const response = await this.getMore(batchSize); - // CursorResponse is disabled in this PR - // however the special `emptyGetMore` can be returned from find cursors - if (CursorResponse.is(response)) { - this.cursorId = response.id; - this.documents = response; - } else if (response?.cursor) { - const cursorId = getCursorId(response); - this.documents.pushMany(response.cursor.nextBatch); - this.cursorId = cursorId; - } + this.cursorId = response.id; + this.documents = response; } catch (error) { try { await this.cleanup(error); @@ -789,7 +778,7 @@ export abstract class AbstractCursor< /** @internal */ private emitClose() { try { - if (!this.hasEmittedClose && (this.documents.length === 0 || this.isClosed)) { + if (!this.hasEmittedClose && ((this.documents?.length ?? 0) === 0 || this.isClosed)) { // @ts-expect-error: CursorEvents is generic so Parameters may not be assignable to `[]`. Not sure how to require extenders do not add parameters. this.emit('close'); } @@ -827,15 +816,6 @@ export abstract class AbstractCursor< } } -/** A temporary helper to box up the many possible type issue of cursor ids */ -function getCursorId(response: Document) { - return typeof response.cursor.id === 'number' - ? Long.fromNumber(response.cursor.id) - : typeof response.cursor.id === 'bigint' - ? Long.fromBigInt(response.cursor.id) - : response.cursor.id; -} - class ReadableCursorStream extends Readable { private _cursor: AbstractCursor; private _readInProgress = false; diff --git a/src/cursor/aggregation_cursor.ts b/src/cursor/aggregation_cursor.ts index 1c23b3e1a4..1b1ad663ba 100644 --- a/src/cursor/aggregation_cursor.ts +++ b/src/cursor/aggregation_cursor.ts @@ -2,12 +2,12 @@ import type { Document } from '../bson'; import type { ExplainVerbosityLike } from '../explain'; import type { MongoClient } from '../mongo_client'; import { AggregateOperation, type AggregateOptions } from '../operations/aggregate'; -import { executeOperation, type ExecutionResult } from '../operations/execute_operation'; +import { executeOperation } from '../operations/execute_operation'; import type { ClientSession } from '../sessions'; import type { Sort } from '../sort'; import type { MongoDBNamespace } from '../utils'; import { mergeOptions } from '../utils'; -import type { AbstractCursorOptions } from './abstract_cursor'; +import type { AbstractCursorOptions, InitialCursorResponse } from './abstract_cursor'; import { AbstractCursor } from './abstract_cursor'; /** @public */ @@ -51,7 +51,7 @@ export class AggregationCursor extends AbstractCursor { } /** @internal */ - async _initialize(session: ClientSession): Promise { + async _initialize(session: ClientSession): Promise { const aggregateOperation = new AggregateOperation(this.namespace, this.pipeline, { ...this.aggregateOptions, ...this.cursorOptions, @@ -60,20 +60,21 @@ export class AggregationCursor extends AbstractCursor { const response = await executeOperation(this.client, aggregateOperation); - // TODO: NODE-2882 return { server: aggregateOperation.server, session, response }; } /** Execute the explain for the cursor */ async explain(verbosity?: ExplainVerbosityLike): Promise { - return await executeOperation( - this.client, - new AggregateOperation(this.namespace, this.pipeline, { - ...this.aggregateOptions, // NOTE: order matters here, we may need to refine this - ...this.cursorOptions, - explain: verbosity ?? true - }) - ); + return ( + await executeOperation( + this.client, + new AggregateOperation(this.namespace, this.pipeline, { + ...this.aggregateOptions, // NOTE: order matters here, we may need to refine this + ...this.cursorOptions, + explain: verbosity ?? true + }) + ) + ).shift(this.aggregateOptions); } /** Add a stage to the aggregation pipeline diff --git a/src/cursor/change_stream_cursor.ts b/src/cursor/change_stream_cursor.ts index df4e4895cf..b42ce3e130 100644 --- a/src/cursor/change_stream_cursor.ts +++ b/src/cursor/change_stream_cursor.ts @@ -1,4 +1,4 @@ -import type { Document, Long, Timestamp } from '../bson'; +import type { Document } from '../bson'; import { ChangeStream, type ChangeStreamDocument, @@ -6,15 +6,19 @@ import { type OperationTime, type ResumeToken } from '../change_stream'; +import { type CursorResponse } from '../cmap/wire_protocol/responses'; import { INIT, RESPONSE } from '../constants'; import type { MongoClient } from '../mongo_client'; -import type { TODO_NODE_3286 } from '../mongo_types'; import { AggregateOperation } from '../operations/aggregate'; import type { CollationOptions } from '../operations/command'; -import { executeOperation, type ExecutionResult } from '../operations/execute_operation'; +import { executeOperation } from '../operations/execute_operation'; import type { ClientSession } from '../sessions'; import { maxWireVersion, type MongoDBNamespace } from '../utils'; -import { AbstractCursor, type AbstractCursorOptions } from './abstract_cursor'; +import { + AbstractCursor, + type AbstractCursorOptions, + type InitialCursorResponse +} from './abstract_cursor'; /** @internal */ export interface ChangeStreamCursorOptions extends AbstractCursorOptions { @@ -26,25 +30,13 @@ export interface ChangeStreamCursorOptions extends AbstractCursorOptions { fullDocument?: string; } -/** @internal */ -export type ChangeStreamAggregateRawResult = { - $clusterTime: { clusterTime: Timestamp }; - cursor: { - postBatchResumeToken: ResumeToken; - ns: string; - id: number | Long; - } & ({ firstBatch: TChange[] } | { nextBatch: TChange[] }); - ok: 1; - operationTime: Timestamp; -}; - /** @internal */ export class ChangeStreamCursor< TSchema extends Document = Document, TChange extends Document = ChangeStreamDocument > extends AbstractCursor { private _resumeToken: ResumeToken; - private startAtOperationTime?: OperationTime; + private startAtOperationTime: OperationTime | null; private hasReceived?: boolean; private readonly changeStreamCursorOptions: ChangeStreamCursorOptions; private postBatchResumeToken?: ResumeToken; @@ -68,7 +60,7 @@ export class ChangeStreamCursor< this.pipeline = pipeline; this.changeStreamCursorOptions = options; this._resumeToken = null; - this.startAtOperationTime = options.startAtOperationTime; + this.startAtOperationTime = options.startAtOperationTime ?? null; if (options.startAfter) { this.resumeToken = options.startAfter; @@ -117,15 +109,13 @@ export class ChangeStreamCursor< this.hasReceived = true; } - _processBatch(response: ChangeStreamAggregateRawResult): void { - const cursor = response.cursor; - if (cursor.postBatchResumeToken) { - this.postBatchResumeToken = response.cursor.postBatchResumeToken; + _processBatch(response: CursorResponse): void { + const { postBatchResumeToken } = response; + if (postBatchResumeToken) { + this.postBatchResumeToken = postBatchResumeToken; - const batch = - 'firstBatch' in response.cursor ? response.cursor.firstBatch : response.cursor.nextBatch; - if (batch.length === 0) { - this.resumeToken = cursor.postBatchResumeToken; + if (response.batchSize === 0) { + this.resumeToken = postBatchResumeToken; } } } @@ -136,17 +126,14 @@ export class ChangeStreamCursor< }); } - async _initialize(session: ClientSession): Promise { + async _initialize(session: ClientSession): Promise { const aggregateOperation = new AggregateOperation(this.namespace, this.pipeline, { ...this.cursorOptions, ...this.changeStreamCursorOptions, session }); - const response = await executeOperation< - TODO_NODE_3286, - ChangeStreamAggregateRawResult - >(session.client, aggregateOperation); + const response = await executeOperation(session.client, aggregateOperation); const server = aggregateOperation.server; this.maxWireVersion = maxWireVersion(server); @@ -165,15 +152,14 @@ export class ChangeStreamCursor< this.emit(INIT, response); this.emit(RESPONSE); - // TODO: NODE-2882 return { server, session, response }; } - override async getMore(batchSize: number): Promise { + override async getMore(batchSize: number): Promise { const response = await super.getMore(batchSize); this.maxWireVersion = maxWireVersion(this.server); - this._processBatch(response as ChangeStreamAggregateRawResult); + this._processBatch(response); this.emit(ChangeStream.MORE, response); this.emit(ChangeStream.RESPONSE); diff --git a/src/cursor/find_cursor.ts b/src/cursor/find_cursor.ts index e779af69cb..e4b3dbc03c 100644 --- a/src/cursor/find_cursor.ts +++ b/src/cursor/find_cursor.ts @@ -5,13 +5,13 @@ import { type ExplainVerbosityLike } from '../explain'; import type { MongoClient } from '../mongo_client'; import type { CollationOptions } from '../operations/command'; import { CountOperation, type CountOptions } from '../operations/count'; -import { executeOperation, type ExecutionResult } from '../operations/execute_operation'; +import { executeOperation } from '../operations/execute_operation'; import { FindOperation, type FindOptions } from '../operations/find'; import type { Hint } from '../operations/operation'; import type { ClientSession } from '../sessions'; import { formatSort, type Sort, type SortDirection } from '../sort'; import { emitWarningOnce, mergeOptions, type MongoDBNamespace, squashError } from '../utils'; -import { AbstractCursor } from './abstract_cursor'; +import { AbstractCursor, type InitialCursorResponse } from './abstract_cursor'; /** @public Flags allowed for cursor */ export const FLAGS = [ @@ -62,7 +62,7 @@ export class FindCursor extends AbstractCursor { } /** @internal */ - async _initialize(session: ClientSession): Promise { + async _initialize(session: ClientSession): Promise { const findOperation = new FindOperation(this.namespace, this.cursorFilter, { ...this.findOptions, // NOTE: order matters here, we may need to refine this ...this.cursorOptions, @@ -72,19 +72,13 @@ export class FindCursor extends AbstractCursor { const response = await executeOperation(this.client, findOperation); // the response is not a cursor when `explain` is enabled - if (CursorResponse.is(response)) { - this.numReturned = response.batchSize; - } else { - // Can be an explain response, hence the ?. on everything - this.numReturned = this.numReturned + (response?.cursor?.firstBatch?.length ?? 0); - } + this.numReturned = response.batchSize; - // TODO: NODE-2882 return { server: findOperation.server, session, response }; } /** @internal */ - override async getMore(batchSize: number): Promise { + override async getMore(batchSize: number): Promise { const numReturned = this.numReturned; if (numReturned) { // TODO(DRIVERS-1448): Remove logic to enforce `limit` in the driver @@ -110,13 +104,9 @@ export class FindCursor extends AbstractCursor { } } - const response = await super.getMore(batchSize, false); + const response = await super.getMore(batchSize); // TODO: wrap this in some logic to prevent it from happening if we don't need this support - if (CursorResponse.is(response)) { - this.numReturned = this.numReturned + response.batchSize; - } else { - this.numReturned = this.numReturned + (response?.cursor?.nextBatch?.length ?? 0); - } + this.numReturned = this.numReturned + response.batchSize; return response; } @@ -144,14 +134,16 @@ export class FindCursor extends AbstractCursor { /** Execute the explain for the cursor */ async explain(verbosity?: ExplainVerbosityLike): Promise { - return await executeOperation( - this.client, - new FindOperation(this.namespace, this.cursorFilter, { - ...this.findOptions, // NOTE: order matters here, we may need to refine this - ...this.cursorOptions, - explain: verbosity ?? true - }) - ); + return ( + await executeOperation( + this.client, + new FindOperation(this.namespace, this.cursorFilter, { + ...this.findOptions, // NOTE: order matters here, we may need to refine this + ...this.cursorOptions, + explain: verbosity ?? true + }) + ) + ).shift(this.findOptions); } /** Set the cursor query */ diff --git a/src/cursor/list_collections_cursor.ts b/src/cursor/list_collections_cursor.ts index 6cefbd0d03..a529709556 100644 --- a/src/cursor/list_collections_cursor.ts +++ b/src/cursor/list_collections_cursor.ts @@ -1,13 +1,13 @@ import type { Document } from '../bson'; import type { Db } from '../db'; -import { executeOperation, type ExecutionResult } from '../operations/execute_operation'; +import { executeOperation } from '../operations/execute_operation'; import { type CollectionInfo, ListCollectionsOperation, type ListCollectionsOptions } from '../operations/list_collections'; import type { ClientSession } from '../sessions'; -import { AbstractCursor } from './abstract_cursor'; +import { AbstractCursor, type InitialCursorResponse } from './abstract_cursor'; /** @public */ export class ListCollectionsCursor< @@ -34,7 +34,7 @@ export class ListCollectionsCursor< } /** @internal */ - async _initialize(session: ClientSession | undefined): Promise { + async _initialize(session: ClientSession | undefined): Promise { const operation = new ListCollectionsOperation(this.parent, this.filter, { ...this.cursorOptions, ...this.options, @@ -43,7 +43,6 @@ export class ListCollectionsCursor< const response = await executeOperation(this.parent.client, operation); - // TODO: NODE-2882 return { server: operation.server, session, response }; } } diff --git a/src/cursor/list_indexes_cursor.ts b/src/cursor/list_indexes_cursor.ts index 7d75edb999..799ddf5bdb 100644 --- a/src/cursor/list_indexes_cursor.ts +++ b/src/cursor/list_indexes_cursor.ts @@ -1,8 +1,8 @@ import type { Collection } from '../collection'; -import { executeOperation, type ExecutionResult } from '../operations/execute_operation'; +import { executeOperation } from '../operations/execute_operation'; import { ListIndexesOperation, type ListIndexesOptions } from '../operations/indexes'; import type { ClientSession } from '../sessions'; -import { AbstractCursor } from './abstract_cursor'; +import { AbstractCursor, type InitialCursorResponse } from './abstract_cursor'; /** @public */ export class ListIndexesCursor extends AbstractCursor { @@ -23,7 +23,7 @@ export class ListIndexesCursor extends AbstractCursor { } /** @internal */ - async _initialize(session: ClientSession | undefined): Promise { + async _initialize(session: ClientSession | undefined): Promise { const operation = new ListIndexesOperation(this.parent, { ...this.cursorOptions, ...this.options, @@ -32,7 +32,6 @@ export class ListIndexesCursor extends AbstractCursor { const response = await executeOperation(this.parent.client, operation); - // TODO: NODE-2882 return { server: operation.server, session, response }; } } diff --git a/src/cursor/run_command_cursor.ts b/src/cursor/run_command_cursor.ts index 553041492f..78b9826b9b 100644 --- a/src/cursor/run_command_cursor.ts +++ b/src/cursor/run_command_cursor.ts @@ -1,14 +1,15 @@ -import type { BSONSerializeOptions, Document, Long } from '../bson'; +import type { BSONSerializeOptions, Document } from '../bson'; +import { CursorResponse } from '../cmap/wire_protocol/responses'; import type { Db } from '../db'; -import { MongoAPIError, MongoUnexpectedServerResponseError } from '../error'; -import { executeOperation, type ExecutionResult } from '../operations/execute_operation'; +import { MongoAPIError } from '../error'; +import { executeOperation } from '../operations/execute_operation'; import { GetMoreOperation } from '../operations/get_more'; import { RunCommandOperation } from '../operations/run_command'; import type { ReadConcernLike } from '../read_concern'; import type { ReadPreferenceLike } from '../read_preference'; import type { ClientSession } from '../sessions'; import { ns } from '../utils'; -import { AbstractCursor } from './abstract_cursor'; +import { AbstractCursor, type InitialCursorResponse } from './abstract_cursor'; /** @public */ export type RunCursorCommandOptions = { @@ -16,12 +17,6 @@ export type RunCursorCommandOptions = { session?: ClientSession; } & BSONSerializeOptions; -/** @internal */ -type RunCursorCommandResponse = { - cursor: { id: bigint | Long | number; ns: string; firstBatch: Document[] }; - ok: 1; -}; - /** @public */ export class RunCommandCursor extends AbstractCursor { public readonly command: Readonly>; @@ -102,16 +97,16 @@ export class RunCommandCursor extends AbstractCursor { } /** @internal */ - protected async _initialize(session: ClientSession): Promise { - const operation = new RunCommandOperation(this.db, this.command, { + protected async _initialize(session: ClientSession): Promise { + const operation = new RunCommandOperation(this.db, this.command, { ...this.cursorOptions, session: session, - readPreference: this.cursorOptions.readPreference + readPreference: this.cursorOptions.readPreference, + responseType: CursorResponse }); + const response = await executeOperation(this.client, operation); - if (response.cursor == null) { - throw new MongoUnexpectedServerResponseError('Expected server to respond with cursor'); - } + return { server: operation.server, session, @@ -120,13 +115,12 @@ export class RunCommandCursor extends AbstractCursor { } /** @internal */ - override async getMore(_batchSize: number): Promise { + override async getMore(_batchSize: number): Promise { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const getMoreOperation = new GetMoreOperation(this.namespace, this.id!, this.server!, { ...this.cursorOptions, session: this.session, - ...this.getMoreOptions, - useCursorResponse: false + ...this.getMoreOptions }); return await executeOperation(this.client, getMoreOperation); diff --git a/src/error.ts b/src/error.ts index 294062e3d1..066cc1f82a 100644 --- a/src/error.ts +++ b/src/error.ts @@ -750,8 +750,8 @@ export class MongoUnexpectedServerResponseError extends MongoRuntimeError { * * @public **/ - constructor(message: string) { - super(message); + constructor(message: string, options?: { cause?: Error }) { + super(message, options); } override get name(): string { @@ -1157,27 +1157,14 @@ export class MongoServerSelectionError extends MongoSystemError { } } -function makeWriteConcernResultObject(input: any) { - const output = Object.assign({}, input); - - if (output.ok === 0) { - output.ok = 1; - delete output.errmsg; - delete output.code; - delete output.codeName; - } - - return output; -} - /** * An error thrown when the server reports a writeConcernError * @public * @category Error */ export class MongoWriteConcernError extends MongoServerError { - /** The result document (provided if ok: 1) */ - result?: Document; + /** The result document */ + result: Document; /** * **Do not use this constructor!** @@ -1190,17 +1177,18 @@ export class MongoWriteConcernError extends MongoServerError { * * @public **/ - constructor(message: ErrorDescription, result?: Document) { - if (result && Array.isArray(result.errorLabels)) { - message.errorLabels = result.errorLabels; - } - - super(message); - this.errInfo = message.errInfo; - - if (result != null) { - this.result = makeWriteConcernResultObject(result); - } + constructor(result: { + writeConcernError: { + code: number; + errmsg: string; + codeName?: string; + errInfo?: Document; + }; + errorLabels?: string[]; + }) { + super({ ...result, ...result.writeConcernError }); + this.errInfo = result.writeConcernError.errInfo; + this.result = result; } override get name(): string { diff --git a/src/index.ts b/src/index.ts index 4ba612a7a0..1bd801518c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -160,7 +160,7 @@ export { SrvPollingEvent } from './sdam/srv_polling'; // type only exports below, these are removed from emitted JS export type { AdminPrivate } from './admin'; -export type { BSONSerializeOptions, Document } from './bson'; +export type { BSONElement, BSONSerializeOptions, Document } from './bson'; export type { deserialize, serialize } from './bson'; export type { BulkResult, @@ -342,12 +342,12 @@ export type { CursorFlag, CursorStreamOptions } from './cursor/abstract_cursor'; -export type { InternalAbstractCursorOptions } from './cursor/abstract_cursor'; -export type { AggregationCursorOptions } from './cursor/aggregation_cursor'; export type { - ChangeStreamAggregateRawResult, - ChangeStreamCursorOptions -} from './cursor/change_stream_cursor'; + InitialCursorResponse, + InternalAbstractCursorOptions +} from './cursor/abstract_cursor'; +export type { AggregationCursorOptions } from './cursor/aggregation_cursor'; +export type { ChangeStreamCursorOptions } from './cursor/change_stream_cursor'; export type { ListSearchIndexesCursor, ListSearchIndexesOptions @@ -473,7 +473,6 @@ export type { DeleteOptions, DeleteResult, DeleteStatement } from './operations/ export type { DistinctOptions } from './operations/distinct'; export type { DropCollectionOptions, DropDatabaseOptions } from './operations/drop'; export type { EstimatedDocumentCountOptions } from './operations/estimated_document_count'; -export type { ExecutionResult } from './operations/execute_operation'; export type { FindOptions } from './operations/find'; export type { FindOneAndDeleteOptions, diff --git a/src/operations/aggregate.ts b/src/operations/aggregate.ts index a3a9349c27..63a057e087 100644 --- a/src/operations/aggregate.ts +++ b/src/operations/aggregate.ts @@ -1,6 +1,6 @@ import type { Document } from '../bson'; +import { CursorResponse, ExplainedCursorResponse } from '../cmap/wire_protocol/responses'; import { MongoInvalidArgumentError } from '../error'; -import { type TODO_NODE_3286 } from '../mongo_types'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; import { maxWireVersion, type MongoDBNamespace } from '../utils'; @@ -37,7 +37,7 @@ export interface AggregateOptions extends CommandOperationOptions { } /** @internal */ -export class AggregateOperation extends CommandOperation { +export class AggregateOperation extends CommandOperation { override options: AggregateOptions; target: string | typeof DB_AGGREGATE_COLLECTION; pipeline: Document[]; @@ -94,7 +94,10 @@ export class AggregateOperation extends CommandOperation { this.pipeline.push(stage); } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined + ): Promise { const options: AggregateOptions = this.options; const serverWireVersion = maxWireVersion(server); const command: Document = { aggregate: this.target, pipeline: this.pipeline }; @@ -134,8 +137,12 @@ export class AggregateOperation extends CommandOperation { command.cursor.batchSize = options.batchSize; } - const res: TODO_NODE_3286 = await super.executeCommand(server, session, command); - return res; + return await super.executeCommand( + server, + session, + command, + this.explain ? ExplainedCursorResponse : CursorResponse + ); } } diff --git a/src/operations/bulk_write.ts b/src/operations/bulk_write.ts index 58a143f208..64c0664b5a 100644 --- a/src/operations/bulk_write.ts +++ b/src/operations/bulk_write.ts @@ -50,8 +50,7 @@ export class BulkWriteOperation extends AbstractOperation { } // Execute the bulk - const result = await bulk.execute({ ...options, session }); - return result; + return await bulk.execute({ ...options, session }); } } diff --git a/src/operations/command.ts b/src/operations/command.ts index fbcafe3f8b..033ec8aa94 100644 --- a/src/operations/command.ts +++ b/src/operations/command.ts @@ -1,4 +1,5 @@ import type { BSONSerializeOptions, Document } from '../bson'; +import { type MongoDBResponseConstructor } from '../cmap/wire_protocol/responses'; import { MongoInvalidArgumentError } from '../error'; import { Explain, type ExplainOptions } from '../explain'; import { ReadConcern } from '../read_concern'; @@ -106,12 +107,25 @@ export abstract class CommandOperation extends AbstractOperation { return true; } - async executeCommand( + public async executeCommand( + server: Server, + session: ClientSession | undefined, + cmd: Document, + responseType: T | undefined + ): Promise>; + + public async executeCommand( server: Server, session: ClientSession | undefined, cmd: Document + ): Promise; + + async executeCommand( + server: Server, + session: ClientSession | undefined, + cmd: Document, + responseType?: MongoDBResponseConstructor ): Promise { - // TODO: consider making this a non-enumerable property this.server = server; const options = { @@ -152,6 +166,6 @@ export abstract class CommandOperation extends AbstractOperation { cmd = decorateWithExplain(cmd, this.explain); } - return await server.command(this.ns, cmd, options); + return await server.command(this.ns, cmd, options, responseType); } } diff --git a/src/operations/count_documents.ts b/src/operations/count_documents.ts index 11fb0c3db6..62273ad022 100644 --- a/src/operations/count_documents.ts +++ b/src/operations/count_documents.ts @@ -1,5 +1,6 @@ import type { Document } from '../bson'; import type { Collection } from '../collection'; +import { type TODO_NODE_3286 } from '../mongo_types'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; import { AggregateOperation, type AggregateOptions } from './aggregate'; @@ -13,7 +14,7 @@ export interface CountDocumentsOptions extends AggregateOptions { } /** @internal */ -export class CountDocumentsOperation extends AggregateOperation { +export class CountDocumentsOperation extends AggregateOperation { constructor(collection: Collection, query: Document, options: CountDocumentsOptions) { const pipeline = []; pipeline.push({ $match: query }); @@ -31,16 +32,11 @@ export class CountDocumentsOperation extends AggregateOperation { super(collection.s.namespace, pipeline, options); } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined + ): Promise { const result = await super.execute(server, session); - - // NOTE: We're avoiding creating a cursor here to reduce the callstack. - const response = result as unknown as Document; - if (response.cursor == null || response.cursor.firstBatch == null) { - return 0; - } - - const docs = response.cursor.firstBatch; - return docs.length ? docs[0].n : 0; + return result.shift()?.n ?? 0; } } diff --git a/src/operations/delete.ts b/src/operations/delete.ts index e952554ff7..f0ef61cb7b 100644 --- a/src/operations/delete.ts +++ b/src/operations/delete.ts @@ -4,8 +4,8 @@ import { MongoCompatibilityError, MongoServerError } from '../error'; import { type TODO_NODE_3286 } from '../mongo_types'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; -import type { MongoDBNamespace } from '../utils'; -import type { WriteConcernOptions } from '../write_concern'; +import { type MongoDBNamespace } from '../utils'; +import { type WriteConcernOptions } from '../write_concern'; import { type CollationOptions, CommandOperation, type CommandOperationOptions } from './command'; import { Aspect, defineAspects, type Hint } from './operation'; diff --git a/src/operations/execute_operation.ts b/src/operations/execute_operation.ts index 4faf4fd95a..829ea2ce6e 100644 --- a/src/operations/execute_operation.ts +++ b/src/operations/execute_operation.ts @@ -1,5 +1,3 @@ -import type { Document } from '../bson'; -import { type CursorResponse } from '../cmap/wire_protocol/responses'; import { isRetryableReadError, isRetryableWriteError, @@ -18,7 +16,6 @@ import { } from '../error'; import type { MongoClient } from '../mongo_client'; import { ReadPreference } from '../read_preference'; -import type { Server } from '../sdam/server'; import type { ServerDescription } from '../sdam/server_description'; import { sameServerSelector, @@ -38,16 +35,6 @@ type ResultTypeFromOperation = TOperation extends AbstractOperation< ? K : never; -/** @internal */ -export interface ExecutionResult { - /** The server selected for the operation */ - server: Server; - /** The session used for this operation, may be implicitly created */ - session?: ClientSession; - /** The raw server response for the operation */ - response: Document | CursorResponse; -} - /** * Executes the given operation with provided arguments. * @internal diff --git a/src/operations/find.ts b/src/operations/find.ts index cdf1a71176..a040af73bc 100644 --- a/src/operations/find.ts +++ b/src/operations/find.ts @@ -1,4 +1,5 @@ import type { Document } from '../bson'; +import { CursorResponse, ExplainedCursorResponse } from '../cmap/wire_protocol/responses'; import { MongoInvalidArgumentError } from '../error'; import { ReadConcern } from '../read_concern'; import type { Server } from '../sdam/server'; @@ -65,7 +66,7 @@ export interface FindOptions } /** @internal */ -export class FindOperation extends CommandOperation { +export class FindOperation extends CommandOperation { /** * @remarks WriteConcern can still be present on the options because * we inherit options from the client/db/collection. The @@ -95,7 +96,10 @@ export class FindOperation extends CommandOperation { return 'find' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined + ): Promise { this.server = server; const options = this.options; @@ -114,7 +118,7 @@ export class FindOperation extends CommandOperation { documentsReturnedIn: 'firstBatch', session }, - undefined + this.explain ? ExplainedCursorResponse : CursorResponse ); } } diff --git a/src/operations/find_and_modify.ts b/src/operations/find_and_modify.ts index 68abe51a7e..c295fcb0a8 100644 --- a/src/operations/find_and_modify.ts +++ b/src/operations/find_and_modify.ts @@ -6,7 +6,7 @@ import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; import { formatSort, type Sort, type SortForCmd } from '../sort'; import { decorateWithCollation, hasAtomicOperators, maxWireVersion } from '../utils'; -import type { WriteConcern, WriteConcernSettings } from '../write_concern'; +import { type WriteConcern, type WriteConcernSettings } from '../write_concern'; import { CommandOperation, type CommandOperationOptions } from './command'; import { Aspect, defineAspects } from './operation'; diff --git a/src/operations/get_more.ts b/src/operations/get_more.ts index 05f54b0b57..aa550721b6 100644 --- a/src/operations/get_more.ts +++ b/src/operations/get_more.ts @@ -1,4 +1,4 @@ -import type { Document, Long } from '../bson'; +import type { Long } from '../bson'; import { CursorResponse } from '../cmap/wire_protocol/responses'; import { MongoRuntimeError } from '../error'; import type { Server } from '../sdam/server'; @@ -20,8 +20,6 @@ export interface GetMoreOptions extends OperationOptions { maxTimeMS?: number; /** TODO(NODE-4413): Address bug with maxAwaitTimeMS not being passed in from the cursor correctly */ maxAwaitTimeMS?: number; - - useCursorResponse: boolean; } /** @@ -58,7 +56,10 @@ export class GetMoreOperation extends AbstractOperation { * Although there is a server already associated with the get more operation, the signature * for execute passes a server so we will just use that one. */ - override async execute(server: Server, _session: ClientSession | undefined): Promise { + override async execute( + server: Server, + _session: ClientSession | undefined + ): Promise { if (server !== this.server) { throw new MongoRuntimeError('Getmore must run on the same server operation began on'); } @@ -99,12 +100,7 @@ export class GetMoreOperation extends AbstractOperation { ...this.options }; - return await server.command( - this.ns, - getMoreCmd, - commandOptions, - this.options.useCursorResponse ? CursorResponse : undefined - ); + return await server.command(this.ns, getMoreCmd, commandOptions, CursorResponse); } } diff --git a/src/operations/indexes.ts b/src/operations/indexes.ts index 8bf390f28b..fda3fa80dd 100644 --- a/src/operations/indexes.ts +++ b/src/operations/indexes.ts @@ -1,4 +1,5 @@ import type { Document } from '../bson'; +import { CursorResponse } from '../cmap/wire_protocol/responses'; import type { Collection } from '../collection'; import { type AbstractCursorOptions } from '../cursor/abstract_cursor'; import { MongoCompatibilityError } from '../error'; @@ -353,7 +354,7 @@ export class DropIndexOperation extends CommandOperation { export type ListIndexesOptions = AbstractCursorOptions; /** @internal */ -export class ListIndexesOperation extends CommandOperation { +export class ListIndexesOperation extends CommandOperation { /** * @remarks WriteConcern can still be present on the options because * we inherit options from the client/db/collection. The @@ -376,7 +377,10 @@ export class ListIndexesOperation extends CommandOperation { return 'listIndexes' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined + ): Promise { const serverWireVersion = maxWireVersion(server); const cursor = this.options.batchSize ? { batchSize: this.options.batchSize } : {}; @@ -389,7 +393,7 @@ export class ListIndexesOperation extends CommandOperation { command.comment = this.options.comment; } - return await super.executeCommand(server, session, command); + return await super.executeCommand(server, session, command, CursorResponse); } } diff --git a/src/operations/list_collections.ts b/src/operations/list_collections.ts index 2d3819cac9..e94300f120 100644 --- a/src/operations/list_collections.ts +++ b/src/operations/list_collections.ts @@ -1,4 +1,5 @@ import type { Binary, Document } from '../bson'; +import { CursorResponse } from '../cmap/wire_protocol/responses'; import type { Db } from '../db'; import type { Server } from '../sdam/server'; import type { ClientSession } from '../sessions'; @@ -17,7 +18,7 @@ export interface ListCollectionsOptions extends Omit { +export class ListCollectionsOperation extends CommandOperation { /** * @remarks WriteConcern can still be present on the options because * we inherit options from the client/db/collection. The @@ -51,11 +52,15 @@ export class ListCollectionsOperation extends CommandOperation { return 'listCollections' as const; } - override async execute(server: Server, session: ClientSession | undefined): Promise { + override async execute( + server: Server, + session: ClientSession | undefined + ): Promise { return await super.executeCommand( server, session, - this.generateCommand(maxWireVersion(server)) + this.generateCommand(maxWireVersion(server)), + CursorResponse ); } diff --git a/src/operations/run_command.ts b/src/operations/run_command.ts index 9042407b3e..ad7d02c044 100644 --- a/src/operations/run_command.ts +++ b/src/operations/run_command.ts @@ -1,4 +1,5 @@ import type { BSONSerializeOptions, Document } from '../bson'; +import { type MongoDBResponseConstructor } from '../cmap/wire_protocol/responses'; import { type Db } from '../db'; import { type TODO_NODE_3286 } from '../mongo_types'; import type { ReadPreferenceLike } from '../read_preference'; @@ -17,7 +18,11 @@ export type RunCommandOptions = { /** @internal */ export class RunCommandOperation extends AbstractOperation { - constructor(parent: Db, public command: Document, public override options: RunCommandOptions) { + constructor( + parent: Db, + public command: Document, + public override options: RunCommandOptions & { responseType?: MongoDBResponseConstructor } + ) { super(options); this.ns = parent.s.namespace.withCollection('$cmd'); } @@ -28,11 +33,16 @@ export class RunCommandOperation extends AbstractOperation { override async execute(server: Server, session: ClientSession | undefined): Promise { this.server = server; - const res: TODO_NODE_3286 = await server.command(this.ns, this.command, { - ...this.options, - readPreference: this.readPreference, - session - }); + const res: TODO_NODE_3286 = await server.command( + this.ns, + this.command, + { + ...this.options, + readPreference: this.readPreference, + session + }, + this.options.responseType + ); return res; } } diff --git a/src/operations/update.ts b/src/operations/update.ts index a863afdc68..ba0ad6d95f 100644 --- a/src/operations/update.ts +++ b/src/operations/update.ts @@ -122,7 +122,8 @@ export class UpdateOperation extends CommandOperation { } } - return await super.executeCommand(server, session, command); + const res = await super.executeCommand(server, session, command); + return res; } } diff --git a/src/sdam/server.ts b/src/sdam/server.ts index 8ea91815c6..59a7231b4f 100644 --- a/src/sdam/server.ts +++ b/src/sdam/server.ts @@ -48,6 +48,7 @@ import { type MongoDBNamespace, supportsRetryableWrites } from '../utils'; +import { throwIfWriteConcernError } from '../write_concern'; import { type ClusterTime, STATE_CLOSED, @@ -323,7 +324,9 @@ export class Server extends TypedEventEmitter { try { try { - return await conn.command(ns, cmd, finalOptions, responseType); + const res = await conn.command(ns, cmd, finalOptions, responseType); + throwIfWriteConcernError(res); + return res; } catch (commandError) { throw this.decorateCommandError(conn, cmd, finalOptions, commandError); } @@ -334,7 +337,9 @@ export class Server extends TypedEventEmitter { ) { await this.pool.reauthenticate(conn); try { - return await conn.command(ns, cmd, finalOptions, responseType); + const res = await conn.command(ns, cmd, finalOptions, responseType); + throwIfWriteConcernError(res); + return res; } catch (commandError) { throw this.decorateCommandError(conn, cmd, finalOptions, commandError); } diff --git a/src/utils.ts b/src/utils.ts index 2ede778258..cebb81e0a0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -8,11 +8,11 @@ import * as url from 'url'; import { URL } from 'url'; import { promisify } from 'util'; -import { type Document, ObjectId, resolveBSONOptions } from './bson'; +import { deserialize, type Document, ObjectId, resolveBSONOptions } from './bson'; import type { Connection } from './cmap/connection'; import { MAX_SUPPORTED_WIRE_VERSION } from './cmap/wire_protocol/constants'; import type { Collection } from './collection'; -import { LEGACY_HELLO_COMMAND } from './constants'; +import { kDecoratedKeys, LEGACY_HELLO_COMMAND } from './constants'; import type { AbstractCursor } from './cursor/abstract_cursor'; import type { FindCursor } from './cursor/find_cursor'; import type { Db } from './db'; @@ -1366,3 +1366,53 @@ export async function fileIsAccessible(fileName: string, mode?: number) { export function noop() { return; } + +/** + * Recurse through the (identically-shaped) `decrypted` and `original` + * objects and attach a `decryptedKeys` property on each sub-object that + * contained encrypted fields. Because we only call this on BSON responses, + * we do not need to worry about circular references. + * + * @internal + */ +export function decorateDecryptionResult( + decrypted: Document & { [kDecoratedKeys]?: Array }, + original: Document, + isTopLevelDecorateCall = true +): void { + if (isTopLevelDecorateCall) { + // The original value could have been either a JS object or a BSON buffer + if (Buffer.isBuffer(original)) { + original = deserialize(original); + } + if (Buffer.isBuffer(decrypted)) { + throw new MongoRuntimeError('Expected result of decryption to be deserialized BSON object'); + } + } + + if (!decrypted || typeof decrypted !== 'object') return; + for (const k of Object.keys(decrypted)) { + const originalValue = original[k]; + + // An object was decrypted by libmongocrypt if and only if it was + // a BSON Binary object with subtype 6. + if (originalValue && originalValue._bsontype === 'Binary' && originalValue.sub_type === 6) { + if (!decrypted[kDecoratedKeys]) { + Object.defineProperty(decrypted, kDecoratedKeys, { + value: [], + configurable: true, + enumerable: false, + writable: false + }); + } + // this is defined in the preceding if-statement + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + decrypted[kDecoratedKeys]!.push(k); + // Do not recurse into this decrypted value. It could be a sub-document/array, + // in which case there is no original value associated with its subfields. + continue; + } + + decorateDecryptionResult(decrypted[k], originalValue, false); + } +} diff --git a/src/write_concern.ts b/src/write_concern.ts index e3a3f5510e..b315798fd4 100644 --- a/src/write_concern.ts +++ b/src/write_concern.ts @@ -1,4 +1,6 @@ import { type Document } from './bson'; +import { MongoDBResponse } from './cmap/wire_protocol/responses'; +import { MongoWriteConcernError } from './error'; /** @public */ export type W = number | 'majority'; @@ -159,3 +161,19 @@ export class WriteConcern { return undefined; } } + +/** Called with either a plain object or MongoDBResponse */ +export function throwIfWriteConcernError(response: unknown): void { + if (typeof response === 'object' && response != null) { + const writeConcernError: object | null = + MongoDBResponse.is(response) && response.has('writeConcernError') + ? response.toObject() + : !MongoDBResponse.is(response) && 'writeConcernError' in response + ? response + : null; + + if (writeConcernError != null) { + throw new MongoWriteConcernError(writeConcernError as any); + } + } +} diff --git a/test/integration/collection-management/collection.test.ts b/test/integration/collection-management/collection.test.ts index 9db80c43eb..809b4697de 100644 --- a/test/integration/collection-management/collection.test.ts +++ b/test/integration/collection-management/collection.test.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; -import { Collection, type Db, isHello, type MongoClient, MongoServerError } from '../../mongodb'; -import * as mock from '../../tools/mongodb-mock/index'; +import { Collection, type Db, type MongoClient, MongoServerError } from '../../mongodb'; +import { type FailPoint } from '../../tools/utils'; import { setupDatabase } from '../shared'; describe('Collection', function () { @@ -422,144 +422,117 @@ describe('Collection', function () { }); }); - describe('#countDocuments', function () { - let client; - let db; - let collection; + describe('countDocuments()', function () { + let client: MongoClient; + let collection: Collection<{ test: string }>; + let aggCommands; beforeEach(async function () { - client = configuration.newClient({ w: 1 }); - await client.connect(); - db = client.db(configuration.db); - collection = db.collection('test_coll'); - await collection.insertOne({ a: 'c' }); + client = this.configuration.newClient({ monitorCommands: true }); + collection = client.db('test').collection('countDocuments'); + await collection.insertMany( + Array.from({ length: 100 }, (_, i) => ({ test: i < 50 ? 'a' : 'b' })) + ); + aggCommands = []; + client.on('commandStarted', ev => { + if (ev.commandName === 'aggregate') aggCommands.push(ev.command); + }); }); afterEach(async function () { - await collection.drop(); + await collection.deleteMany({}); await client.close(); }); - context('when passing a non-matching query', function () { - it('returns 0', async function () { - const result = await collection.countDocuments({ a: 'b' }); - expect(result).to.equal(0); - }); - }); + it('returns the correct count as a js number', async () => { + const count = await collection.countDocuments({}); + expect(count).to.be.a('number').that.equals(100); - it('returns a promise', function () { - const docsPromise = collection.countDocuments(); - expect(docsPromise).to.exist.and.to.be.an.instanceof(Promise); - return docsPromise.then(result => expect(result).to.equal(1)); - }); - }); + const countDefault = await collection.countDocuments(); + expect(countDefault).to.be.a('number').that.equals(100); - describe('countDocuments with mock server', function () { - let server; + const countA = await collection.countDocuments({ test: 'a' }); + expect(countA).to.be.a('number').that.equals(50); - beforeEach(() => { - return mock.createServer().then(s => { - server = s; - }); + const countC = await collection.countDocuments({ test: 'c' }); + expect(countC).to.be.a('number').that.equals(0); }); - afterEach(() => mock.cleanup()); + it('does not mutate options', async () => { + const options = Object.freeze(Object.create(null)); + const count = await collection.countDocuments({}, options); + expect(count).to.be.a('number').that.equals(100); + expect(options).to.deep.equal({}); + }); - function testCountDocMock(testConfiguration, config, done) { - const client = testConfiguration.newClient(`mongodb://${server.uri()}/test`, { - serverApi: null // TODO(NODE-3807): remove resetting serverApi when the usage of mongodb mock server is removed + context('when a filter is applied', () => { + it('adds a $match pipeline', async () => { + await collection.countDocuments({ test: 'a' }); + expect(aggCommands[0]) + .to.have.property('pipeline') + .that.deep.equals([{ $match: { test: 'a' } }, { $group: { _id: 1, n: { $sum: 1 } } }]); }); - const close = e => client.close(() => done(e)); - - server.setMessageHandler(request => { - const doc = request.document; - if (doc.aggregate) { - try { - config.replyHandler(doc); - request.reply(config.reply); - } catch (e) { - close(e); - } - } + }); - if (isHello(doc)) { - request.reply(Object.assign({}, mock.HELLO)); - } else if (doc.endSessions) { - request.reply({ ok: 1 }); - } + describe('when aggregation fails', { requires: { mongodb: '>=4.4' } }, () => { + beforeEach(async function () { + await client + .db() + .admin() + .command({ + configureFailPoint: 'failCommand', + mode: 'alwaysOn', + data: { failCommands: ['aggregate'], errorCode: 1 } + } as FailPoint); }); - const db = client.db('test'); - const collection = db.collection('countDoc_mock'); - - config.executeCountDocuments(collection, close); - } - - it('countDocuments should return appropriate error if aggregation fails with callback given', function (done) { - const replyHandler = () => null; - const executeCountDocuments = (collection, close) => { - collection.countDocuments(err => { - expect(err).to.exist; - expect(err.errmsg).to.equal('aggregation error - callback'); - close(); - }); - }; + afterEach(async function () { + await client + .db() + .admin() + .command({ + configureFailPoint: 'failCommand', + mode: 'off', + data: { failCommands: ['aggregate'] } + } as FailPoint); + }); - testCountDocMock( - configuration, - { - replyHandler, - executeCountDocuments, - reply: { ok: 0, errmsg: 'aggregation error - callback' } - }, - done - ); + it('rejects the countDocuments API', async () => { + const error = await collection.countDocuments().catch(error => error); + expect(error).to.be.instanceOf(MongoServerError); + }); }); - it('countDocuments should error if aggregation fails using Promises', function (done) { - const replyHandler = () => null; - const executeCountDocuments = (collection, close) => { - collection - .countDocuments() - .then(() => expect(false).to.equal(true)) // should never get here; error should be caught - .catch(e => { - expect(e.errmsg).to.equal('aggregation error - promise'); - close(); - }); - }; - - testCountDocMock( - configuration, - { - replyHandler, - executeCountDocuments, - reply: { ok: 0, errmsg: 'aggregation error - promise' } - }, - done - ); - }); + context('when provided with options', () => { + it('adds $skip stage to the pipeline', async () => { + await collection.countDocuments({}, { skip: 1 }); + expect(aggCommands[0]) + .to.have.property('pipeline') + .that.deep.equals([{ $match: {} }, { $skip: 1 }, { $group: { _id: 1, n: { $sum: 1 } } }]); + }); - it('countDocuments pipeline should be correct with skip and limit applied', function (done) { - const replyHandler = doc => { - expect(doc.pipeline).to.deep.include({ $skip: 1 }); - expect(doc.pipeline).to.deep.include({ $limit: 1 }); - }; - const executeCountDocuments = (collection, close) => { - collection.countDocuments({}, { limit: 1, skip: 1 }, err => { - expect(err).to.not.exist; - close(); - }); - }; + it('adds $limit stage to the pipeline', async () => { + await collection.countDocuments({}, { limit: 1 }); + expect(aggCommands[0]) + .to.have.property('pipeline') + .that.deep.equals([ + { $match: {} }, + { $limit: 1 }, + { $group: { _id: 1, n: { $sum: 1 } } } + ]); + }); - testCountDocMock( - configuration, - { - replyHandler, - executeCountDocuments, - reply: { ok: 1 } - }, - done - ); + it('adds $skip and $limit stages to the pipeline', async () => { + await collection.countDocuments({}, { skip: 1, limit: 1 }); + expect(aggCommands[0]) + .to.have.property('pipeline') + .that.deep.equals([ + { $match: {} }, + { $skip: 1 }, + { $limit: 1 }, + { $group: { _id: 1, n: { $sum: 1 } } } + ]); + }); }); }); diff --git a/test/integration/crud/abstract_operation.test.ts b/test/integration/crud/abstract_operation.test.ts index 77c45890e2..dd972cd316 100644 --- a/test/integration/crud/abstract_operation.test.ts +++ b/test/integration/crud/abstract_operation.test.ts @@ -322,7 +322,11 @@ describe('abstract operation', function () { it(`operation.commandName equals key in command document`, async function () { const subclassInstance = subclassCreator(); const yieldDoc = - subclassType.name === 'ProfilingLevelOperation' ? { ok: 1, was: 1 } : { ok: 1 }; + subclassType.name === 'ProfilingLevelOperation' + ? { ok: 1, was: 1 } + : subclassType.name === 'CountDocumentsOperation' + ? { shift: () => ({ n: 1 }) } + : { ok: 1 }; const cmdCallerStub = sinon.stub(Server.prototype, 'command').resolves(yieldDoc); if (sameServerOnlyOperationSubclasses.includes(subclassType.name.toString())) { await subclassInstance.execute(constructorServer, client.session); diff --git a/test/integration/crud/explain.test.ts b/test/integration/crud/explain.test.ts index c99e4f1709..189a1ca387 100644 --- a/test/integration/crud/explain.test.ts +++ b/test/integration/crud/explain.test.ts @@ -88,6 +88,9 @@ describe('CRUD API explain option', function () { context(`When explain is ${explainValue}, operation ${name}`, function () { it(`sets command verbosity to ${explainValue} and includes ${explainValueToExpectation(explainValue)} in the return response`, async function () { const response = await op.op(explainValue).catch(error => error); + if (response instanceof Error && !(response instanceof MongoServerError)) { + throw response; + } const commandStartedEvent = await commandStartedPromise; const explainJson = JSON.stringify(response); switch (explainValue) { diff --git a/test/integration/retryable-writes/non-server-retryable_writes.test.ts b/test/integration/retryable-writes/non-server-retryable_writes.test.ts index 8a4190615c..40513134d5 100644 --- a/test/integration/retryable-writes/non-server-retryable_writes.test.ts +++ b/test/integration/retryable-writes/non-server-retryable_writes.test.ts @@ -34,13 +34,14 @@ describe('Non Server Retryable Writes', function () { async () => { const serverCommandStub = sinon.stub(Server.prototype, 'command'); serverCommandStub.onCall(0).rejects(new PoolClearedError('error')); - serverCommandStub - .onCall(1) - .returns( - Promise.reject( - new MongoWriteConcernError({ errorLabels: ['NoWritesPerformed'], errorCode: 10107 }, {}) - ) - ); + serverCommandStub.onCall(1).returns( + Promise.reject( + new MongoWriteConcernError({ + errorLabels: ['NoWritesPerformed'], + writeConcernError: { errmsg: 'NotWritablePrimary error', errorCode: 10107 } + }) + ) + ); const insertResult = await collection.insertOne({ _id: 1 }).catch(error => error); sinon.restore(); diff --git a/test/integration/retryable-writes/retryable_writes.spec.prose.test.ts b/test/integration/retryable-writes/retryable_writes.spec.prose.test.ts index a5a53e7cf7..d02540526a 100644 --- a/test/integration/retryable-writes/retryable_writes.spec.prose.test.ts +++ b/test/integration/retryable-writes/retryable_writes.spec.prose.test.ts @@ -277,23 +277,22 @@ describe('Retryable Writes Spec Prose', () => { { requires: { topology: 'replicaset', mongodb: '>=4.2.9' } }, async () => { const serverCommandStub = sinon.stub(Server.prototype, 'command'); - serverCommandStub - .onCall(0) - .returns( - Promise.reject( - new MongoWriteConcernError({ errorLabels: ['RetryableWriteError'], code: 91 }, {}) - ) - ); - serverCommandStub - .onCall(1) - .returns( - Promise.reject( - new MongoWriteConcernError( - { errorLabels: ['RetryableWriteError', 'NoWritesPerformed'], errorCode: 10107 }, - {} - ) - ) - ); + serverCommandStub.onCall(0).returns( + Promise.reject( + new MongoWriteConcernError({ + errorLabels: ['RetryableWriteError'], + writeConcernError: { errmsg: 'ShutdownInProgress error', code: 91 } + }) + ) + ); + serverCommandStub.onCall(1).returns( + Promise.reject( + new MongoWriteConcernError({ + errorLabels: ['RetryableWriteError', 'NoWritesPerformed'], + writeConcernError: { errmsg: 'NotWritablePrimary error', errorCode: 10107 } + }) + ) + ); const insertResult = await collection.insertOne({ _id: 1 }).catch(error => error); sinon.restore(); diff --git a/test/unit/assorted/sessions_collection.test.js b/test/unit/assorted/sessions_collection.test.js index 409d818d13..e7711efb6b 100644 --- a/test/unit/assorted/sessions_collection.test.js +++ b/test/unit/assorted/sessions_collection.test.js @@ -48,27 +48,5 @@ describe('Sessions - unit/sessions', function () { return client.close(); }); }); - - it('does not mutate command options', function () { - const options = Object.freeze({}); - test.server.setMessageHandler(request => { - const doc = request.document; - if (isHello(doc)) { - request.reply(mock.HELLO); - } else if (doc.count || doc.aggregate || doc.endSessions) { - request.reply({ ok: 1 }); - } - }); - - const client = new MongoClient(`mongodb://${test.server.uri()}/test`); - return client.connect().then(client => { - const coll = client.db('foo').collection('bar'); - - return coll.countDocuments({}, options).then(() => { - expect(options).to.deep.equal({}); - return client.close(); - }); - }); - }); }); }); diff --git a/test/unit/client-side-encryption/auto_encrypter.test.ts b/test/unit/client-side-encryption/auto_encrypter.test.ts index bd50961cde..08072512c7 100644 --- a/test/unit/client-side-encryption/auto_encrypter.test.ts +++ b/test/unit/client-side-encryption/auto_encrypter.test.ts @@ -162,7 +162,7 @@ describe('AutoEncrypter', function () { local: { key: Buffer.alloc(96) } } }); - const decrypted = await mc.decrypt(input); + const decrypted = BSON.deserialize(await mc.decrypt(input)); expect(decrypted).to.eql({ filter: { find: 'test', ssn: '457-55-5462' } }); expect(decrypted).to.not.have.property(Symbol.for('@@mdb.decryptedKeys')); expect(decrypted.filter).to.not.have.property(Symbol.for('@@mdb.decryptedKeys')); @@ -185,25 +185,26 @@ describe('AutoEncrypter', function () { local: { key: Buffer.alloc(96) } } }); - mc[Symbol.for('@@mdb.decorateDecryptionResult')] = true; - let decrypted = await mc.decrypt(input); + + let decrypted = BSON.deserialize(await mc.decrypt(input)); expect(decrypted).to.eql({ filter: { find: 'test', ssn: '457-55-5462' } }); - expect(decrypted).to.not.have.property(Symbol.for('@@mdb.decryptedKeys')); - expect(decrypted.filter[Symbol.for('@@mdb.decryptedKeys')]).to.eql(['ssn']); // The same, but with an object containing different data types as the input - decrypted = await mc.decrypt({ - a: [null, 1, { c: new bson.Binary(Buffer.from('foo', 'utf8'), 1) }] - }); + decrypted = BSON.deserialize( + await mc.decrypt( + BSON.serialize({ + a: [null, 1, { c: new bson.Binary(Buffer.from('foo', 'utf8'), 1) }] + }) + ) + ); expect(decrypted).to.eql({ a: [null, 1, { c: new bson.Binary(Buffer.from('foo', 'utf8'), 1) }] }); expect(decrypted).to.not.have.property(Symbol.for('@@mdb.decryptedKeys')); // The same, but with nested data inside the decrypted input - decrypted = await mc.decrypt(nestedInput); + decrypted = BSON.deserialize(await mc.decrypt(nestedInput)); expect(decrypted).to.eql({ nested: { x: { y: 1234 } } }); - expect(decrypted[Symbol.for('@@mdb.decryptedKeys')]).to.eql(['nested']); expect(decrypted.nested).to.not.have.property(Symbol.for('@@mdb.decryptedKeys')); expect(decrypted.nested.x).to.not.have.property(Symbol.for('@@mdb.decryptedKeys')); expect(decrypted.nested.x.y).to.not.have.property(Symbol.for('@@mdb.decryptedKeys')); @@ -243,7 +244,7 @@ describe('AutoEncrypter', function () { aws: {} } }); - const decrypted = await mc.decrypt(input); + const decrypted = BSON.deserialize(await mc.decrypt(input)); expect(decrypted).to.eql({ filter: { find: 'test', ssn: '457-55-5462' } }); }); }); diff --git a/test/unit/cmap/wire_protocol/responses.test.ts b/test/unit/cmap/wire_protocol/responses.test.ts index 91e052da84..9498765cf4 100644 --- a/test/unit/cmap/wire_protocol/responses.test.ts +++ b/test/unit/cmap/wire_protocol/responses.test.ts @@ -3,7 +3,6 @@ import * as sinon from 'sinon'; import { BSON, - BSONError, CursorResponse, Int32, MongoDBResponse, @@ -16,36 +15,6 @@ describe('class MongoDBResponse', () => { expect(new MongoDBResponse(BSON.serialize({ ok: 1 }))).to.be.instanceOf(OnDemandDocument); }); - context('get isError', () => { - it('returns true when ok is 0', () => { - const doc = new MongoDBResponse(BSON.serialize({ ok: 0 })); - expect(doc.isError).to.be.true; - }); - - it('returns true when $err is defined', () => { - const doc = new MongoDBResponse(BSON.serialize({ $err: 0 })); - expect(doc.isError).to.be.true; - }); - - it('returns true when errmsg is defined', () => { - const doc = new MongoDBResponse(BSON.serialize({ errmsg: 0 })); - expect(doc.isError).to.be.true; - }); - - it('returns true when code is defined', () => { - const doc = new MongoDBResponse(BSON.serialize({ code: 0 })); - expect(doc.isError).to.be.true; - }); - - it('short circuits detection of $err, errmsg, code', () => { - const doc = new MongoDBResponse(BSON.serialize({ ok: 0 })); - expect(doc.isError).to.be.true; - expect(doc).to.not.have.property('cache.$err'); - expect(doc).to.not.have.property('cache.errmsg'); - expect(doc).to.not.have.property('cache.code'); - }); - }); - context('utf8 validation', () => { afterEach(() => sinon.restore()); @@ -86,30 +55,59 @@ describe('class MongoDBResponse', () => { }); describe('class CursorResponse', () => { - describe('constructor()', () => { + describe('get cursor()', () => { it('throws if input does not contain cursor embedded document', () => { - expect(() => new CursorResponse(BSON.serialize({ ok: 1 }))).to.throw(BSONError); + expect(() => new CursorResponse(BSON.serialize({ ok: 1 })).cursor).to.throw( + MongoUnexpectedServerResponseError, + /"cursor" is missing/ + ); }); + }); + describe('get id()', () => { it('throws if input does not contain cursor.id int64', () => { - expect(() => new CursorResponse(BSON.serialize({ ok: 1, cursor: {} }))).to.throw(BSONError); + expect(() => new CursorResponse(BSON.serialize({ ok: 1, cursor: {} })).id).to.throw( + MongoUnexpectedServerResponseError, + /"id" is missing/ + ); }); + }); + describe('get batch()', () => { + it('throws if input does not contain firstBatch nor nextBatch', () => { + expect( + // @ts-expect-error: testing private getter + () => new CursorResponse(BSON.serialize({ ok: 1, cursor: { id: 0n, batch: [] } })).batch + ).to.throw(MongoUnexpectedServerResponseError, /did not contain a batch/); + }); + }); + + describe('get ns()', () => { it('sets namespace to null if input does not contain cursor.ns', () => { expect(new CursorResponse(BSON.serialize({ ok: 1, cursor: { id: 0n, firstBatch: [] } })).ns) .to.be.null; }); + }); - it('throws if input does not contain firstBatch nor nextBatch', () => { - expect( - () => new CursorResponse(BSON.serialize({ ok: 1, cursor: { id: 0n, batch: [] } })) - ).to.throw(MongoUnexpectedServerResponseError); + describe('get batchSize()', () => { + it('reports the returned batch size', () => { + const response = new CursorResponse( + BSON.serialize({ ok: 1, cursor: { id: 0n, nextBatch: [{}, {}, {}] } }) + ); + expect(response.batchSize).to.equal(3); + expect(response.shift()).to.deep.equal({}); + expect(response.batchSize).to.equal(3); }); + }); - it('reports a length equal to the batch', () => { - expect( - new CursorResponse(BSON.serialize({ ok: 1, cursor: { id: 0n, nextBatch: [1, 2, 3] } })) - ).to.have.lengthOf(3); + describe('get length()', () => { + it('reports number of documents remaining in the batch', () => { + const response = new CursorResponse( + BSON.serialize({ ok: 1, cursor: { id: 0n, nextBatch: [{}, {}, {}] } }) + ); + expect(response).to.have.lengthOf(3); + expect(response.shift()).to.deep.equal({}); + expect(response).to.have.lengthOf(2); // length makes CursorResponse act like an array }); }); @@ -162,12 +160,4 @@ describe('class CursorResponse', () => { expect(response.shift()).to.be.null; }); }); - - describe('pushMany()', () => - it('throws unsupported error', () => - expect(CursorResponse.prototype.pushMany).to.throw(/Unsupported/i))); - - describe('push()', () => - it('throws unsupported error', () => - expect(CursorResponse.prototype.push).to.throw(/Unsupported/i))); }); diff --git a/test/unit/error.test.ts b/test/unit/error.test.ts index 298e1f2191..c57ee71da6 100644 --- a/test/unit/error.test.ts +++ b/test/unit/error.test.ts @@ -65,20 +65,15 @@ describe('MongoErrors', () => { }); describe('error names should be read-only', () => { - for (const [errorName, errorClass] of Object.entries(errorClassesFromEntryPoint)) { + for (const [errorName, errorClass] of Object.entries<{ new (): Error }>( + errorClassesFromEntryPoint + )) { it(`${errorName} should be read-only`, () => { - // Dynamically create error class with message - const error = new (errorClass as any)('generated by test', { - cause: new Error('something went wrong') - }); - // expect name property to be class name - expect(error).to.have.property('name', errorName); - - try { - error.name = 'renamed by test'; - // eslint-disable-next-line no-empty - } catch (err) {} - expect(error).to.have.property('name', errorName); + const errorNameDescriptor = Object.getOwnPropertyDescriptor(errorClass.prototype, 'name'); + expect(errorNameDescriptor).to.have.property('set').that.does.not.exist; + expect(errorNameDescriptor).to.not.have.property('value'); + expect(errorNameDescriptor).to.have.property('get'); + expect(errorNameDescriptor.get.call(undefined)).to.equal(errorName); }); } }); @@ -301,7 +296,7 @@ describe('MongoErrors', () => { }; const RAW_USER_WRITE_CONCERN_ERROR = { - ok: 0, + ok: 1, errmsg: 'waiting for replication timed out', code: 64, codeName: 'WriteConcernFailed', @@ -316,7 +311,7 @@ describe('MongoErrors', () => { }; const RAW_USER_WRITE_CONCERN_ERROR_INFO = { - ok: 0, + ok: 1, errmsg: 'waiting for replication timed out', code: 64, codeName: 'WriteConcernFailed', @@ -393,9 +388,9 @@ describe('MongoErrors', () => { expect(err).to.be.an.instanceOf(MongoWriteConcernError); expect(err.result).to.exist; expect(err.result).to.have.property('ok', 1); - expect(err.result).to.not.have.property('errmsg'); - expect(err.result).to.not.have.property('code'); - expect(err.result).to.not.have.property('codeName'); + expect(err.result).to.have.property('errmsg'); + expect(err.result).to.have.property('code'); + expect(err.result).to.have.property('codeName'); expect(err.result).to.have.property('writeConcernError'); } ) @@ -480,43 +475,62 @@ describe('MongoErrors', () => { error: new MongoNetworkError('socket bad, try again'), maxWireVersion: BELOW_4_4 }, - { - description: 'a MongoWriteConcernError with no code nor label', - result: false, - error: new MongoWriteConcernError({ message: 'empty wc error' }), - maxWireVersion: BELOW_4_4 - }, { description: 'a MongoWriteConcernError with a random label', result: false, - error: new MongoWriteConcernError( - { message: 'random label' }, - { errorLabels: ['myLabel'] } - ), + error: new MongoWriteConcernError({ + writeConcernError: { + errmsg: 'random label', + code: 1 + }, + errorLabels: ['myLabel'] + }), maxWireVersion: BELOW_4_4 }, { description: 'a MongoWriteConcernError with a retryable code above server 4.4', result: false, - error: new MongoWriteConcernError({}, { code: 262 }), + error: new MongoWriteConcernError({ + writeConcernError: { + errmsg: 'code 262', // ExceededTimeLimit, is retryable + code: 262 + } + }), maxWireVersion: ABOVE_4_4 }, { description: 'a MongoWriteConcernError with a retryable code below server 4.4', result: true, - error: new MongoWriteConcernError({}, { code: 262 }), + error: new MongoWriteConcernError({ + writeConcernError: { + errmsg: 'code 262', + code: 262 + } + }), maxWireVersion: BELOW_4_4 }, { description: 'a MongoWriteConcernError with a RetryableWriteError label below server 4.4', result: false, - error: new MongoWriteConcernError({}, { errorLabels: ['RetryableWriteError'] }), + error: new MongoWriteConcernError({ + writeConcernError: { + errmsg: 'code 1', + code: 1 + }, + errorLabels: ['RetryableWriteError'] + }), maxWireVersion: BELOW_4_4 }, { description: 'a MongoWriteConcernError with a RetryableWriteError label above server 4.4', result: false, - error: new MongoWriteConcernError({}, { errorLabels: ['RetryableWriteError'] }), + error: new MongoWriteConcernError({ + writeConcernError: { + errmsg: 'code 1', + code: 1 + }, + errorLabels: ['RetryableWriteError'] + }), maxWireVersion: ABOVE_4_4 }, {