diff --git a/.gitignore b/.gitignore index dab77719f..70b2502cf 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ coverage *.code-workspace /testkit/CAs /testkit/CustomCAs +/testkit/firefox_profile/* diff --git a/packages/bolt-connection/src/bolt/bolt-protocol-v1.js b/packages/bolt-connection/src/bolt/bolt-protocol-v1.js index 51a65de2d..d04b33b4e 100644 --- a/packages/bolt-connection/src/bolt/bolt-protocol-v1.js +++ b/packages/bolt-connection/src/bolt/bolt-protocol-v1.js @@ -45,6 +45,12 @@ const { txConfig: { TxConfig } } = internal +const DEFAULT_DIAGNOSTIC_RECORD = Object.freeze({ + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/' +}) + export default class BoltProtocol { /** * @callback CreateResponseHandler Creates the response handler @@ -164,6 +170,13 @@ export default class BoltProtocol { return metadata } + enrichErrorMetadata (metadata) { + return { + ...metadata, + diagnostic_record: metadata.diagnostic_record !== null ? { ...DEFAULT_DIAGNOSTIC_RECORD, ...metadata.diagnostic_record } : null + } + } + /** * Perform initialization and authentication of the underlying connection. * @param {Object} param diff --git a/packages/bolt-connection/src/bolt/bolt-protocol-v5x7.js b/packages/bolt-connection/src/bolt/bolt-protocol-v5x7.js new file mode 100644 index 000000000..719e3b1bc --- /dev/null +++ b/packages/bolt-connection/src/bolt/bolt-protocol-v5x7.js @@ -0,0 +1,59 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import BoltProtocolV5x6 from './bolt-protocol-v5x6' + +import transformersFactories from './bolt-protocol-v5x5.transformer' +import Transformer from './transformer' + +import { internal } from 'neo4j-driver-core' + +const { + constants: { BOLT_PROTOCOL_V5_7 } +} = internal + +const DEFAULT_DIAGNOSTIC_RECORD = Object.freeze({ + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/' +}) + +export default class BoltProtocol extends BoltProtocolV5x6 { + get version () { + return BOLT_PROTOCOL_V5_7 + } + + get transformer () { + if (this._transformer === undefined) { + this._transformer = new Transformer(Object.values(transformersFactories).map(create => create(this._config, this._log))) + } + return this._transformer + } + + /** + * + * @param {object} metadata + * @returns {object} + */ + enrichErrorMetadata (metadata) { + return { + ...metadata, + cause: (metadata.cause !== null && metadata.cause !== undefined) ? this.enrichErrorMetadata(metadata.cause) : null, + code: metadata.neo4j_code, + diagnostic_record: metadata.diagnostic_record !== null ? { ...DEFAULT_DIAGNOSTIC_RECORD, ...metadata.diagnostic_record } : null + } + } +} diff --git a/packages/bolt-connection/src/bolt/bolt-protocol-v5x7.transformer.js b/packages/bolt-connection/src/bolt/bolt-protocol-v5x7.transformer.js new file mode 100644 index 000000000..96fe5565b --- /dev/null +++ b/packages/bolt-connection/src/bolt/bolt-protocol-v5x7.transformer.js @@ -0,0 +1,22 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import v5x6 from './bolt-protocol-v5x6.transformer' + +export default { + ...v5x6 +} diff --git a/packages/bolt-connection/src/bolt/create.js b/packages/bolt-connection/src/bolt/create.js index fa8b41098..fe204b612 100644 --- a/packages/bolt-connection/src/bolt/create.js +++ b/packages/bolt-connection/src/bolt/create.js @@ -31,6 +31,7 @@ import BoltProtocolV5x3 from './bolt-protocol-v5x3' import BoltProtocolV5x4 from './bolt-protocol-v5x4' import BoltProtocolV5x5 from './bolt-protocol-v5x5' import BoltProtocolV5x6 from './bolt-protocol-v5x6' +import BoltProtocolV5x7 from './bolt-protocol-v5x7' // eslint-disable-next-line no-unused-vars import { Chunker, Dechunker } from '../channel' import ResponseHandler from './response-handler' @@ -64,6 +65,7 @@ export default function create ({ const createResponseHandler = protocol => { const responseHandler = new ResponseHandler({ transformMetadata: protocol.transformMetadata.bind(protocol), + enrichErrorMetadata: protocol.enrichErrorMetadata.bind(protocol), log, observer }) @@ -247,6 +249,14 @@ function createProtocol ( log, onProtocolError, serversideRouting) + case 5.7: + return new BoltProtocolV5x7(server, + chunker, + packingConfig, + createResponseHandler, + log, + onProtocolError, + serversideRouting) default: throw newError('Unknown Bolt protocol version: ' + version) } diff --git a/packages/bolt-connection/src/bolt/handshake.js b/packages/bolt-connection/src/bolt/handshake.js index 00f08e8e5..abf318025 100644 --- a/packages/bolt-connection/src/bolt/handshake.js +++ b/packages/bolt-connection/src/bolt/handshake.js @@ -76,7 +76,7 @@ function parseNegotiatedResponse (buffer, log) { */ function newHandshakeBuffer () { return createHandshakeMessage([ - [version(5, 6), version(5, 0)], + [version(5, 7), version(5, 0)], [version(4, 4), version(4, 2)], version(4, 1), version(3, 0) diff --git a/packages/bolt-connection/src/bolt/response-handler.js b/packages/bolt-connection/src/bolt/response-handler.js index 6944c8ed4..c846067f5 100644 --- a/packages/bolt-connection/src/bolt/response-handler.js +++ b/packages/bolt-connection/src/bolt/response-handler.js @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { newError, json } from 'neo4j-driver-core' +import { newError, newGQLError, json } from 'neo4j-driver-core' // Signature bytes for each response message type const SUCCESS = 0x70 // 0111 0000 // SUCCESS @@ -70,10 +70,11 @@ export default class ResponseHandler { * @param {Logger} log The logger * @param {ResponseHandler~Observer} observer Object which will be notified about errors */ - constructor ({ transformMetadata, log, observer } = {}) { + constructor ({ transformMetadata, enrichErrorMetadata, log, observer } = {}) { this._pendingObservers = [] this._log = log this._transformMetadata = transformMetadata || NO_OP_IDENTITY + this._enrichErrorMetadata = enrichErrorMetadata || NO_OP_IDENTITY this._observer = Object.assign( { onObserversCountChange: NO_OP, @@ -115,11 +116,7 @@ export default class ResponseHandler { this._log.debug(`S: FAILURE ${json.stringify(msg)}`) } try { - const standardizedCode = _standardizeCode(payload.code) - const error = newError(payload.message, standardizedCode) - this._currentFailure = this._observer.onErrorApplyTransformation( - error - ) + this._currentFailure = this._handleErrorPayload(this._enrichErrorMetadata(payload)) this._currentObserver.onError(this._currentFailure) } finally { this._updateCurrentObserver() @@ -196,6 +193,23 @@ export default class ResponseHandler { _resetFailure () { this._currentFailure = null } + + _handleErrorPayload (payload) { + const standardizedCode = _standardizeCode(payload.code) + const cause = payload.cause != null ? this._handleErrorCause(payload.cause) : undefined + const error = newError(payload.message, standardizedCode, cause, payload.gql_status, payload.description, payload.diagnostic_record) + return this._observer.onErrorApplyTransformation( + error + ) + } + + _handleErrorCause (payload) { + const cause = payload.cause != null ? this._handleErrorCause(payload.cause) : undefined + const error = newGQLError(payload.message, cause, payload.gql_status, payload.description, payload.diagnostic_record) + return this._observer.onErrorApplyTransformation( + error + ) + } } /** diff --git a/packages/bolt-connection/src/connection/connection-channel.js b/packages/bolt-connection/src/connection/connection-channel.js index b056e3e1b..bf4e65ca7 100644 --- a/packages/bolt-connection/src/connection/connection-channel.js +++ b/packages/bolt-connection/src/connection/connection-channel.js @@ -441,7 +441,7 @@ export default class ChannelConnection extends Connection { reject(error) } else { const neo4jError = this._handleProtocolError( - 'Received FAILURE as a response for RESET: ' + error + `Received FAILURE as a response for RESET: ${error}` ) reject(neo4jError) } diff --git a/packages/bolt-connection/test/bolt/__snapshots__/bolt-protocol-v5x7.test.js.snap b/packages/bolt-connection/test/bolt/__snapshots__/bolt-protocol-v5x7.test.js.snap new file mode 100644 index 000000000..e04e65b76 --- /dev/null +++ b/packages/bolt-connection/test/bolt/__snapshots__/bolt-protocol-v5x7.test.js.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#unit BoltProtocolV5x7 .packable() should resultant function not pack graph types (Node) 1`] = `"It is not allowed to pass nodes in query parameters, given: (c:a {a:"b"})"`; + +exports[`#unit BoltProtocolV5x7 .packable() should resultant function not pack graph types (Path) 1`] = `"It is not allowed to pass paths in query parameters, given: [object Object]"`; + +exports[`#unit BoltProtocolV5x7 .packable() should resultant function not pack graph types (Relationship) 1`] = `"It is not allowed to pass relationships in query parameters, given: (e)-[:a {b:"c"}]->(f)"`; + +exports[`#unit BoltProtocolV5x7 .packable() should resultant function not pack graph types (UnboundRelationship) 1`] = `"It is not allowed to pass unbound relationships in query parameters, given: -[:a {b:"c"}]->"`; + +exports[`#unit BoltProtocolV5x7 .unpack() should not unpack with wrong size (Date with less fields) 1`] = `"Wrong struct size for Date, expected 1 but was 0"`; + +exports[`#unit BoltProtocolV5x7 .unpack() should not unpack with wrong size (Date with more fields) 1`] = `"Wrong struct size for Date, expected 1 but was 2"`; + +exports[`#unit BoltProtocolV5x7 .unpack() should not unpack with wrong size (DateTimeWithZoneId with less fields) 1`] = `"Wrong struct size for DateTimeWithZoneId, expected 3 but was 2"`; + +exports[`#unit BoltProtocolV5x7 .unpack() should not unpack with wrong size (DateTimeWithZoneId with more fields) 1`] = `"Wrong struct size for DateTimeWithZoneId, expected 3 but was 4"`; + +exports[`#unit BoltProtocolV5x7 .unpack() should not unpack with wrong size (DateTimeWithZoneOffset with less fields) 1`] = `"Wrong struct size for DateTimeWithZoneOffset, expected 3 but was 2"`; + +exports[`#unit BoltProtocolV5x7 .unpack() should not unpack with wrong size (DateTimeWithZoneOffset with more fields) 1`] = `"Wrong struct size for DateTimeWithZoneOffset, expected 3 but was 4"`; + +exports[`#unit BoltProtocolV5x7 .unpack() should not unpack with wrong size (Duration with less fields) 1`] = `"Wrong struct size for Duration, expected 4 but was 3"`; + +exports[`#unit BoltProtocolV5x7 .unpack() should not unpack with wrong size (Duration with more fields) 1`] = `"Wrong struct size for Duration, expected 4 but was 5"`; + +exports[`#unit BoltProtocolV5x7 .unpack() should not unpack with wrong size (LocalDateTime with less fields) 1`] = `"Wrong struct size for LocalDateTime, expected 2 but was 1"`; + +exports[`#unit BoltProtocolV5x7 .unpack() should not unpack with wrong size (LocalDateTime with more fields) 1`] = `"Wrong struct size for LocalDateTime, expected 2 but was 3"`; + +exports[`#unit BoltProtocolV5x7 .unpack() should not unpack with wrong size (LocalTime with less fields) 1`] = `"Wrong struct size for LocalTime, expected 1 but was 0"`; + +exports[`#unit BoltProtocolV5x7 .unpack() should not unpack with wrong size (LocalTime with more fields) 1`] = `"Wrong struct size for LocalTime, expected 1 but was 2"`; + +exports[`#unit BoltProtocolV5x7 .unpack() should not unpack with wrong size (Node with less fields) 1`] = `"Wrong struct size for Node, expected 4 but was 3"`; + +exports[`#unit BoltProtocolV5x7 .unpack() should not unpack with wrong size (Node with more fields) 1`] = `"Wrong struct size for Node, expected 4 but was 5"`; + +exports[`#unit BoltProtocolV5x7 .unpack() should not unpack with wrong size (Path with less fields) 1`] = `"Wrong struct size for Path, expected 3 but was 2"`; + +exports[`#unit BoltProtocolV5x7 .unpack() should not unpack with wrong size (Path with more fields) 1`] = `"Wrong struct size for Path, expected 3 but was 4"`; + +exports[`#unit BoltProtocolV5x7 .unpack() should not unpack with wrong size (Point with less fields) 1`] = `"Wrong struct size for Point2D, expected 3 but was 2"`; + +exports[`#unit BoltProtocolV5x7 .unpack() should not unpack with wrong size (Point with more fields) 1`] = `"Wrong struct size for Point2D, expected 3 but was 4"`; + +exports[`#unit BoltProtocolV5x7 .unpack() should not unpack with wrong size (Point3D with less fields) 1`] = `"Wrong struct size for Point3D, expected 4 but was 3"`; + +exports[`#unit BoltProtocolV5x7 .unpack() should not unpack with wrong size (Point3D with more fields) 1`] = `"Wrong struct size for Point3D, expected 4 but was 5"`; + +exports[`#unit BoltProtocolV5x7 .unpack() should not unpack with wrong size (Relationship with less fields) 1`] = `"Wrong struct size for Relationship, expected 8 but was 5"`; + +exports[`#unit BoltProtocolV5x7 .unpack() should not unpack with wrong size (Relationship with more fields) 1`] = `"Wrong struct size for Relationship, expected 8 but was 9"`; + +exports[`#unit BoltProtocolV5x7 .unpack() should not unpack with wrong size (Time with less fields) 1`] = `"Wrong struct size for Time, expected 2 but was 1"`; + +exports[`#unit BoltProtocolV5x7 .unpack() should not unpack with wrong size (Time with more fileds) 1`] = `"Wrong struct size for Time, expected 2 but was 3"`; + +exports[`#unit BoltProtocolV5x7 .unpack() should not unpack with wrong size (UnboundRelationship with less fields) 1`] = `"Wrong struct size for UnboundRelationship, expected 4 but was 3"`; + +exports[`#unit BoltProtocolV5x7 .unpack() should not unpack with wrong size (UnboundRelationship with more fields) 1`] = `"Wrong struct size for UnboundRelationship, expected 4 but was 5"`; diff --git a/packages/bolt-connection/test/bolt/bolt-protocol-v5x7.test.js b/packages/bolt-connection/test/bolt/bolt-protocol-v5x7.test.js new file mode 100644 index 000000000..23727925d --- /dev/null +++ b/packages/bolt-connection/test/bolt/bolt-protocol-v5x7.test.js @@ -0,0 +1,1579 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import BoltProtocolV5x7 from '../../src/bolt/bolt-protocol-v5x7' +import RequestMessage from '../../src/bolt/request-message' +import { v2, structure } from '../../src/packstream' +import utils from '../test-utils' +import { LoginObserver, RouteObserver } from '../../src/bolt/stream-observers' +import fc from 'fast-check' +import { + Date, + DateTime, + Duration, + LocalDateTime, + LocalTime, + Path, + PathSegment, + Point, + Relationship, + Time, + UnboundRelationship, + Node, + internal +} from 'neo4j-driver-core' + +import { alloc } from '../../src/channel' +import { notificationFilterBehaviour, telemetryBehaviour } from './behaviour' + +const WRITE = 'WRITE' + +const { + txConfig: { TxConfig }, + bookmarks: { Bookmarks }, + logger: { Logger }, + temporalUtil +} = internal + +describe('#unit BoltProtocolV5x7', () => { + beforeEach(() => { + expect.extend(utils.matchers) + }) + + telemetryBehaviour.protocolSupportsTelemetry(newProtocol) + + it('should enrich error metadata', () => { + const protocol = new BoltProtocolV5x7() + const enrichedData = protocol.enrichErrorMetadata({ neo4j_code: 'hello', diagnostic_record: {} }) + expect(enrichedData.code).toBe('hello') + expect(enrichedData.diagnostic_record.OPERATION).toBe('') + expect(enrichedData.diagnostic_record.OPERATION_CODE).toBe('0') + expect(enrichedData.diagnostic_record.CURRENT_SCHEMA).toBe('/') + }) + + it('should request routing information', () => { + const recorder = new utils.MessageRecordingConnection() + const protocol = new BoltProtocolV5x7(recorder, null, false) + utils.spyProtocolWrite(protocol) + const routingContext = { someContextParam: 'value' } + const databaseName = 'name' + + const observer = protocol.requestRoutingInformation({ + routingContext, + databaseName + }) + + protocol.verifyMessageCount(1) + expect(protocol.messages[0]).toBeMessage( + RequestMessage.routeV4x4(routingContext, [], { databaseName, impersonatedUser: null }) + ) + expect(protocol.observers).toEqual([observer]) + expect(observer).toEqual(expect.any(RouteObserver)) + expect(protocol.flushes).toEqual([true]) + }) + + it('should request routing information sending bookmarks', () => { + const recorder = new utils.MessageRecordingConnection() + const protocol = new BoltProtocolV5x7(recorder, null, false) + utils.spyProtocolWrite(protocol) + const routingContext = { someContextParam: 'value' } + const listOfBookmarks = ['a', 'b', 'c'] + const bookmarks = new Bookmarks(listOfBookmarks) + const databaseName = 'name' + + const observer = protocol.requestRoutingInformation({ + routingContext, + databaseName, + sessionContext: { bookmarks } + }) + + protocol.verifyMessageCount(1) + expect(protocol.messages[0]).toBeMessage( + RequestMessage.routeV4x4(routingContext, listOfBookmarks, { databaseName, impersonatedUser: null }) + ) + expect(protocol.observers).toEqual([observer]) + expect(observer).toEqual(expect.any(RouteObserver)) + expect(protocol.flushes).toEqual([true]) + }) + + it('should run a query', () => { + const database = 'testdb' + const bookmarks = new Bookmarks([ + 'neo4j:bookmark:v1:tx1', + 'neo4j:bookmark:v1:tx2' + ]) + const txConfig = new TxConfig({ + timeout: 5000, + metadata: { x: 1, y: 'something' } + }) + const recorder = new utils.MessageRecordingConnection() + const protocol = new BoltProtocolV5x7(recorder, null, false) + utils.spyProtocolWrite(protocol) + + const query = 'RETURN $x, $y' + const parameters = { x: 'x', y: 'y' } + + const observer = protocol.run(query, parameters, { + bookmarks, + txConfig, + database, + mode: WRITE + }) + + protocol.verifyMessageCount(2) + + expect(protocol.messages[0]).toBeMessage( + RequestMessage.runWithMetadata(query, parameters, { + bookmarks, + txConfig, + database, + mode: WRITE + }) + ) + expect(protocol.messages[1]).toBeMessage(RequestMessage.pull()) + expect(protocol.observers).toEqual([observer, observer]) + expect(protocol.flushes).toEqual([false, true]) + }) + + it('should run a with impersonated user', () => { + const database = 'testdb' + const impersonatedUser = 'the impostor' + const bookmarks = new Bookmarks([ + 'neo4j:bookmark:v1:tx1', + 'neo4j:bookmark:v1:tx2' + ]) + const txConfig = new TxConfig({ + timeout: 5000, + metadata: { x: 1, y: 'something' } + }) + const recorder = new utils.MessageRecordingConnection() + const protocol = new BoltProtocolV5x7(recorder, null, false) + utils.spyProtocolWrite(protocol) + + const query = 'RETURN $x, $y' + const parameters = { x: 'x', y: 'y' } + + const observer = protocol.run(query, parameters, { + bookmarks, + txConfig, + database, + mode: WRITE, + impersonatedUser + }) + + protocol.verifyMessageCount(2) + + expect(protocol.messages[0]).toBeMessage( + RequestMessage.runWithMetadata(query, parameters, { + bookmarks, + txConfig, + database, + mode: WRITE, + impersonatedUser + }) + ) + expect(protocol.messages[1]).toBeMessage(RequestMessage.pull()) + expect(protocol.observers).toEqual([observer, observer]) + expect(protocol.flushes).toEqual([false, true]) + }) + + it('should begin a transaction', () => { + const database = 'testdb' + const bookmarks = new Bookmarks([ + 'neo4j:bookmark:v1:tx1', + 'neo4j:bookmark:v1:tx2' + ]) + const txConfig = new TxConfig({ + timeout: 5000, + metadata: { x: 1, y: 'something' } + }) + const recorder = new utils.MessageRecordingConnection() + const protocol = new BoltProtocolV5x7(recorder, null, false) + utils.spyProtocolWrite(protocol) + + const observer = protocol.beginTransaction({ + bookmarks, + txConfig, + database, + mode: WRITE + }) + + protocol.verifyMessageCount(1) + expect(protocol.messages[0]).toBeMessage( + RequestMessage.begin({ bookmarks, txConfig, database, mode: WRITE }) + ) + expect(protocol.observers).toEqual([observer]) + expect(protocol.flushes).toEqual([true]) + }) + + it('should begin a transaction with impersonated user', () => { + const database = 'testdb' + const impersonatedUser = 'the impostor' + const bookmarks = new Bookmarks([ + 'neo4j:bookmark:v1:tx1', + 'neo4j:bookmark:v1:tx2' + ]) + const txConfig = new TxConfig({ + timeout: 5000, + metadata: { x: 1, y: 'something' } + }) + const recorder = new utils.MessageRecordingConnection() + const protocol = new BoltProtocolV5x7(recorder, null, false) + utils.spyProtocolWrite(protocol) + + const observer = protocol.beginTransaction({ + bookmarks, + txConfig, + database, + mode: WRITE, + impersonatedUser + }) + + protocol.verifyMessageCount(1) + expect(protocol.messages[0]).toBeMessage( + RequestMessage.begin({ bookmarks, txConfig, database, mode: WRITE, impersonatedUser }) + ) + expect(protocol.observers).toEqual([observer]) + expect(protocol.flushes).toEqual([true]) + }) + + it('should return correct bolt version number', () => { + const protocol = new BoltProtocolV5x7(null, null, false) + + expect(protocol.version).toBe(5.7) + }) + + it('should update metadata', () => { + const metadata = { t_first: 1, t_last: 2, db_hits: 3, some_other_key: 4 } + const protocol = new BoltProtocolV5x7(null, null, false) + + const transformedMetadata = protocol.transformMetadata(metadata) + + expect(transformedMetadata).toEqual({ + result_available_after: 1, + result_consumed_after: 2, + db_hits: 3, + some_other_key: 4 + }) + }) + + it('should initialize connection', () => { + const recorder = new utils.MessageRecordingConnection() + const protocol = new BoltProtocolV5x7(recorder, null, false) + utils.spyProtocolWrite(protocol) + + const clientName = 'js-driver/1.2.3' + const boltAgent = { + product: 'neo4j-javascript/5.7', + platform: 'netbsd 1.1.1; Some arch', + languageDetails: 'Node/16.0.1 (v8 1.7.0)' + } + const authToken = { username: 'neo4j', password: 'secret' } + + const observer = protocol.initialize({ userAgent: clientName, boltAgent, authToken }) + + protocol.verifyMessageCount(2) + expect(protocol.messages[0]).toBeMessage( + RequestMessage.hello5x3(clientName, boltAgent) + ) + expect(protocol.messages[1]).toBeMessage( + RequestMessage.logon(authToken) + ) + + expect(protocol.observers.length).toBe(2) + + // hello observer + const helloObserver = protocol.observers[0] + expect(helloObserver).toBeInstanceOf(LoginObserver) + expect(helloObserver).not.toBe(observer) + + // login observer + const loginObserver = protocol.observers[1] + expect(loginObserver).toBeInstanceOf(LoginObserver) + expect(loginObserver).toBe(observer) + + expect(protocol.flushes).toEqual([false, true]) + }) + + it.each([ + 'javascript-driver/5.7.0', + '', + undefined, + null + ])('should always use the user agent set by the user', (userAgent) => { + const recorder = new utils.MessageRecordingConnection() + const protocol = new BoltProtocolV5x7(recorder, null, false) + utils.spyProtocolWrite(protocol) + + const boltAgent = { + product: 'neo4j-javascript/5.7', + platform: 'netbsd 1.1.1; Some arch', + languageDetails: 'Node/16.0.1 (v8 1.7.0)' + } + const authToken = { username: 'neo4j', password: 'secret' } + + const observer = protocol.initialize({ userAgent, boltAgent, authToken }) + + protocol.verifyMessageCount(2) + expect(protocol.messages[0]).toBeMessage( + RequestMessage.hello5x3(userAgent, boltAgent) + ) + expect(protocol.messages[1]).toBeMessage( + RequestMessage.logon(authToken) + ) + + expect(protocol.observers.length).toBe(2) + + // hello observer + const helloObserver = protocol.observers[0] + expect(helloObserver).toBeInstanceOf(LoginObserver) + expect(helloObserver).not.toBe(observer) + + // login observer + const loginObserver = protocol.observers[1] + expect(loginObserver).toBeInstanceOf(LoginObserver) + expect(loginObserver).toBe(observer) + + expect(protocol.flushes).toEqual([false, true]) + }) + + it.each( + [true, false] + )('should logon to the server [flush=%s]', (flush) => { + const recorder = new utils.MessageRecordingConnection() + const protocol = new BoltProtocolV5x7(recorder, null, false) + utils.spyProtocolWrite(protocol) + + const authToken = { username: 'neo4j', password: 'secret' } + + const observer = protocol.logon({ authToken, flush }) + + protocol.verifyMessageCount(1) + + expect(protocol.messages[0]).toBeMessage( + RequestMessage.logon(authToken) + ) + + expect(protocol.observers).toEqual([observer]) + expect(protocol.flushes).toEqual([flush]) + }) + + it.each( + [true, false] + )('should logoff from the server [flush=%s]', (flush) => { + const recorder = new utils.MessageRecordingConnection() + const protocol = new BoltProtocolV5x7(recorder, null, false) + utils.spyProtocolWrite(protocol) + + const observer = protocol.logoff({ flush }) + + protocol.verifyMessageCount(1) + + expect(protocol.messages[0]).toBeMessage( + RequestMessage.logoff() + ) + + expect(protocol.observers).toEqual([observer]) + expect(protocol.flushes).toEqual([flush]) + }) + + it('should begin a transaction', () => { + const bookmarks = new Bookmarks([ + 'neo4j:bookmark:v1:tx1', + 'neo4j:bookmark:v1:tx2' + ]) + const txConfig = new TxConfig({ + timeout: 5000, + metadata: { x: 1, y: 'something' } + }) + const recorder = new utils.MessageRecordingConnection() + const protocol = new BoltProtocolV5x7(recorder, null, false) + utils.spyProtocolWrite(protocol) + + const observer = protocol.beginTransaction({ + bookmarks, + txConfig, + mode: WRITE + }) + + protocol.verifyMessageCount(1) + expect(protocol.messages[0]).toBeMessage( + RequestMessage.begin({ bookmarks, txConfig, mode: WRITE }) + ) + expect(protocol.observers).toEqual([observer]) + expect(protocol.flushes).toEqual([true]) + }) + + it('should commit', () => { + const recorder = new utils.MessageRecordingConnection() + const protocol = new BoltProtocolV5x7(recorder, null, false) + utils.spyProtocolWrite(protocol) + + const observer = protocol.commitTransaction() + + protocol.verifyMessageCount(1) + expect(protocol.messages[0]).toBeMessage(RequestMessage.commit()) + expect(protocol.observers).toEqual([observer]) + expect(protocol.flushes).toEqual([true]) + }) + + it('should rollback', () => { + const recorder = new utils.MessageRecordingConnection() + const protocol = new BoltProtocolV5x7(recorder, null, false) + utils.spyProtocolWrite(protocol) + + const observer = protocol.rollbackTransaction() + + protocol.verifyMessageCount(1) + expect(protocol.messages[0]).toBeMessage(RequestMessage.rollback()) + expect(protocol.observers).toEqual([observer]) + expect(protocol.flushes).toEqual([true]) + }) + + it('should support logoff', () => { + const recorder = new utils.MessageRecordingConnection() + const protocol = new BoltProtocolV5x7(recorder, null, false) + + expect(protocol.supportsReAuth).toBe(true) + }) + + describe('unpacker configuration', () => { + test.each([ + [false, false], + [false, true], + [true, false], + [true, true] + ])( + 'should create unpacker with disableLosslessIntegers=%p and useBigInt=%p', + (disableLosslessIntegers, useBigInt) => { + const protocol = new BoltProtocolV5x7(null, null, { + disableLosslessIntegers, + useBigInt + }) + expect(protocol._unpacker._disableLosslessIntegers).toBe( + disableLosslessIntegers + ) + expect(protocol._unpacker._useBigInt).toBe(useBigInt) + } + ) + }) + + describe('notificationFilter', () => { + notificationFilterBehaviour.shouldSupportGqlNotificationFilterOnInitialize(newProtocol) + notificationFilterBehaviour.shouldSupportGqlNotificationFilterOnBeginTransaction(newProtocol) + notificationFilterBehaviour.shouldSupportGqlNotificationFilterOnRun(newProtocol) + }) + + describe('watermarks', () => { + it('.run() should configure watermarks', () => { + const recorder = new utils.MessageRecordingConnection() + const protocol = utils.spyProtocolWrite( + new BoltProtocolV5x7(recorder, null, false) + ) + + const query = 'RETURN $x, $y' + const parameters = { x: 'x', y: 'y' } + const observer = protocol.run(query, parameters, { + bookmarks: Bookmarks.empty(), + txConfig: TxConfig.empty(), + lowRecordWatermark: 100, + highRecordWatermark: 200 + }) + + expect(observer._lowRecordWatermark).toEqual(100) + expect(observer._highRecordWatermark).toEqual(200) + }) + }) + + describe('packstream', () => { + it('should configure v2 packer', () => { + const protocol = new BoltProtocolV5x7(null, null, false) + expect(protocol.packer()).toBeInstanceOf(v2.Packer) + }) + + it('should configure v2 unpacker', () => { + const protocol = new BoltProtocolV5x7(null, null, false) + expect(protocol.unpacker()).toBeInstanceOf(v2.Unpacker) + }) + }) + + describe('.packable()', () => { + it.each([ + ['Node', new Node(1, ['a'], { a: 'b' }, 'c')], + ['Relationship', new Relationship(1, 2, 3, 'a', { b: 'c' }, 'd', 'e', 'f')], + ['UnboundRelationship', new UnboundRelationship(1, 'a', { b: 'c' }, '1')], + ['Path', new Path(new Node(1, [], {}), new Node(2, [], {}), [])] + ])('should resultant function not pack graph types (%s)', (_, graphType) => { + const protocol = new BoltProtocolV5x7( + new utils.MessageRecordingConnection(), + null, + false + ) + + const packable = protocol.packable(graphType) + + expect(packable).toThrowErrorMatchingSnapshot() + }) + + it.each([ + ['Duration', new Duration(1, 1, 1, 1)], + ['LocalTime', new LocalTime(1, 1, 1, 1)], + ['Time', new Time(1, 1, 1, 1, 1)], + ['Date', new Date(1, 1, 1)], + ['LocalDateTime', new LocalDateTime(1, 1, 1, 1, 1, 1, 1)], + [ + 'DateTimeWithZoneOffset', + new DateTime(2022, 6, 14, 15, 21, 18, 183_000_000, 120 * 60) + ], + [ + 'DateTimeWithZoneOffset / 1978', + new DateTime(1978, 12, 16, 10, 5, 59, 128000987, -150 * 60) + ], + [ + 'DateTimeWithZoneId / Berlin 2:30 CET', + new DateTime(2022, 10, 30, 2, 30, 0, 183_000_000, 2 * 60 * 60, 'Europe/Berlin') + ], + [ + 'DateTimeWithZoneId / Berlin 2:30 CEST', + new DateTime(2022, 10, 30, 2, 30, 0, 183_000_000, 1 * 60 * 60, 'Europe/Berlin') + ], + ['Point2D', new Point(1, 1, 1)], + ['Point3D', new Point(1, 1, 1, 1)] + ])('should pack spatial types and temporal types (%s)', (_, object) => { + const buffer = alloc(256) + const protocol = new BoltProtocolV5x7( + new utils.MessageRecordingConnection(), + buffer, + { + disableLosslessIntegers: true + } + ) + + const packable = protocol.packable(object) + + expect(packable).not.toThrow() + + buffer.reset() + + const unpacked = protocol.unpack(buffer) + + expect(unpacked).toEqual(object) + }) + + it.each([ + [ + 'DateTimeWithZoneId / Australia', + new DateTime(2022, 6, 15, 15, 21, 18, 183_000_000, undefined, 'Australia/Eucla') + ], + [ + 'DateTimeWithZoneId', + new DateTime(2022, 6, 22, 15, 21, 18, 183_000_000, undefined, 'Europe/Berlin') + ], + [ + 'DateTimeWithZoneId / Europe just before turn CEST', + new DateTime(2022, 3, 27, 1, 59, 59, 183_000_000, undefined, 'Europe/Berlin') + ], + [ + 'DateTimeWithZoneId / Europe just 1 before turn CEST', + new DateTime(2022, 3, 27, 0, 59, 59, 183_000_000, undefined, 'Europe/Berlin') + ], + [ + 'DateTimeWithZoneId / Europe just after turn CEST', + new DateTime(2022, 3, 27, 3, 0, 0, 183_000_000, undefined, 'Europe/Berlin') + ], + [ + 'DateTimeWithZoneId / Europe just 1 after turn CEST', + new DateTime(2022, 3, 27, 4, 0, 0, 183_000_000, undefined, 'Europe/Berlin') + ], + [ + 'DateTimeWithZoneId / Europe just before turn CET', + new DateTime(2022, 10, 30, 2, 59, 59, 183_000_000, undefined, 'Europe/Berlin') + ], + [ + 'DateTimeWithZoneId / Europe just 1 before turn CET', + new DateTime(2022, 10, 30, 1, 59, 59, 183_000_000, undefined, 'Europe/Berlin') + ], + [ + 'DateTimeWithZoneId / Europe just after turn CET', + new DateTime(2022, 10, 30, 3, 0, 0, 183_000_000, undefined, 'Europe/Berlin') + ], + [ + 'DateTimeWithZoneId / Europe just 1 after turn CET', + new DateTime(2022, 10, 30, 4, 0, 0, 183_000_000, undefined, 'Europe/Berlin') + ], + [ + 'DateTimeWithZoneId / Sao Paulo just before turn summer time', + new DateTime(2018, 11, 4, 11, 59, 59, 183_000_000, undefined, 'America/Sao_Paulo') + ], + [ + 'DateTimeWithZoneId / Sao Paulo just 1 before turn summer time', + new DateTime(2018, 11, 4, 10, 59, 59, 183_000_000, undefined, 'America/Sao_Paulo') + ], + [ + 'DateTimeWithZoneId / Sao Paulo just after turn summer time', + new DateTime(2018, 11, 5, 1, 0, 0, 183_000_000, undefined, 'America/Sao_Paulo') + ], + [ + 'DateTimeWithZoneId / Sao Paulo just 1 after turn summer time', + new DateTime(2018, 11, 5, 2, 0, 0, 183_000_000, undefined, 'America/Sao_Paulo') + ], + [ + 'DateTimeWithZoneId / Sao Paulo just before turn winter time', + new DateTime(2019, 2, 17, 11, 59, 59, 183_000_000, undefined, 'America/Sao_Paulo') + ], + [ + 'DateTimeWithZoneId / Sao Paulo just 1 before turn winter time', + new DateTime(2019, 2, 17, 10, 59, 59, 183_000_000, undefined, 'America/Sao_Paulo') + ], + [ + 'DateTimeWithZoneId / Sao Paulo just after turn winter time', + new DateTime(2019, 2, 18, 0, 0, 0, 183_000_000, undefined, 'America/Sao_Paulo') + ], + [ + 'DateTimeWithZoneId / Sao Paulo just 1 after turn winter time', + new DateTime(2019, 2, 18, 1, 0, 0, 183_000_000, undefined, 'America/Sao_Paulo') + ], + [ + 'DateTimeWithZoneId / Istanbul', + new DateTime(1978, 12, 16, 12, 35, 59, 128000987, undefined, 'Europe/Istanbul') + ], + [ + 'DateTimeWithZoneId / Istanbul', + new DateTime(2020, 6, 15, 4, 30, 0, 183_000_000, undefined, 'Pacific/Honolulu') + ], + [ + 'DateWithWithZoneId / Berlin before common era', + new DateTime(-2020, 6, 15, 4, 30, 0, 183_000_000, undefined, 'Europe/Berlin') + ], + [ + 'DateWithWithZoneId / Max Date', + new DateTime(99_999, 12, 31, 23, 59, 59, 999_999_999, undefined, 'Pacific/Kiritimati') + ], + [ + 'DateWithWithZoneId / Min Date', + new DateTime(-99_999, 12, 31, 23, 59, 59, 999_999_999, undefined, 'Pacific/Samoa') + ], + [ + 'DateWithWithZoneId / Ambiguous date between 00 and 99', + new DateTime(50, 12, 31, 23, 59, 59, 999_999_999, undefined, 'Pacific/Samoa') + ] + ])('should pack and unpack DateTimeWithZoneId and without offset (%s)', (_, object) => { + const buffer = alloc(256) + const loggerFunction = jest.fn() + const protocol = new BoltProtocolV5x7( + new utils.MessageRecordingConnection(), + buffer, + { + disableLosslessIntegers: true + }, + undefined, + new Logger('debug', loggerFunction) + ) + + const packable = protocol.packable(object) + + expect(packable).not.toThrow() + expect(loggerFunction) + .toBeCalledWith('warn', + 'DateTime objects without "timeZoneOffsetSeconds" property ' + + 'are prune to bugs related to ambiguous times. For instance, ' + + '2022-10-30T2:30:00[Europe/Berlin] could be GMT+1 or GMT+2.') + + buffer.reset() + + const unpacked = protocol.unpack(buffer) + + expect(unpacked.timeZoneOffsetSeconds).toBeDefined() + + const unpackedDateTimeWithoutOffset = new DateTime( + unpacked.year, + unpacked.month, + unpacked.day, + unpacked.hour, + unpacked.minute, + unpacked.second, + unpacked.nanosecond, + undefined, + unpacked.timeZoneId + ) + + expect(unpackedDateTimeWithoutOffset).toEqual(object) + }) + + it('should pack and unpack DateTimeWithOffset', () => { + fc.assert( + fc.property( + fc.date({ + min: temporalUtil.newDate(utils.MIN_UTC_IN_MS + utils.ONE_DAY_IN_MS), + max: temporalUtil.newDate(utils.MAX_UTC_IN_MS - utils.ONE_DAY_IN_MS) + }), + fc.integer({ min: 0, max: 999_999 }), + utils.arbitraryTimeZoneId(), + (date, nanoseconds, timeZoneId) => { + const object = new DateTime( + date.getUTCFullYear(), + date.getUTCMonth() + 1, + date.getUTCDate(), + date.getUTCHours(), + date.getUTCMinutes(), + date.getUTCSeconds(), + date.getUTCMilliseconds() * 1_000_000 + nanoseconds, + undefined, + timeZoneId + ) + const buffer = alloc(256) + const loggerFunction = jest.fn() + const protocol = new BoltProtocolV5x7( + new utils.MessageRecordingConnection(), + buffer, + { + disableLosslessIntegers: true + }, + undefined, + new Logger('debug', loggerFunction) + ) + + const packable = protocol.packable(object) + + expect(packable).not.toThrow() + expect(loggerFunction) + .toBeCalledWith('warn', + 'DateTime objects without "timeZoneOffsetSeconds" property ' + + 'are prune to bugs related to ambiguous times. For instance, ' + + '2022-10-30T2:30:00[Europe/Berlin] could be GMT+1 or GMT+2.') + + buffer.reset() + + const unpacked = protocol.unpack(buffer) + + expect(unpacked.timeZoneOffsetSeconds).toBeDefined() + + const unpackedDateTimeWithoutOffset = new DateTime( + unpacked.year, + unpacked.month, + unpacked.day, + unpacked.hour, + unpacked.minute, + unpacked.second, + unpacked.nanosecond, + undefined, + unpacked.timeZoneId + ) + + expect(unpackedDateTimeWithoutOffset).toEqual(object) + }) + ) + }) + + it('should pack and unpack DateTimeWithZoneIdAndNoOffset', () => { + fc.assert( + fc.property(fc.date(), date => { + const object = DateTime.fromStandardDate(date) + const buffer = alloc(256) + const loggerFunction = jest.fn() + const protocol = new BoltProtocolV5x7( + new utils.MessageRecordingConnection(), + buffer, + { + disableLosslessIntegers: true + }, + undefined, + new Logger('debug', loggerFunction) + ) + + const packable = protocol.packable(object) + + expect(packable).not.toThrow() + + buffer.reset() + + const unpacked = protocol.unpack(buffer) + + expect(unpacked.timeZoneOffsetSeconds).toBeDefined() + + expect(unpacked).toEqual(object) + }) + ) + }) + }) + + describe('.unpack()', () => { + it.each([ + [ + 'Node', + new structure.Structure(0x4e, [1, ['a'], { c: 'd' }, 'elementId']), + new Node(1, ['a'], { c: 'd' }, 'elementId') + ], + [ + 'Relationship', + new structure.Structure(0x52, [1, 2, 3, '4', { 5: 6 }, 'elementId', 'node1', 'node2']), + new Relationship(1, 2, 3, '4', { 5: 6 }, 'elementId', 'node1', 'node2') + ], + [ + 'UnboundRelationship', + new structure.Structure(0x72, [1, '2', { 3: 4 }, 'elementId']), + new UnboundRelationship(1, '2', { 3: 4 }, 'elementId') + ], + [ + 'Path', + new structure.Structure( + 0x50, + [ + [ + new structure.Structure(0x4e, [1, ['2'], { 3: '4' }, 'node1']), + new structure.Structure(0x4e, [4, ['5'], { 6: 7 }, 'node2']), + new structure.Structure(0x4e, [2, ['3'], { 4: '5' }, 'node3']) + ], + [ + new structure.Structure(0x52, [3, 1, 4, 'reltype1', { 4: '5' }, 'rel1', 'node1', 'node2']), + new structure.Structure(0x52, [5, 4, 2, 'reltype2', { 6: 7 }, 'rel2', 'node2', 'node3']) + ], + [1, 1, 2, 2] + ] + ), + new Path( + new Node(1, ['2'], { 3: '4' }, 'node1'), + new Node(2, ['3'], { 4: '5' }, 'node3'), + [ + new PathSegment( + new Node(1, ['2'], { 3: '4' }, 'node1'), + new Relationship(3, 1, 4, 'reltype1', { 4: '5' }, 'rel1', 'node1', 'node2'), + new Node(4, ['5'], { 6: 7 }, 'node2') + ), + new PathSegment( + new Node(4, ['5'], { 6: 7 }, 'node2'), + new Relationship(5, 4, 2, 'reltype2', { 6: 7 }, 'rel2', 'node2', 'node3'), + new Node(2, ['3'], { 4: '5' }, 'node3') + ) + ] + ) + ] + ])('should unpack graph types (%s)', (_, struct, graphObject) => { + const buffer = alloc(256) + const protocol = new BoltProtocolV5x7( + new utils.MessageRecordingConnection(), + buffer, + false + ) + + const packable = protocol.packable(struct) + + expect(packable).not.toThrow() + + buffer.reset() + + const unpacked = protocol.unpack(buffer) + expect(unpacked).toEqual(graphObject) + }) + + it.each([ + [ + 'Node with less fields', + new structure.Structure(0x4e, [1, ['a'], { c: 'd' }]) + ], + [ + 'Node with more fields', + new structure.Structure(0x4e, [1, ['a'], { c: 'd' }, '1', 'b']) + ], + [ + 'Relationship with less fields', + new structure.Structure(0x52, [1, 2, 3, '4', { 5: 6 }]) + ], + [ + 'Relationship with more fields', + new structure.Structure(0x52, [1, 2, 3, '4', { 5: 6 }, '1', '2', '3', '4']) + ], + [ + 'UnboundRelationship with less fields', + new structure.Structure(0x72, [1, '2', { 3: 4 }]) + ], + [ + 'UnboundRelationship with more fields', + new structure.Structure(0x72, [1, '2', { 3: 4 }, '1', '2']) + ], + [ + 'Path with less fields', + new structure.Structure( + 0x50, + [ + [ + new structure.Structure(0x4e, [1, ['2'], { 3: '4' }]), + new structure.Structure(0x4e, [4, ['5'], { 6: 7 }]), + new structure.Structure(0x4e, [2, ['3'], { 4: '5' }]) + ], + [ + new structure.Structure(0x52, [3, 1, 4, 'rel1', { 4: '5' }]), + new structure.Structure(0x52, [5, 4, 2, 'rel2', { 6: 7 }]) + ] + ] + ) + ], + [ + 'Path with more fields', + new structure.Structure( + 0x50, + [ + [ + new structure.Structure(0x4e, [1, ['2'], { 3: '4' }]), + new structure.Structure(0x4e, [4, ['5'], { 6: 7 }]), + new structure.Structure(0x4e, [2, ['3'], { 4: '5' }]) + ], + [ + new structure.Structure(0x52, [3, 1, 4, 'rel1', { 4: '5' }]), + new structure.Structure(0x52, [5, 4, 2, 'rel2', { 6: 7 }]) + ], + [1, 1, 2, 2], + 'a' + ] + ) + ], + [ + 'Point with less fields', + new structure.Structure(0x58, [1, 2]) + ], + [ + 'Point with more fields', + new structure.Structure(0x58, [1, 2, 3, 4]) + ], + [ + 'Point3D with less fields', + new structure.Structure(0x59, [1, 2, 3]) + ], + + [ + 'Point3D with more fields', + new structure.Structure(0x59, [1, 2, 3, 4, 6]) + ], + [ + 'Duration with less fields', + new structure.Structure(0x45, [1, 2, 3]) + ], + [ + 'Duration with more fields', + new structure.Structure(0x45, [1, 2, 3, 4, 5]) + ], + [ + 'LocalTime with less fields', + new structure.Structure(0x74, []) + ], + [ + 'LocalTime with more fields', + new structure.Structure(0x74, [1, 2]) + ], + [ + 'Time with less fields', + new structure.Structure(0x54, [1]) + ], + [ + 'Time with more fileds', + new structure.Structure(0x54, [1, 2, 3]) + ], + [ + 'Date with less fields', + new structure.Structure(0x44, []) + ], + [ + 'Date with more fields', + new structure.Structure(0x44, [1, 2]) + ], + [ + 'LocalDateTime with less fields', + new structure.Structure(0x64, [1]) + ], + [ + 'LocalDateTime with more fields', + new structure.Structure(0x64, [1, 2, 3]) + ], + [ + 'DateTimeWithZoneOffset with less fields', + new structure.Structure(0x49, [1, 2]) + ], + [ + 'DateTimeWithZoneOffset with more fields', + new structure.Structure(0x49, [1, 2, 3, 4]) + ], + [ + 'DateTimeWithZoneId with less fields', + new structure.Structure(0x69, [1, 2]) + ], + [ + 'DateTimeWithZoneId with more fields', + new structure.Structure(0x69, [1, 2, 'America/Sao Paulo', 'Brasil']) + ] + ])('should not unpack with wrong size (%s)', (_, struct) => { + const buffer = alloc(256) + const protocol = new BoltProtocolV5x7( + new utils.MessageRecordingConnection(), + buffer, + false + ) + + const packable = protocol.packable(struct) + + expect(packable).not.toThrow() + + buffer.reset() + + const unpacked = protocol.unpack(buffer) + expect(() => unpacked instanceof structure.Structure).toThrowErrorMatchingSnapshot() + }) + + it.each([ + [ + 'Point', + new structure.Structure(0x58, [1, 2, 3]), + new Point(1, 2, 3) + ], + [ + 'Point3D', + new structure.Structure(0x59, [1, 2, 3, 4]), + new Point(1, 2, 3, 4) + ], + [ + 'Duration', + new structure.Structure(0x45, [1, 2, 3, 4]), + new Duration(1, 2, 3, 4) + ], + [ + 'LocalTime', + new structure.Structure(0x74, [1]), + new LocalTime(0, 0, 0, 1) + ], + [ + 'Time', + new structure.Structure(0x54, [1, 2]), + new Time(0, 0, 0, 1, 2) + ], + [ + 'Date', + new structure.Structure(0x44, [1]), + new Date(1970, 1, 2) + ], + [ + 'LocalDateTime', + new structure.Structure(0x64, [1, 2]), + new LocalDateTime(1970, 1, 1, 0, 0, 1, 2) + ], + [ + 'DateTimeWithZoneOffset', + new structure.Structure(0x49, [ + 1655212878, 183_000_000, 120 * 60 + ]), + new DateTime(2022, 6, 14, 15, 21, 18, 183_000_000, 120 * 60) + ], + [ + 'DateTimeWithZoneOffset / 1978', + new structure.Structure(0x49, [ + 282659759, 128000987, -150 * 60 + ]), + new DateTime(1978, 12, 16, 10, 5, 59, 128000987, -150 * 60) + ], + [ + 'DateTimeWithZoneId', + new structure.Structure(0x69, [ + 1655212878, 183_000_000, 'Europe/Berlin' + ]), + new DateTime(2022, 6, 14, 15, 21, 18, 183_000_000, 2 * 60 * 60, 'Europe/Berlin') + ], + [ + 'DateTimeWithZoneId / Australia', + new structure.Structure(0x69, [ + 1655212878, 183_000_000, 'Australia/Eucla' + ]), + new DateTime(2022, 6, 14, 22, 6, 18, 183_000_000, 8 * 60 * 60 + 45 * 60, 'Australia/Eucla') + ], + [ + 'DateTimeWithZoneId / Honolulu', + new structure.Structure(0x69, [ + 1592231400, 183_000_000, 'Pacific/Honolulu' + ]), + new DateTime(2020, 6, 15, 4, 30, 0, 183_000_000, -10 * 60 * 60, 'Pacific/Honolulu') + ], + [ + 'DateTimeWithZoneId / Midnight', + new structure.Structure(0x69, [ + 1685397950, 183_000_000, 'Europe/Berlin' + ]), + new DateTime(2023, 5, 30, 0, 5, 50, 183_000_000, 2 * 60 * 60, 'Europe/Berlin') + ] + ])('should unpack spatial types and temporal types (%s)', (_, struct, object) => { + const buffer = alloc(256) + const protocol = new BoltProtocolV5x7( + new utils.MessageRecordingConnection(), + buffer, + { + disableLosslessIntegers: true + } + ) + + const packable = protocol.packable(struct) + + expect(packable).not.toThrow() + + buffer.reset() + + const unpacked = protocol.unpack(buffer) + expect(unpacked).toEqual(object) + }) + + it.each([ + [ + 'DateTimeWithZoneOffset/0x46', + new structure.Structure(0x46, [1, 2, 3]) + ], + [ + 'DateTimeWithZoneId/0x66', + new structure.Structure(0x66, [1, 2, 'America/Sao_Paulo']) + ] + ])('should unpack deprecated temporal types as unknown structs (%s)', (_, struct) => { + const buffer = alloc(256) + const protocol = new BoltProtocolV5x7( + new utils.MessageRecordingConnection(), + buffer, + { + disableLosslessIntegers: true + } + ) + + const packable = protocol.packable(struct) + + expect(packable).not.toThrow() + + buffer.reset() + + const unpacked = protocol.unpack(buffer) + expect(unpacked).toEqual(struct) + }) + }) + + describe('result metadata enrichment', () => { + it('run should configure BoltProtocolV5x7._enrichMetadata as enrichMetadata', () => { + const database = 'testdb' + const bookmarks = new Bookmarks([ + 'neo4j:bookmark:v1:tx1', + 'neo4j:bookmark:v1:tx2' + ]) + const txConfig = new TxConfig({ + timeout: 5000, + metadata: { x: 1, y: 'something' } + }) + const recorder = new utils.MessageRecordingConnection() + const protocol = new BoltProtocolV5x7(recorder, null, false) + utils.spyProtocolWrite(protocol) + + const query = 'RETURN $x, $y' + const parameters = { x: 'x', y: 'y' } + + const observer = protocol.run(query, parameters, { + bookmarks, + txConfig, + database, + mode: WRITE + }) + + expect(observer._enrichMetadata).toBe(protocol._enrichMetadata) + }) + + describe('BoltProtocolV5x7._enrichMetadata', () => { + const protocol = newProtocol() + + it('should handle empty metadata', () => { + const metadata = protocol._enrichMetadata({}) + + expect(metadata).toEqual({}) + }) + + it('should handle metadata with random objects', () => { + const metadata = protocol._enrichMetadata({ + a: 1133, + b: 345 + }) + + expect(metadata).toEqual({ + a: 1133, + b: 345 + }) + }) + + it('should handle metadata not change notifications ', () => { + const metadata = protocol._enrichMetadata({ + a: 1133, + b: 345, + notifications: [ + { + severity: 'WARNING', + category: 'HINT' + } + ] + }) + + expect(metadata).toEqual({ + a: 1133, + b: 345, + notifications: [ + { + severity: 'WARNING', + category: 'HINT' + } + ] + }) + }) + + it.each([ + [null, null], + [undefined, undefined], + [[], []], + [statusesWithDiagnosticRecord(null, null), statusesWithDiagnosticRecord(null, null)], + [statusesWithDiagnosticRecord(undefined, undefined), statusesWithDiagnosticRecord({ + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/' + }, + { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/' + })], + [ + statusesWithDiagnosticRecord({ + OPERATION: 'A' + }), + statusesWithDiagnosticRecord({ + OPERATION: 'A', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/' + }) + ], + [ + statusesWithDiagnosticRecord({ + OPERATION: 'A', + OPERATION_CODE: 'B' + }), + statusesWithDiagnosticRecord({ + OPERATION: 'A', + OPERATION_CODE: 'B', + CURRENT_SCHEMA: '/' + }) + ], + [ + statusesWithDiagnosticRecord({ + OPERATION: 'A', + OPERATION_CODE: 'B', + CURRENT_SCHEMA: '/C' + }), + statusesWithDiagnosticRecord({ + OPERATION: 'A', + OPERATION_CODE: 'B', + CURRENT_SCHEMA: '/C' + }) + ], + [ + statusesWithDiagnosticRecord({ + OPERATION: 'A', + OPERATION_CODE: 'B', + CURRENT_SCHEMA: '/C', + _status_parameters: { d: 'E' } + }), + statusesWithDiagnosticRecord({ + OPERATION: 'A', + OPERATION_CODE: 'B', + CURRENT_SCHEMA: '/C', + _status_parameters: { d: 'E' } + }) + ], + [ + statusesWithDiagnosticRecord({ + OPERATION: 'A', + OPERATION_CODE: 'B', + CURRENT_SCHEMA: '/C', + _status_parameters: { d: 'E' }, + _severity: 'F' + }), + statusesWithDiagnosticRecord({ + OPERATION: 'A', + OPERATION_CODE: 'B', + CURRENT_SCHEMA: '/C', + _status_parameters: { d: 'E' }, + _severity: 'F' + }) + ], + [ + statusesWithDiagnosticRecord({ + OPERATION: 'A', + OPERATION_CODE: 'B', + CURRENT_SCHEMA: '/C', + _status_parameters: { d: 'E' }, + _severity: 'F', + _classification: 'G' + }), + statusesWithDiagnosticRecord({ + OPERATION: 'A', + OPERATION_CODE: 'B', + CURRENT_SCHEMA: '/C', + _status_parameters: { d: 'E' }, + _severity: 'F', + _classification: 'G' + }) + ], + [ + statusesWithDiagnosticRecord({ + OPERATION: 'A', + OPERATION_CODE: 'B', + CURRENT_SCHEMA: '/C', + _status_parameters: { d: 'E' }, + _severity: 'F', + _classification: 'G', + _position: { + offset: 1, + line: 2, + column: 3 + } + }), + statusesWithDiagnosticRecord({ + OPERATION: 'A', + OPERATION_CODE: 'B', + CURRENT_SCHEMA: '/C', + _status_parameters: { d: 'E' }, + _severity: 'F', + _classification: 'G', + _position: { + offset: 1, + line: 2, + column: 3 + } + }) + ], + [ + statusesWithDiagnosticRecord({ + OPERATION: null + }), + statusesWithDiagnosticRecord({ + OPERATION: null, + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/' + }) + ], + [ + statusesWithDiagnosticRecord({ + OPERATION: null, + OPERATION_CODE: null + }), + statusesWithDiagnosticRecord({ + OPERATION: null, + OPERATION_CODE: null, + CURRENT_SCHEMA: '/' + }) + ], + [ + statusesWithDiagnosticRecord({ + OPERATION: null, + OPERATION_CODE: null, + CURRENT_SCHEMA: null + }), + statusesWithDiagnosticRecord({ + OPERATION: null, + OPERATION_CODE: null, + CURRENT_SCHEMA: null + }) + ], + [ + statusesWithDiagnosticRecord({ + OPERATION: null, + OPERATION_CODE: null, + CURRENT_SCHEMA: null, + _status_parameters: null + }), + statusesWithDiagnosticRecord({ + OPERATION: null, + OPERATION_CODE: null, + CURRENT_SCHEMA: null, + _status_parameters: null + }) + ], + [ + statusesWithDiagnosticRecord({ + OPERATION: null, + OPERATION_CODE: null, + CURRENT_SCHEMA: null, + _status_parameters: null, + _severity: null + }), + statusesWithDiagnosticRecord({ + OPERATION: null, + OPERATION_CODE: null, + CURRENT_SCHEMA: null, + _status_parameters: null, + _severity: null + }) + ], + [ + statusesWithDiagnosticRecord({ + OPERATION: null, + OPERATION_CODE: null, + CURRENT_SCHEMA: null, + _status_parameters: null, + _severity: null, + _classification: null + }), + statusesWithDiagnosticRecord({ + OPERATION: null, + OPERATION_CODE: null, + CURRENT_SCHEMA: null, + _status_parameters: null, + _severity: null, + _classification: null + }) + ], + [ + statusesWithDiagnosticRecord({ + OPERATION: null, + OPERATION_CODE: null, + CURRENT_SCHEMA: null, + _status_parameters: null, + _severity: null, + _classification: null, + _position: null + }), + statusesWithDiagnosticRecord({ + OPERATION: null, + OPERATION_CODE: null, + CURRENT_SCHEMA: null, + _status_parameters: null, + _severity: null, + _classification: null, + _position: null + }) + ], + [ + statusesWithDiagnosticRecord({ + OPERATION: undefined, + OPERATION_CODE: undefined, + CURRENT_SCHEMA: undefined, + _status_parameters: undefined, + _severity: undefined, + _classification: undefined, + _position: undefined + }), + statusesWithDiagnosticRecord({ + OPERATION: undefined, + OPERATION_CODE: undefined, + CURRENT_SCHEMA: undefined, + _status_parameters: undefined, + _severity: undefined, + _classification: undefined, + _position: undefined + }) + ], + [ + [{ + gql_status: '03N33', + status_description: 'info: description', + neo4j_code: 'Neo.Info.My.Code', + title: 'Mitt title', + diagnostic_record: { + _classification: 'SOME', + _severity: 'INFORMATION' + } + }], + [{ + gql_status: '03N33', + status_description: 'info: description', + neo4j_code: 'Neo.Info.My.Code', + title: 'Mitt title', + diagnostic_record: { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/', + _classification: 'SOME', + _severity: 'INFORMATION' + } + }] + ], + [ + [{ + gql_status: '03N33', + status_description: 'info: description', + neo4j_code: 'Neo.Info.My.Code', + description: 'description', + title: 'Mitt title', + diagnostic_record: { + _classification: 'SOME', + _severity: 'INFORMATION' + } + }], + [{ + gql_status: '03N33', + status_description: 'info: description', + neo4j_code: 'Neo.Info.My.Code', + title: 'Mitt title', + description: 'description', + diagnostic_record: { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/', + _classification: 'SOME', + _severity: 'INFORMATION' + } + }] + ], + [ + [{ + gql_status: '03N33', + status_description: 'info: description', + description: 'description' + }], + [{ + gql_status: '03N33', + status_description: 'info: description', + description: 'description', + diagnostic_record: { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/' + } + }] + ] + ])('should handle statuses (%o) ', (statuses, expectedStatuses) => { + const metadata = protocol._enrichMetadata({ + a: 1133, + b: 345, + statuses + }) + + expect(metadata).toEqual({ + a: 1133, + b: 345, + statuses: expectedStatuses + }) + }) + }) + + function statusesWithDiagnosticRecord (...diagnosticRecords) { + return diagnosticRecords.map(diagnosticRecord => { + return { + gql_status: '00000', + status_description: 'note: successful completion', + diagnostic_record: diagnosticRecord + } + }) + } + }) + + function newProtocol (recorder) { + return new BoltProtocolV5x7(recorder, null, false, undefined, undefined, () => {}) + } +}) diff --git a/packages/bolt-connection/test/bolt/index.test.js b/packages/bolt-connection/test/bolt/index.test.js index b70a18c26..1a9117556 100644 --- a/packages/bolt-connection/test/bolt/index.test.js +++ b/packages/bolt-connection/test/bolt/index.test.js @@ -48,13 +48,13 @@ describe('#unit Bolt', () => { const writtenBuffer = channel.written[0] const boltMagicPreamble = '60 60 b0 17' - const protocolVersion5x6to5x0 = '00 06 06 05' + const protocolVersion5x7to5x0 = '00 07 07 05' const protocolVersion4x4to4x2 = '00 02 04 04' const protocolVersion4x1 = '00 00 01 04' const protocolVersion3 = '00 00 00 03' expect(writtenBuffer.toHex()).toEqual( - `${boltMagicPreamble} ${protocolVersion5x6to5x0} ${protocolVersion4x4to4x2} ${protocolVersion4x1} ${protocolVersion3}` + `${boltMagicPreamble} ${protocolVersion5x7to5x0} ${protocolVersion4x4to4x2} ${protocolVersion4x1} ${protocolVersion3}` ) }) diff --git a/packages/bolt-connection/test/bolt/response-handler.test.js b/packages/bolt-connection/test/bolt/response-handler.test.js index 3c75c1b98..196ea65fb 100644 --- a/packages/bolt-connection/test/bolt/response-handler.test.js +++ b/packages/bolt-connection/test/bolt/response-handler.test.js @@ -16,8 +16,10 @@ */ import ResponseHandler from '../../src/bolt/response-handler' +import BoltProtocolV1 from '../../src/bolt/bolt-protocol-v1' import { internal, newError } from 'neo4j-driver-core' +/* eslint-disable camelcase */ const { logger: { Logger } } = internal @@ -69,8 +71,85 @@ describe('response-handler', () => { expect(receivedError.message).toBe(expectedError.message) expect(receivedError.code).toBe(expectedError.code) }) + + it('should correctly handle errors with gql data', () => { + const errorPayload = { + message: 'older message', + code: 'Neo.ClientError.Test.Kaboom', + gql_status: '13N37', + description: 'I made this error up, for fun and profit!', + diagnostic_record: { OPERATION: '', OPERATION_CODE: '0', CURRENT_SCHEMA: '/', _classification: 'CLIENT_ERROR' } + } + const observer = { + capturedErrors: [], + onFailure: error => observer.capturedErrors.push(error) + } + const responseHandler = new ResponseHandler({ observer, log: Logger.noOp() }) + responseHandler._queueObserver({}) + + const errorMessage = { + signature: FAILURE, + fields: [errorPayload] + } + responseHandler.handleResponse(errorMessage) + + expect(observer.capturedErrors.length).toBe(1) + const [receivedError] = observer.capturedErrors + expect(receivedError.code).toBe(errorPayload.code) + expect(receivedError.message).toBe(errorPayload.message) + expect(receivedError.gqlStatus).toBe(errorPayload.gql_status) + expect(receivedError.gqlStatusDescription).toBe(errorPayload.description) + expect(receivedError.classification).toBe(errorPayload.diagnostic_record._classification) + }) + + it('should correctly handle errors with gql data and nested causes', () => { + const errorPayload = { + message: 'old message', + code: 'Neo.ClientError.Test.Error', + gql_status: '13N37', + description: 'I made this error up, for fun and profit!', + diagnostic_record: { OPERATION_CODE: '0', CURRENT_SCHEMA: '/', _classification: 'CLIENT_ERROR', additional_thing: 5268 }, + cause: { + message: 'old cause message', + gql_status: '13N38', + description: 'I made this error up, for fun and profit and reasons!', + diagnostic_record: { OPERATION: '', OPERATION_CODE: '2', CURRENT_SCHEMA: '/', _classification: 'DATABASE_ERROR', additional_thing: false } + } + } + const observer = { + capturedErrors: [], + onFailure: error => observer.capturedErrors.push(error) + } + const enrichErrorMetadata = new BoltProtocolV1().enrichErrorMetadata + const responseHandler = new ResponseHandler({ observer, enrichErrorMetadata, log: Logger.noOp() }) + responseHandler._queueObserver({}) + + const errorMessage = { + signature: FAILURE, + fields: [errorPayload] + } + responseHandler.handleResponse(errorMessage) + + expect(observer.capturedErrors.length).toBe(1) + const [receivedError] = observer.capturedErrors + expect(receivedError.code).toBe(errorPayload.code) + expect(receivedError.message).toBe(errorPayload.message) + expect(receivedError.gqlStatus).toBe(errorPayload.gql_status) + expect(receivedError.gqlStatusDescription).toBe(errorPayload.description) + testDiagnosticRecord(receivedError.diagnosticRecord, { ...errorPayload.diagnostic_record, OPERATION: '' }) + testDiagnosticRecord(receivedError.cause.diagnosticRecord, errorPayload.cause.diagnostic_record) + expect(receivedError.classification).toBe(errorPayload.diagnostic_record._classification) + expect(receivedError.cause.classification).toBe(errorPayload.cause.diagnostic_record._classification) + }) }) + function testDiagnosticRecord (diagnostic_record, expected_diagnostic_record) { + expect(diagnostic_record.OPERATION).toBe(expected_diagnostic_record.OPERATION) + expect(diagnostic_record.CURRENT_SCHEMA).toBe(expected_diagnostic_record.CURRENT_SCHEMA) + expect(diagnostic_record.OPERATION_CODE).toBe(expected_diagnostic_record.OPERATION_CODE) + expect(diagnostic_record.additional_thing).toBe(expected_diagnostic_record.additional_thing) + } + it('should keep track of observers and notify onObserversCountChange()', () => { const observer = { onObserversCountChange: jest.fn() diff --git a/packages/bolt-connection/test/connection/connection-channel.test.js b/packages/bolt-connection/test/connection/connection-channel.test.js index 95edaa5bb..d14630fab 100644 --- a/packages/bolt-connection/test/connection/connection-channel.test.js +++ b/packages/bolt-connection/test/connection/connection-channel.test.js @@ -371,7 +371,7 @@ describe('ChannelConnection', () => { expect(loggerFunction).toHaveBeenCalledWith( 'error', `${connection} experienced a fatal error caused by Neo4jError: some error ` + - '({"code":"C","name":"Neo4jError","retriable":false})' + '({"gqlStatus":"50N42","gqlStatusDescription":"error: general processing exception - unexpected error. some error","diagnosticRecord":{"OPERATION":"","OPERATION_CODE":"0","CURRENT_SCHEMA":"/"},"classification":"UNKNOWN","name":"Neo4jError","code":"C","retriable":false})' ) }) }) @@ -419,7 +419,7 @@ describe('ChannelConnection', () => { expect(loggerFunction).toHaveBeenCalledWith( 'error', `${connection} experienced a fatal error caused by Neo4jError: current failure ` + - '({"code":"ongoing","name":"Neo4jError","retriable":false})' + '({"gqlStatus":"50N42","gqlStatusDescription":"error: general processing exception - unexpected error. current failure","diagnosticRecord":{"OPERATION":"","OPERATION_CODE":"0","CURRENT_SCHEMA":"/"},"classification":"UNKNOWN","name":"Neo4jError","code":"ongoing","retriable":false})' ) }) }) diff --git a/packages/core/src/error.ts b/packages/core/src/error.ts index d391e7fc2..40386ddf8 100644 --- a/packages/core/src/error.ts +++ b/packages/core/src/error.ts @@ -18,6 +18,25 @@ // A common place for constructing error objects, to keep them // uniform across the driver surface. +import * as json from './json' +import { DiagnosticRecord, rawPolyfilledDiagnosticRecord } from './gql-constants' + +export type ErrorClassification = 'DATABASE_ERROR' | 'CLIENT_ERROR' | 'TRANSIENT_ERROR' | 'UNKNOWN' +/** + * @typedef { 'DATABASE_ERROR' | 'CLIENT_ERROR' | 'TRANSIENT_ERROR' | 'UNKNOWN' } ErrorClassification + * @experimental this is part of the preview of GQL-compliant errors + */ + +const errorClassification: { [key in ErrorClassification]: key } = { + DATABASE_ERROR: 'DATABASE_ERROR', + CLIENT_ERROR: 'CLIENT_ERROR', + TRANSIENT_ERROR: 'TRANSIENT_ERROR', + UNKNOWN: 'UNKNOWN' +} + +Object.freeze(errorClassification) +const classifications = Object.values(errorClassification) + /** * Error code representing complete loss of service. Used by {@link Neo4jError#code}. * @type {string} @@ -52,35 +71,131 @@ type Neo4jErrorCode = | typeof NOT_AVAILABLE /// TODO: Remove definitions of this.constructor and this.__proto__ + +/** + * Class for nested errors, to be used as causes in {@link Neo4jError} + * @experimental this class is part of the preview of GQL-compliant errors + */ +class GQLError extends Error { + gqlStatus: string + gqlStatusDescription: string + diagnosticRecord: DiagnosticRecord | undefined + classification: ErrorClassification + rawClassification?: string + cause?: Error + __proto__: GQLError + + /** + * @constructor + * @param {string} message - the error message + * @param {string} gqlStatus - the GQL status code of the error + * @param {string} gqlStatusDescription - the GQL status description of the error + * @param {ErrorDiagnosticRecord} diagnosticRecord - the error diagnostic record + * @param {Error} cause - Optional nested error, the cause of the error + */ + constructor (message: string, gqlStatus: string, gqlStatusDescription: string, diagnosticRecord?: DiagnosticRecord, cause?: Error) { + // eslint-disable-next-line + // @ts-ignore: not available in ES6 yet + super(message, cause != null ? { cause } : undefined) + this.constructor = GQLError + // eslint-disable-next-line no-proto + this.__proto__ = GQLError.prototype + /** + * Optional, nested error which caused the error + * + * @type {Error?} + * @public + */ + this.cause = cause != null ? cause : undefined + /** + * The GQL Status code + * + * @type {string} + * @experimental this property is part of the preview of GQL-compliant errors + * @public + */ + this.gqlStatus = gqlStatus + /** + * The GQL Status Description + * + * @type {string} + * @experimental this property is part of the preview of GQL-compliant errors + * @public + */ + this.gqlStatusDescription = gqlStatusDescription + /** + * The GQL diagnostic record + * + * @type {DiagnosticRecord} + * @experimental this property is part of the preview of GQL-compliant errors + * @public + */ + this.diagnosticRecord = diagnosticRecord + /** + * The GQL error classification, extracted from the diagnostic record + * + * @type {ErrorClassification} + * @experimental this property is part of the preview of GQL-compliant errors + * @public + */ + this.classification = _extractClassification(this.diagnosticRecord) + /** + * The GQL error classification, extracted from the diagnostic record as a raw string + * + * @type {string} + * @experimental this property is part of the preview of GQL-compliant errors + * @public + */ + this.rawClassification = diagnosticRecord?._classification ?? undefined + this.name = 'GQLError' + } + + /** + * The json string representation of the diagnostic record. + * The goal of this method is provide a serialized object for human inspection. + * + * @type {string} + * @experimental this is part of the preview of GQL-compliant errors + * @public + */ + public get diagnosticRecordAsJsonString (): string { + return json.stringify(this.diagnosticRecord, { useCustomToString: true }) + } +} + /** * Class for all errors thrown/returned by the driver. */ -class Neo4jError extends Error { +class Neo4jError extends GQLError { /** * Optional error code. Will be populated when error originates in the database. */ - code: Neo4jErrorCode + code: string retriable: boolean - __proto__: Neo4jError /** * @constructor * @param {string} message - the error message * @param {string} code - Optional error code. Will be populated when error originates in the database. + * @param {string} gqlStatus - the GQL status code of the error + * @param {string} gqlStatusDescription - the GQL status description of the error + * @param {DiagnosticRecord} diagnosticRecord - the error diagnostic record + * @param {Error} cause - Optional nested error, the cause of the error */ - constructor (message: string, code: Neo4jErrorCode, cause?: Error) { - // eslint-disable-next-line - // @ts-ignore: not available in ES6 yet - super(message, cause != null ? { cause } : undefined) + constructor (message: string, code: Neo4jErrorCode, gqlStatus: string, gqlStatusDescription: string, diagnosticRecord?: DiagnosticRecord, cause?: Error) { + super(message, gqlStatus, gqlStatusDescription, diagnosticRecord, cause) this.constructor = Neo4jError // eslint-disable-next-line no-proto this.__proto__ = Neo4jError.prototype - this.code = code - this.name = 'Neo4jError' /** - * Indicates if the error is retriable. - * @type {boolean} - true if the error is retriable + * The Neo4j Error code + * + * @type {string} + * @public */ + this.code = code + + this.name = 'Neo4jError' this.retriable = _isRetriableCode(code) } @@ -99,14 +214,33 @@ class Neo4jError extends Error { } /** - * Create a new error from a message and error code + * Create a new error from a message and optional data + * @param message the error message + * @param {Neo4jErrorCode} [code] the error code + * @param {Neo4jError} [cause] + * @param {String} [gqlStatus] + * @param {String} [gqlStatusDescription] + * @param {DiagnosticRecord} diagnosticRecord - the error message + * @return {Neo4jError} an {@link Neo4jError} + * @private + */ +function newError (message: string, code?: Neo4jErrorCode, cause?: Error, gqlStatus?: string, gqlStatusDescription?: string, diagnosticRecord?: DiagnosticRecord): Neo4jError { + return new Neo4jError(message, code ?? NOT_AVAILABLE, gqlStatus ?? '50N42', gqlStatusDescription ?? 'error: general processing exception - unexpected error. ' + message, diagnosticRecord ?? rawPolyfilledDiagnosticRecord, cause) +} + +/** + * Create a new GQL error from a message and optional data * @param message the error message - * @param code the error code + * @param {Neo4jError} [cause] + * @param {String} [gqlStatus] + * @param {String} [gqlStatusDescription] + * @param {DiagnosticRecord} diagnosticRecord - the error message * @return {Neo4jError} an {@link Neo4jError} + * @experimental this is part of the preview of GQL-compliant errors * @private */ -function newError (message: string, code?: Neo4jErrorCode, cause?: Error): Neo4jError { - return new Neo4jError(message, code ?? NOT_AVAILABLE, cause) +function newGQLError (message: string, cause?: Error, gqlStatus?: string, gqlStatusDescription?: string, diagnosticRecord?: DiagnosticRecord): GQLError { + return new GQLError(message, gqlStatus ?? '50N42', gqlStatusDescription ?? 'error: general processing exception - unexpected error. ' + message, diagnosticRecord ?? rawPolyfilledDiagnosticRecord, cause) } /** @@ -148,10 +282,22 @@ function _isAuthorizationExpired (code?: Neo4jErrorCode): boolean { return code === 'Neo.ClientError.Security.AuthorizationExpired' } +/** + * extracts a typed classification from the diagnostic record. + */ +function _extractClassification (diagnosticRecord?: any): ErrorClassification { + if (diagnosticRecord === undefined || diagnosticRecord._classification === undefined) { + return 'UNKNOWN' + } + return classifications.includes(diagnosticRecord._classification) ? diagnosticRecord?._classification : 'UNKNOWN' +} + export { newError, + newGQLError, isRetriableError, Neo4jError, + GQLError, SERVICE_UNAVAILABLE, SESSION_EXPIRED, PROTOCOL_ERROR diff --git a/packages/core/src/gql-constants.ts b/packages/core/src/gql-constants.ts new file mode 100644 index 000000000..c12e562bb --- /dev/null +++ b/packages/core/src/gql-constants.ts @@ -0,0 +1,27 @@ +import { NumberOrInteger } from './graph-types' + +/** + * Class for the DiagnosticRecord in a {@link Neo4jError}, including commonly used fields. + */ +export interface DiagnosticRecord { + OPERATION: string + OPERATION_CODE: string + CURRENT_SCHEMA: string + _severity?: string + _classification?: string + _position?: { + offset: NumberOrInteger + line: NumberOrInteger + column: NumberOrInteger + } + _status_parameters?: Record + [key: string]: unknown +} + +export const rawPolyfilledDiagnosticRecord = { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/' +} + +Object.freeze(rawPolyfilledDiagnosticRecord) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f9604cd87..5c3871b9e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -18,6 +18,8 @@ import { newError, Neo4jError, + newGQLError, + GQLError, isRetriableError, PROTOCOL_ERROR, SERVICE_UNAVAILABLE, @@ -116,6 +118,8 @@ const forExport = { authTokenManagers, newError, Neo4jError, + newGQLError, + GQLError, isRetriableError, error, Integer, @@ -189,6 +193,8 @@ export { authTokenManagers, newError, Neo4jError, + newGQLError, + GQLError, isRetriableError, error, Integer, diff --git a/packages/core/src/internal/constants.ts b/packages/core/src/internal/constants.ts index 09ccb7602..ed32dcef5 100644 --- a/packages/core/src/internal/constants.ts +++ b/packages/core/src/internal/constants.ts @@ -38,6 +38,7 @@ const BOLT_PROTOCOL_V5_3: number = 5.3 const BOLT_PROTOCOL_V5_4: number = 5.4 const BOLT_PROTOCOL_V5_5: number = 5.5 const BOLT_PROTOCOL_V5_6: number = 5.6 +const BOLT_PROTOCOL_V5_7: number = 5.7 const TELEMETRY_APIS = { MANAGED_TRANSACTION: 0, @@ -72,5 +73,6 @@ export { BOLT_PROTOCOL_V5_4, BOLT_PROTOCOL_V5_5, BOLT_PROTOCOL_V5_6, + BOLT_PROTOCOL_V5_7, TELEMETRY_APIS } diff --git a/packages/core/src/notification.ts b/packages/core/src/notification.ts index 695373cd7..4104293fa 100644 --- a/packages/core/src/notification.ts +++ b/packages/core/src/notification.ts @@ -16,7 +16,7 @@ */ import * as json from './json' import { util } from './internal' -import { NumberOrInteger } from './graph-types' +import { DiagnosticRecord, rawPolyfilledDiagnosticRecord } from './gql-constants' interface NotificationPosition { offset?: number @@ -41,7 +41,7 @@ const unknownGqlStatus: Record - [key: string]: unknown -} - /** * Representation for GqlStatusObject found when executing a query. *

@@ -251,7 +236,7 @@ interface NotificationDiagnosticRecord { class GqlStatusObject { public readonly gqlStatus: string public readonly statusDescription: string - public readonly diagnosticRecord: NotificationDiagnosticRecord + public readonly diagnosticRecord: DiagnosticRecord public readonly position?: NotificationPosition public readonly severity: NotificationSeverityLevel public readonly rawSeverity?: string @@ -420,7 +405,7 @@ function polyfillNotification (status: any): Notification | undefined { */ function polyfillGqlStatusObject (notification: any): GqlStatusObject { const defaultStatus = notification.severity === notificationSeverityLevel.WARNING ? unknownGqlStatus.WARNING : unknownGqlStatus.INFORMATION - const polyfilledRawObj: any & { diagnostic_record: NotificationDiagnosticRecord } = { + const polyfilledRawObj: any & { diagnostic_record: DiagnosticRecord } = { gql_status: defaultStatus.gql_status, status_description: notification.description ?? defaultStatus.status_description, neo4j_code: notification.code, @@ -445,14 +430,6 @@ function polyfillGqlStatusObject (notification: any): GqlStatusObject { return new GqlStatusObject(polyfilledRawObj) } -const rawPolyfilledDiagnosticRecord = { - OPERATION: '', - OPERATION_CODE: '0', - CURRENT_SCHEMA: '/' -} - -Object.freeze(rawPolyfilledDiagnosticRecord) - /** * This objects are used for polyfilling the first status on the status list * @@ -598,6 +575,5 @@ export type { NotificationPosition, NotificationSeverityLevel, NotificationCategory, - NotificationClassification, - NotificationDiagnosticRecord + NotificationClassification } diff --git a/packages/core/test/__snapshots__/json.test.ts.snap b/packages/core/test/__snapshots__/json.test.ts.snap index 14abb06b7..195fb37ba 100644 --- a/packages/core/test/__snapshots__/json.test.ts.snap +++ b/packages/core/test/__snapshots__/json.test.ts.snap @@ -102,11 +102,11 @@ exports[`json .stringify should handle object with custom toString in list 1`] = exports[`json .stringify should handle object with custom toString in object 1`] = `"{"key":{"identity":"1"}}"`; -exports[`json .stringify should handle objects created with createBrokenObject 1`] = `"{"__isBrokenObject__":true,"__reason__":{"code":"N/A","name":"Neo4jError","retriable":false}}"`; +exports[`json .stringify should handle objects created with createBrokenObject 1`] = `"{"__isBrokenObject__":true,"__reason__":{"gqlStatus":"50N42","gqlStatusDescription":"error: general processing exception - unexpected error. some error","diagnosticRecord":{"OPERATION":"","OPERATION_CODE":"0","CURRENT_SCHEMA":"/"},"classification":"UNKNOWN","name":"Neo4jError","code":"N/A","retriable":false}}"`; -exports[`json .stringify should handle objects created with createBrokenObject in list 1`] = `"[{"__isBrokenObject__":true,"__reason__":{"code":"N/A","name":"Neo4jError","retriable":false}}]"`; +exports[`json .stringify should handle objects created with createBrokenObject in list 1`] = `"[{"__isBrokenObject__":true,"__reason__":{"gqlStatus":"50N42","gqlStatusDescription":"error: general processing exception - unexpected error. some error","diagnosticRecord":{"OPERATION":"","OPERATION_CODE":"0","CURRENT_SCHEMA":"/"},"classification":"UNKNOWN","name":"Neo4jError","code":"N/A","retriable":false}}]"`; -exports[`json .stringify should handle objects created with createBrokenObject inside other object 1`] = `"{"number":1,"broken":{"__isBrokenObject__":true,"__reason__":{"code":"N/A","name":"Neo4jError","retriable":false}}}"`; +exports[`json .stringify should handle objects created with createBrokenObject inside other object 1`] = `"{"number":1,"broken":{"__isBrokenObject__":true,"__reason__":{"gqlStatus":"50N42","gqlStatusDescription":"error: general processing exception - unexpected error. some error","diagnosticRecord":{"OPERATION":"","OPERATION_CODE":"0","CURRENT_SCHEMA":"/"},"classification":"UNKNOWN","name":"Neo4jError","code":"N/A","retriable":false}}}"`; exports[`json .stringify should handle string 1`] = `""my string""`; diff --git a/packages/core/test/error.test.ts b/packages/core/test/error.test.ts index 3b628d8a5..8f06745a6 100644 --- a/packages/core/test/error.test.ts +++ b/packages/core/test/error.test.ts @@ -18,6 +18,7 @@ import { Neo4jError, isRetriableError, newError, + newGQLError, PROTOCOL_ERROR, SERVICE_UNAVAILABLE, SESSION_EXPIRED @@ -43,37 +44,83 @@ describe('newError', () => { } ) - test('should create Neo4jErro without code should be created with "N/A" error', () => { + test('should create Neo4jError without code with "N/A" error', () => { const error: Neo4jError = newError('some error') expect(error.message).toEqual('some error') expect(error.code).toEqual('N/A') }) - test('should create Neo4jErro with cause', () => { - const cause = new Error('cause') - const error: Neo4jError = newError('some error', undefined, cause) + test('should create Neo4jError without status description with default description', () => { + const error: Neo4jError = newError('some error') + + expect(error.gqlStatusDescription).toEqual('error: general processing exception - unexpected error. some error') + expect(error.code).toEqual('N/A') + }) + + test('should create Neo4jError without gql status with default status', () => { + const error: Neo4jError = newError('some error') + + expect(error.gqlStatus).toEqual('50N42') + expect(error.code).toEqual('N/A') + }) + + test('should create Neo4jError with cause', () => { + const cause = newGQLError('cause') + const error: Neo4jError = newError('some error', undefined, cause, 'some status', 'some description', undefined) expect(error.message).toEqual('some error') expect(error.code).toEqual('N/A') if (supportsCause) { - // @ts-expect-error expect(error.cause).toBe(cause) + // @ts-expect-error + expect(error.cause.classification).toBe('UNKNOWN') } else { + expect(error.cause).toBeUndefined() + } + }) + + test('should create Neo4jError with nested cause', () => { + const cause = newGQLError('cause', newGQLError('nested'), undefined, undefined, undefined) + const error: Neo4jError = newError('some error', undefined, cause, 'some status', 'some description', undefined) + + expect(error.message).toEqual('some error') + expect(error.code).toEqual('N/A') + if (supportsCause) { + expect(error.cause).toBe(cause) + // @ts-expect-error + expect(error.cause.classification).toBe('UNKNOWN') // @ts-expect-error + expect(error.cause.cause.classification).toBe('UNKNOWN') + } else { expect(error.cause).toBeUndefined() } }) test.each([null, undefined])('should create Neo4jError without cause (%s)', (cause) => { // @ts-expect-error - const error: Neo4jError = newError('some error', undefined, cause) + const error: Neo4jError = newError('some error', undefined, cause, undefined, undefined, undefined) expect(error.message).toEqual('some error') expect(error.code).toEqual('N/A') - // @ts-expect-error expect(error.cause).toBeUndefined() }) + + test('should create Neo4jError without diagnosticRecord with UNKNOWN classification', () => { + const error: Neo4jError = newError('some error') + + expect(error.classification).toEqual('UNKNOWN') + }) + + test.each([ + 'TRANSIENT_ERROR', + 'CLIENT_ERROR', + 'DATABASE_ERROR' + ])('should create Neo4jError with diagnosticRecord with classification (%s)', (classification) => { + const error: Neo4jError = newError('some error', undefined, undefined, undefined, undefined, { OPERATION: '', OPERATION_CODE: '0', CURRENT_SCHEMA: '/', _classification: classification }) + + expect(error.classification).toEqual(classification) + }) }) describe('isRetriableError()', () => { @@ -88,31 +135,43 @@ describe('isRetriableError()', () => { describe('Neo4jError', () => { test('should have message', () => { - const error = new Neo4jError('message', 'code') + const error = new Neo4jError('message', 'code', 'gqlStatus', 'gqlStatusDescription') expect(error.message).toEqual('message') }) test('should have code', () => { - const error = new Neo4jError('message', 'code') + const error = new Neo4jError('message', 'code', 'gqlStatus', 'gqlStatusDescription') expect(error.code).toEqual('code') }) + test('should have gqlStatus', () => { + const error = new Neo4jError('message', 'code', 'gqlStatus', 'gqlStatusDescription') + + expect(error.gqlStatus).toEqual('gqlStatus') + }) + + test('should have gqlStatusDescription', () => { + const error = new Neo4jError('message', 'code', 'gqlStatus', 'gqlStatusDescription') + + expect(error.gqlStatusDescription).toEqual('gqlStatusDescription') + }) + test('should have name equal to Neo4jError', () => { - const error = new Neo4jError('message', 'code') + const error = new Neo4jError('message', 'code', 'gqlStatus', 'gqlStatusDescription') expect(error.name).toEqual('Neo4jError') }) test('should define stackstrace', () => { - const error = new Neo4jError('message', 'code') + const error = new Neo4jError('message', 'code', 'gqlStatus', 'gqlStatusDescription') expect(error.stack).toBeDefined() }) test('should define __proto__ and constructor to backwards compatility with ES6', () => { - const error = new Neo4jError('message', 'code') + const error = new Neo4jError('message', 'code', 'gqlStatus', 'gqlStatusDescription') // eslint-disable-next-line no-proto expect(error.__proto__).toEqual(Neo4jError.prototype) @@ -120,13 +179,13 @@ describe('Neo4jError', () => { }) test.each(getRetriableCodes())('should define retriable as true for error with code %s', code => { - const error = new Neo4jError('message', code) + const error = new Neo4jError('message', code, 'gqlStatus', 'gqlStatusDescription') expect(error.retriable).toBe(true) }) test.each(getNonRetriableCodes())('should define retriable as false for error with code %s', code => { - const error = new Neo4jError('message', code) + const error = new Neo4jError('message', code, 'gqlStatus', 'gqlStatusDescription') expect(error.retriable).toBe(false) }) diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v1.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v1.js index 17cd57d04..a83f32f5a 100644 --- a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v1.js +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v1.js @@ -45,6 +45,12 @@ const { txConfig: { TxConfig } } = internal +const DEFAULT_DIAGNOSTIC_RECORD = Object.freeze({ + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/' +}) + export default class BoltProtocol { /** * @callback CreateResponseHandler Creates the response handler @@ -164,6 +170,13 @@ export default class BoltProtocol { return metadata } + enrichErrorMetadata (metadata) { + return { + ...metadata, + diagnostic_record: metadata.diagnostic_record !== null ? { ...DEFAULT_DIAGNOSTIC_RECORD, ...metadata.diagnostic_record } : null + } + } + /** * Perform initialization and authentication of the underlying connection. * @param {Object} param diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v5x7.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v5x7.js new file mode 100644 index 000000000..9f417ab50 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v5x7.js @@ -0,0 +1,59 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import BoltProtocolV5x6 from './bolt-protocol-v5x6.js' + +import transformersFactories from './bolt-protocol-v5x5.transformer.js' +import Transformer from './transformer.js' + +import { internal } from '../../core/index.ts' + +const { + constants: { BOLT_PROTOCOL_V5_7 } +} = internal + +const DEFAULT_DIAGNOSTIC_RECORD = Object.freeze({ + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/' +}) + +export default class BoltProtocol extends BoltProtocolV5x6 { + get version () { + return BOLT_PROTOCOL_V5_7 + } + + get transformer () { + if (this._transformer === undefined) { + this._transformer = new Transformer(Object.values(transformersFactories).map(create => create(this._config, this._log))) + } + return this._transformer + } + + /** + * + * @param {object} metadata + * @returns {object} + */ + enrichErrorMetadata (metadata) { + return { + ...metadata, + cause: (metadata.cause !== null && metadata.cause !== undefined) ? this.enrichErrorMetadata(metadata.cause) : null, + code: metadata.neo4j_code, + diagnostic_record: metadata.diagnostic_record !== null ? { ...DEFAULT_DIAGNOSTIC_RECORD, ...metadata.diagnostic_record } : null + } + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v5x7.transformer.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v5x7.transformer.js new file mode 100644 index 000000000..ff74e0120 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v5x7.transformer.js @@ -0,0 +1,22 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import v5x6 from './bolt-protocol-v5x6.transformer.js' + +export default { + ...v5x6 +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/create.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/create.js index 54bfef412..7a0fe2165 100644 --- a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/create.js +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/create.js @@ -31,6 +31,7 @@ import BoltProtocolV5x3 from './bolt-protocol-v5x3.js' import BoltProtocolV5x4 from './bolt-protocol-v5x4.js' import BoltProtocolV5x5 from './bolt-protocol-v5x5.js' import BoltProtocolV5x6 from './bolt-protocol-v5x6.js' +import BoltProtocolV5x7 from './bolt-protocol-v5x7.js' // eslint-disable-next-line no-unused-vars import { Chunker, Dechunker } from '../channel/index.js' import ResponseHandler from './response-handler.js' @@ -64,6 +65,7 @@ export default function create ({ const createResponseHandler = protocol => { const responseHandler = new ResponseHandler({ transformMetadata: protocol.transformMetadata.bind(protocol), + enrichErrorMetadata: protocol.enrichErrorMetadata.bind(protocol), log, observer }) @@ -247,6 +249,14 @@ function createProtocol ( log, onProtocolError, serversideRouting) + case 5.7: + return new BoltProtocolV5x7(server, + chunker, + packingConfig, + createResponseHandler, + log, + onProtocolError, + serversideRouting) default: throw newError('Unknown Bolt protocol version: ' + version) } diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/handshake.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/handshake.js index 38354274f..c91e0e18f 100644 --- a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/handshake.js +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/handshake.js @@ -76,7 +76,7 @@ function parseNegotiatedResponse (buffer, log) { */ function newHandshakeBuffer () { return createHandshakeMessage([ - [version(5, 6), version(5, 0)], + [version(5, 7), version(5, 0)], [version(4, 4), version(4, 2)], version(4, 1), version(3, 0) diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/response-handler.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/response-handler.js index 8a0aeddbf..93839c2c3 100644 --- a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/response-handler.js +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/response-handler.js @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { newError, json } from '../../core/index.ts' +import { newError, newGQLError, json } from '../../core/index.ts' // Signature bytes for each response message type const SUCCESS = 0x70 // 0111 0000 // SUCCESS @@ -70,10 +70,11 @@ export default class ResponseHandler { * @param {Logger} log The logger * @param {ResponseHandler~Observer} observer Object which will be notified about errors */ - constructor ({ transformMetadata, log, observer } = {}) { + constructor ({ transformMetadata, enrichErrorMetadata, log, observer } = {}) { this._pendingObservers = [] this._log = log this._transformMetadata = transformMetadata || NO_OP_IDENTITY + this._enrichErrorMetadata = enrichErrorMetadata || NO_OP_IDENTITY this._observer = Object.assign( { onObserversCountChange: NO_OP, @@ -115,11 +116,7 @@ export default class ResponseHandler { this._log.debug(`S: FAILURE ${json.stringify(msg)}`) } try { - const standardizedCode = _standardizeCode(payload.code) - const error = newError(payload.message, standardizedCode) - this._currentFailure = this._observer.onErrorApplyTransformation( - error - ) + this._currentFailure = this._handleErrorPayload(this._enrichErrorMetadata(payload)) this._currentObserver.onError(this._currentFailure) } finally { this._updateCurrentObserver() @@ -196,6 +193,23 @@ export default class ResponseHandler { _resetFailure () { this._currentFailure = null } + + _handleErrorPayload (payload) { + const standardizedCode = _standardizeCode(payload.code) + const cause = payload.cause != null ? this._handleErrorCause(payload.cause) : undefined + const error = newError(payload.message, standardizedCode, cause, payload.gql_status, payload.description, payload.diagnostic_record) + return this._observer.onErrorApplyTransformation( + error + ) + } + + _handleErrorCause (payload) { + const cause = payload.cause != null ? this._handleErrorCause(payload.cause) : undefined + const error = newGQLError(payload.message, cause, payload.gql_status, payload.description, payload.diagnostic_record) + return this._observer.onErrorApplyTransformation( + error + ) + } } /** diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/connection/connection-channel.js b/packages/neo4j-driver-deno/lib/bolt-connection/connection/connection-channel.js index 12ad7fc86..0da4592db 100644 --- a/packages/neo4j-driver-deno/lib/bolt-connection/connection/connection-channel.js +++ b/packages/neo4j-driver-deno/lib/bolt-connection/connection/connection-channel.js @@ -441,7 +441,7 @@ export default class ChannelConnection extends Connection { reject(error) } else { const neo4jError = this._handleProtocolError( - 'Received FAILURE as a response for RESET: ' + error + `Received FAILURE as a response for RESET: ${error}` ) reject(neo4jError) } diff --git a/packages/neo4j-driver-deno/lib/core/error.ts b/packages/neo4j-driver-deno/lib/core/error.ts index d391e7fc2..b00bb92dc 100644 --- a/packages/neo4j-driver-deno/lib/core/error.ts +++ b/packages/neo4j-driver-deno/lib/core/error.ts @@ -18,6 +18,25 @@ // A common place for constructing error objects, to keep them // uniform across the driver surface. +import * as json from './json.ts' +import { DiagnosticRecord, rawPolyfilledDiagnosticRecord } from './gql-constants.ts' + +export type ErrorClassification = 'DATABASE_ERROR' | 'CLIENT_ERROR' | 'TRANSIENT_ERROR' | 'UNKNOWN' +/** + * @typedef { 'DATABASE_ERROR' | 'CLIENT_ERROR' | 'TRANSIENT_ERROR' | 'UNKNOWN' } ErrorClassification + * @experimental this is part of the preview of GQL-compliant errors + */ + +const errorClassification: { [key in ErrorClassification]: key } = { + DATABASE_ERROR: 'DATABASE_ERROR', + CLIENT_ERROR: 'CLIENT_ERROR', + TRANSIENT_ERROR: 'TRANSIENT_ERROR', + UNKNOWN: 'UNKNOWN' +} + +Object.freeze(errorClassification) +const classifications = Object.values(errorClassification) + /** * Error code representing complete loss of service. Used by {@link Neo4jError#code}. * @type {string} @@ -52,35 +71,131 @@ type Neo4jErrorCode = | typeof NOT_AVAILABLE /// TODO: Remove definitions of this.constructor and this.__proto__ + +/** + * Class for nested errors, to be used as causes in {@link Neo4jError} + * @experimental this class is part of the preview of GQL-compliant errors + */ +class GQLError extends Error { + gqlStatus: string + gqlStatusDescription: string + diagnosticRecord: DiagnosticRecord | undefined + classification: ErrorClassification + rawClassification?: string + cause?: Error + __proto__: GQLError + + /** + * @constructor + * @param {string} message - the error message + * @param {string} gqlStatus - the GQL status code of the error + * @param {string} gqlStatusDescription - the GQL status description of the error + * @param {ErrorDiagnosticRecord} diagnosticRecord - the error diagnostic record + * @param {Error} cause - Optional nested error, the cause of the error + */ + constructor (message: string, gqlStatus: string, gqlStatusDescription: string, diagnosticRecord?: DiagnosticRecord, cause?: Error) { + // eslint-disable-next-line + // @ts-ignore: not available in ES6 yet + super(message, cause != null ? { cause } : undefined) + this.constructor = GQLError + // eslint-disable-next-line no-proto + this.__proto__ = GQLError.prototype + /** + * Optional, nested error which caused the error + * + * @type {Error?} + * @public + */ + this.cause = cause != null ? cause : undefined + /** + * The GQL Status code + * + * @type {string} + * @experimental this property is part of the preview of GQL-compliant errors + * @public + */ + this.gqlStatus = gqlStatus + /** + * The GQL Status Description + * + * @type {string} + * @experimental this property is part of the preview of GQL-compliant errors + * @public + */ + this.gqlStatusDescription = gqlStatusDescription + /** + * The GQL diagnostic record + * + * @type {DiagnosticRecord} + * @experimental this property is part of the preview of GQL-compliant errors + * @public + */ + this.diagnosticRecord = diagnosticRecord + /** + * The GQL error classification, extracted from the diagnostic record + * + * @type {ErrorClassification} + * @experimental this property is part of the preview of GQL-compliant errors + * @public + */ + this.classification = _extractClassification(this.diagnosticRecord) + /** + * The GQL error classification, extracted from the diagnostic record as a raw string + * + * @type {string} + * @experimental this property is part of the preview of GQL-compliant errors + * @public + */ + this.rawClassification = diagnosticRecord?._classification ?? undefined + this.name = 'GQLError' + } + + /** + * The json string representation of the diagnostic record. + * The goal of this method is provide a serialized object for human inspection. + * + * @type {string} + * @experimental this is part of the preview of GQL-compliant errors + * @public + */ + public get diagnosticRecordAsJsonString (): string { + return json.stringify(this.diagnosticRecord, { useCustomToString: true }) + } +} + /** * Class for all errors thrown/returned by the driver. */ -class Neo4jError extends Error { +class Neo4jError extends GQLError { /** * Optional error code. Will be populated when error originates in the database. */ - code: Neo4jErrorCode + code: string retriable: boolean - __proto__: Neo4jError /** * @constructor * @param {string} message - the error message * @param {string} code - Optional error code. Will be populated when error originates in the database. + * @param {string} gqlStatus - the GQL status code of the error + * @param {string} gqlStatusDescription - the GQL status description of the error + * @param {DiagnosticRecord} diagnosticRecord - the error diagnostic record + * @param {Error} cause - Optional nested error, the cause of the error */ - constructor (message: string, code: Neo4jErrorCode, cause?: Error) { - // eslint-disable-next-line - // @ts-ignore: not available in ES6 yet - super(message, cause != null ? { cause } : undefined) + constructor (message: string, code: Neo4jErrorCode, gqlStatus: string, gqlStatusDescription: string, diagnosticRecord?: DiagnosticRecord, cause?: Error) { + super(message, gqlStatus, gqlStatusDescription, diagnosticRecord, cause) this.constructor = Neo4jError // eslint-disable-next-line no-proto this.__proto__ = Neo4jError.prototype - this.code = code - this.name = 'Neo4jError' /** - * Indicates if the error is retriable. - * @type {boolean} - true if the error is retriable + * The Neo4j Error code + * + * @type {string} + * @public */ + this.code = code + + this.name = 'Neo4jError' this.retriable = _isRetriableCode(code) } @@ -99,14 +214,33 @@ class Neo4jError extends Error { } /** - * Create a new error from a message and error code + * Create a new error from a message and optional data + * @param message the error message + * @param {Neo4jErrorCode} [code] the error code + * @param {Neo4jError} [cause] + * @param {String} [gqlStatus] + * @param {String} [gqlStatusDescription] + * @param {DiagnosticRecord} diagnosticRecord - the error message + * @return {Neo4jError} an {@link Neo4jError} + * @private + */ +function newError (message: string, code?: Neo4jErrorCode, cause?: Error, gqlStatus?: string, gqlStatusDescription?: string, diagnosticRecord?: DiagnosticRecord): Neo4jError { + return new Neo4jError(message, code ?? NOT_AVAILABLE, gqlStatus ?? '50N42', gqlStatusDescription ?? 'error: general processing exception - unexpected error. ' + message, diagnosticRecord ?? rawPolyfilledDiagnosticRecord, cause) +} + +/** + * Create a new GQL error from a message and optional data * @param message the error message - * @param code the error code + * @param {Neo4jError} [cause] + * @param {String} [gqlStatus] + * @param {String} [gqlStatusDescription] + * @param {DiagnosticRecord} diagnosticRecord - the error message * @return {Neo4jError} an {@link Neo4jError} + * @experimental this is part of the preview of GQL-compliant errors * @private */ -function newError (message: string, code?: Neo4jErrorCode, cause?: Error): Neo4jError { - return new Neo4jError(message, code ?? NOT_AVAILABLE, cause) +function newGQLError (message: string, cause?: Error, gqlStatus?: string, gqlStatusDescription?: string, diagnosticRecord?: DiagnosticRecord): GQLError { + return new GQLError(message, gqlStatus ?? '50N42', gqlStatusDescription ?? 'error: general processing exception - unexpected error. ' + message, diagnosticRecord ?? rawPolyfilledDiagnosticRecord, cause) } /** @@ -148,10 +282,22 @@ function _isAuthorizationExpired (code?: Neo4jErrorCode): boolean { return code === 'Neo.ClientError.Security.AuthorizationExpired' } +/** + * extracts a typed classification from the diagnostic record. + */ +function _extractClassification (diagnosticRecord?: any): ErrorClassification { + if (diagnosticRecord === undefined || diagnosticRecord._classification === undefined) { + return 'UNKNOWN' + } + return classifications.includes(diagnosticRecord._classification) ? diagnosticRecord?._classification : 'UNKNOWN' +} + export { newError, + newGQLError, isRetriableError, Neo4jError, + GQLError, SERVICE_UNAVAILABLE, SESSION_EXPIRED, PROTOCOL_ERROR diff --git a/packages/neo4j-driver-deno/lib/core/gql-constants.ts b/packages/neo4j-driver-deno/lib/core/gql-constants.ts new file mode 100644 index 000000000..ad61eb0bf --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/gql-constants.ts @@ -0,0 +1,27 @@ +import { NumberOrInteger } from './graph-types.ts' + +/** + * Class for the DiagnosticRecord in a {@link Neo4jError}, including commonly used fields. + */ +export interface DiagnosticRecord { + OPERATION: string + OPERATION_CODE: string + CURRENT_SCHEMA: string + _severity?: string + _classification?: string + _position?: { + offset: NumberOrInteger + line: NumberOrInteger + column: NumberOrInteger + } + _status_parameters?: Record + [key: string]: unknown +} + +export const rawPolyfilledDiagnosticRecord = { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/' +} + +Object.freeze(rawPolyfilledDiagnosticRecord) diff --git a/packages/neo4j-driver-deno/lib/core/index.ts b/packages/neo4j-driver-deno/lib/core/index.ts index 15813a6a2..1a0a7203f 100644 --- a/packages/neo4j-driver-deno/lib/core/index.ts +++ b/packages/neo4j-driver-deno/lib/core/index.ts @@ -18,6 +18,8 @@ import { newError, Neo4jError, + newGQLError, + GQLError, isRetriableError, PROTOCOL_ERROR, SERVICE_UNAVAILABLE, @@ -116,6 +118,8 @@ const forExport = { authTokenManagers, newError, Neo4jError, + newGQLError, + GQLError, isRetriableError, error, Integer, @@ -189,6 +193,8 @@ export { authTokenManagers, newError, Neo4jError, + newGQLError, + GQLError, isRetriableError, error, Integer, diff --git a/packages/neo4j-driver-deno/lib/core/internal/constants.ts b/packages/neo4j-driver-deno/lib/core/internal/constants.ts index 09ccb7602..ed32dcef5 100644 --- a/packages/neo4j-driver-deno/lib/core/internal/constants.ts +++ b/packages/neo4j-driver-deno/lib/core/internal/constants.ts @@ -38,6 +38,7 @@ const BOLT_PROTOCOL_V5_3: number = 5.3 const BOLT_PROTOCOL_V5_4: number = 5.4 const BOLT_PROTOCOL_V5_5: number = 5.5 const BOLT_PROTOCOL_V5_6: number = 5.6 +const BOLT_PROTOCOL_V5_7: number = 5.7 const TELEMETRY_APIS = { MANAGED_TRANSACTION: 0, @@ -72,5 +73,6 @@ export { BOLT_PROTOCOL_V5_4, BOLT_PROTOCOL_V5_5, BOLT_PROTOCOL_V5_6, + BOLT_PROTOCOL_V5_7, TELEMETRY_APIS } diff --git a/packages/neo4j-driver-deno/lib/core/notification.ts b/packages/neo4j-driver-deno/lib/core/notification.ts index 0e2fc02c6..ff8b3e898 100644 --- a/packages/neo4j-driver-deno/lib/core/notification.ts +++ b/packages/neo4j-driver-deno/lib/core/notification.ts @@ -16,7 +16,7 @@ */ import * as json from './json.ts' import { util } from './internal/index.ts' -import { NumberOrInteger } from './graph-types.ts' +import { DiagnosticRecord, rawPolyfilledDiagnosticRecord } from './gql-constants.ts' interface NotificationPosition { offset?: number @@ -41,7 +41,7 @@ const unknownGqlStatus: Record - [key: string]: unknown -} - /** * Representation for GqlStatusObject found when executing a query. *

@@ -251,7 +236,7 @@ interface NotificationDiagnosticRecord { class GqlStatusObject { public readonly gqlStatus: string public readonly statusDescription: string - public readonly diagnosticRecord: NotificationDiagnosticRecord + public readonly diagnosticRecord: DiagnosticRecord public readonly position?: NotificationPosition public readonly severity: NotificationSeverityLevel public readonly rawSeverity?: string @@ -420,7 +405,7 @@ function polyfillNotification (status: any): Notification | undefined { */ function polyfillGqlStatusObject (notification: any): GqlStatusObject { const defaultStatus = notification.severity === notificationSeverityLevel.WARNING ? unknownGqlStatus.WARNING : unknownGqlStatus.INFORMATION - const polyfilledRawObj: any & { diagnostic_record: NotificationDiagnosticRecord } = { + const polyfilledRawObj: any & { diagnostic_record: DiagnosticRecord } = { gql_status: defaultStatus.gql_status, status_description: notification.description ?? defaultStatus.status_description, neo4j_code: notification.code, @@ -445,14 +430,6 @@ function polyfillGqlStatusObject (notification: any): GqlStatusObject { return new GqlStatusObject(polyfilledRawObj) } -const rawPolyfilledDiagnosticRecord = { - OPERATION: '', - OPERATION_CODE: '0', - CURRENT_SCHEMA: '/' -} - -Object.freeze(rawPolyfilledDiagnosticRecord) - /** * This objects are used for polyfilling the first status on the status list * @@ -598,6 +575,5 @@ export type { NotificationPosition, NotificationSeverityLevel, NotificationCategory, - NotificationClassification, - NotificationDiagnosticRecord + NotificationClassification } diff --git a/packages/neo4j-driver/test/internal/connection-channel.test.js b/packages/neo4j-driver/test/internal/connection-channel.test.js index 8a8bd9e91..34e94663d 100644 --- a/packages/neo4j-driver/test/internal/connection-channel.test.js +++ b/packages/neo4j-driver/test/internal/connection-channel.test.js @@ -42,7 +42,7 @@ const { SERVICE_UNAVAILABLE } = error const ILLEGAL_MESSAGE = { signature: 42, fields: [] } const SUCCESS_MESSAGE = { signature: 0x70, fields: [{}] } -const FAILURE_MESSAGE = { signature: 0x7f, fields: [newError('Hello')] } +const FAILURE_MESSAGE = { signature: 0x7f, fields: [{ message: 'Hello' }] } const RECORD_MESSAGE = { signature: 0x71, fields: [{ value: 'Hello' }] } const BOLT_AGENT = { diff --git a/packages/neo4j-driver/test/internal/transaction-executor.test.js b/packages/neo4j-driver/test/internal/transaction-executor.test.js index 3005dc73f..efe012f0d 100644 --- a/packages/neo4j-driver/test/internal/transaction-executor.test.js +++ b/packages/neo4j-driver/test/internal/transaction-executor.test.js @@ -143,7 +143,7 @@ describe('#unit TransactionExecutor', () => { await testNoRetryOnUnknownError([OOM_ERROR], 1) }, 30000) - it('should not retry when transaction work returns promise rejected with unknown error', async () => { + it('should not retry when transaction work returns promise rejected with unexpected error', async () => { await testNoRetryOnUnknownError([UNKNOWN_ERROR], 1) }, 30000) @@ -155,7 +155,7 @@ describe('#unit TransactionExecutor', () => { await testNoRetryOnUnknownError([LOCKS_TERMINATED_ERROR], 1) }, 30000) - it('should not retry when transaction work returns promise rejected with unknown error type', async () => { + it('should not retry when transaction work returns promise rejected with unexpected error type', async () => { class MyTestError extends Error { constructor (message, code) { super(message) diff --git a/packages/testkit-backend/deno/controller.ts b/packages/testkit-backend/deno/controller.ts index b0d8385d7..310b70a65 100644 --- a/packages/testkit-backend/deno/controller.ts +++ b/packages/testkit-backend/deno/controller.ts @@ -1,4 +1,5 @@ import Context from "../src/context.js"; +import CypherNativeBinders from "../src/cypher-native-binders.js"; import { FakeTime } from "./deps.ts"; import { RequestHandlerMap, @@ -16,7 +17,11 @@ interface Wire { writeBackendError(msg: string): Promise; } -function newWire(context: Context, reply: Reply): Wire { +function newWire( + context: Context, + binder: CypherNativeBinders, + reply: Reply, +): Wire { return { writeResponse: (response: TestkitResponse) => reply(response), writeError: (e: Error) => { @@ -30,18 +35,7 @@ function newWire(context: Context, reply: Reply): Wire { }); } else { const id = context.addError(e); - return reply({ - name: "DriverError", - data: { - id, - errorType: e.name, - msg: e.message, - // @ts-ignore Code Neo4jError does have code - code: e.code, - // @ts-ignore Code Neo4jError does retryable - retryable: e.retriable, - }, - }); + return reply(writeDriverError(id, e, binder)); } } const msg = e.message; @@ -58,12 +52,13 @@ export function createHandler( newContext: () => Context, requestHandlers: RequestHandlerMap, ) { + const binder = new CypherNativeBinders(neo4j); return async function ( reply: Reply, requests: () => AsyncIterable, ) { const context = newContext(); - const wire = newWire(context, (response) => { + const wire = newWire(context, binder, (response) => { console.log("response:", response.name); console.debug(response.data); return reply(response); @@ -85,6 +80,46 @@ export function createHandler( }; } +function writeDriverError(id, e, binder) { + const cause = (e.cause != null && e.cause != undefined) + ? writeGqlError(e.cause, binder) + : undefined; + return { + name: "DriverError", + data: { + id, + errorType: e.name, + msg: e.message, + code: e.code, + gqlStatus: e.gqlStatus, + statusDescription: e.gqlStatusDescription, + diagnosticRecord: binder.objectToCypher(e.diagnosticRecord), + cause: cause, + classification: e.classification, + rawClassification: e.rawClassification, + retryable: e.retriable, + }, + }; +} + +function writeGqlError(e, binder) { + const cause = (e.cause != null && e.cause != undefined) + ? writeGqlError(e.cause, binder) + : undefined; + return { + name: "GqlError", + data: { + msg: e.message, + gqlStatus: e.gqlStatus, + statusDescription: e.gqlStatusDescription, + diagnosticRecord: binder.objectToCypher(e.diagnosticRecord), + cause: cause, + classification: e.classification, + rawClassification: e.rawClassification, + }, + }; +} + export default { createHandler, }; diff --git a/packages/testkit-backend/src/channel/testkit-protocol.js b/packages/testkit-backend/src/channel/testkit-protocol.js index 43bcd57ca..78bd22cd9 100644 --- a/packages/testkit-backend/src/channel/testkit-protocol.js +++ b/packages/testkit-backend/src/channel/testkit-protocol.js @@ -62,8 +62,7 @@ export default class Protocol extends EventEmitter { serializeResponse (response) { const responseStr = stringify(response) - console.log('> writing response', response.name) - console.debug(responseStr) + console.log('> writing response', responseStr) return ['#response begin', responseStr, '#response end'].join('\n') + '\n' } diff --git a/packages/testkit-backend/src/controller/local.js b/packages/testkit-backend/src/controller/local.js index 8cc0b3e64..1ebfa57f3 100644 --- a/packages/testkit-backend/src/controller/local.js +++ b/packages/testkit-backend/src/controller/local.js @@ -67,13 +67,7 @@ export default class LocalController extends Controller { })) } else { const id = this._contexts.get(contextId).addError(e) - this._writeResponse(contextId, newResponse('DriverError', { - id, - errorType: e.name, - msg: e.message, - code: e.code, - retryable: e.retriable - })) + this._writeResponse(contextId, writeDriverError(id, e, this._binder)) } return } @@ -86,3 +80,39 @@ function newResponse (name, data) { name, data } } + +function writeDriverError (id, e, binder) { + let cause + if (e.cause != null) { + cause = writeGqlError(e.cause, binder) + } + return newResponse('DriverError', { + id, + errorType: e.name, + msg: e.message, + code: e.code, + gqlStatus: e.gqlStatus, + statusDescription: e.gqlStatusDescription, + diagnosticRecord: binder.objectToCypher(e.diagnosticRecord), + cause, + classification: e.classification, + rawClassification: e.rawClassification, + retryable: e.retriable + }) +} + +function writeGqlError (e, binder) { + let cause + if (e.cause != null) { + cause = writeGqlError(e.cause, binder) + } + return newResponse('GqlError', { + msg: e.message, + gqlStatus: e.gqlStatus, + statusDescription: e.gqlStatusDescription, + diagnosticRecord: binder.objectToCypher(e.diagnosticRecord), + cause, + classification: e.classification, + rawClassification: e.rawClassification + }) +} diff --git a/packages/testkit-backend/src/feature/common.js b/packages/testkit-backend/src/feature/common.js index 756bcc92a..a56917f96 100644 --- a/packages/testkit-backend/src/feature/common.js +++ b/packages/testkit-backend/src/feature/common.js @@ -26,6 +26,7 @@ const features = [ 'Feature:Bolt:5.4', 'Feature:Bolt:5.5', 'Feature:Bolt:5.6', + 'Feature:Bolt:5.7', 'Feature:Bolt:Patch:UTC', 'Feature:API:ConnectionAcquisitionTimeout', 'Feature:API:Driver.ExecuteQuery', diff --git a/testkit/backend.py b/testkit/backend.py index 7e1bb6784..5f6c1b5cb 100644 --- a/testkit/backend.py +++ b/testkit/backend.py @@ -12,6 +12,7 @@ import os import time + if __name__ == "__main__": print("starting backend") backend_script = "start-testkit-backend" @@ -34,7 +35,7 @@ time.sleep(5) print("openning firefox") with open_proccess_in_driver_repo([ - "firefox", "-headless", "http://localhost:8000" + "firefox", "--profile", "./testkit/firefox_profile", "--headless", "http://localhost:8000" # type: ignore ]) as firefox: firefox.wait() backend.wait() diff --git a/testkit/firefox_profile/prefs.js b/testkit/firefox_profile/prefs.js new file mode 100644 index 000000000..2f8b5160b --- /dev/null +++ b/testkit/firefox_profile/prefs.js @@ -0,0 +1,3 @@ +/* global user_pref */ +user_pref('network.dnsCacheExpiration', 0); +user_pref('network.dnsCacheExpirationGracePeriod', 0);