diff --git a/package.json b/package.json index a0cb4ac09..f34bc042f 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "scripts": { "clean": "lerna clean -y && lerna run clean", "build": "lerna bootstrap --ci", - "build::deno": "(cd ./packages/neo4j-driver-deno && deno run --allow-read --allow-write --allow-net ./generate.ts --version=5.0.0-dev)", + "build::deno": "cd ./packages/neo4j-driver-deno && deno run --allow-read --allow-write --allow-net ./generate.ts --version=5.0.0-dev", "build::notci": "lerna bootstrap", "docs": "lerna run docs --stream --concurrency 1", "test::unit": "lerna run test::unit --stream", diff --git a/packages/neo4j-driver-deno/.gitignore b/packages/neo4j-driver-deno/.gitignore index 5f354661c..68e2c5173 100644 --- a/packages/neo4j-driver-deno/.gitignore +++ b/packages/neo4j-driver-deno/.gitignore @@ -1,2 +1,2 @@ -lib/ .vscode/ +lib2/ diff --git a/packages/neo4j-driver-deno/generate.ts b/packages/neo4j-driver-deno/generate.ts index ed5741c62..582f6c463 100644 --- a/packages/neo4j-driver-deno/generate.ts +++ b/packages/neo4j-driver-deno/generate.ts @@ -27,7 +27,7 @@ const isDir = (path: string) => { //////////////////////////////////////////////////////////////////////////////// // Parse arguments const parsedArgs = parse(Deno.args, { - string: ["version"], + string: ["version", "output"], boolean: ["transform"], // Pass --no-transform to disable default: { transform: true }, unknown: (arg) => { @@ -42,7 +42,7 @@ const version = parsedArgs.version ?? "0.0.0dev"; //////////////////////////////////////////////////////////////////////////////// // Clear out the destination folder -const rootOutDir = "lib/"; +const rootOutDir = parsedArgs.output ?? "lib/"; await ensureDir(rootOutDir); // Make sure it exists for await (const existingFile of Deno.readDir(rootOutDir)) { await Deno.remove(`${rootOutDir}${existingFile.name}`, { recursive: true }); diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-util.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-util.js new file mode 100644 index 000000000..21293dd7a --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-util.js @@ -0,0 +1,82 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { newError } from '../../core/index.ts' +// eslint-disable-next-line no-unused-vars +import { ResultStreamObserver } from './stream-observers.js' + +/** + * @param {TxConfig} txConfig the auto-commit transaction configuration. + * @param {function(error: string)} onProtocolError called when the txConfig is not empty. + * @param {ResultStreamObserver} observer the response observer. + */ +function assertTxConfigIsEmpty (txConfig, onProtocolError = () => {}, observer) { + if (txConfig && !txConfig.isEmpty()) { + const error = newError( + 'Driver is connected to the database that does not support transaction configuration. ' + + 'Please upgrade to neo4j 3.5.0 or later in order to use this functionality' + ) + + // unsupported API was used, consider this a fatal error for the current connection + onProtocolError(error.message) + observer.onError(error) + throw error + } +} + +/** + * Asserts that the passed-in database name is empty. + * @param {string} database + * @param {fuction(err: String)} onProtocolError Called when it doesn't have database set + */ +function assertDatabaseIsEmpty (database, onProtocolError = () => {}, observer) { + if (database) { + const error = newError( + 'Driver is connected to the database that does not support multiple databases. ' + + 'Please upgrade to neo4j 4.0.0 or later in order to use this functionality' + ) + + // unsupported API was used, consider this a fatal error for the current connection + onProtocolError(error.message) + observer.onError(error) + throw error + } +} + +/** + * Asserts that the passed-in impersonated user is empty + * @param {string} impersonatedUser + * @param {function (err:Error)} onProtocolError Called when it does have impersonated user set + * @param {any} observer + */ +function assertImpersonatedUserIsEmpty (impersonatedUser, onProtocolError = () => {}, observer) { + if (impersonatedUser) { + const error = newError( + 'Driver is connected to the database that does not support user impersonation. ' + + 'Please upgrade to neo4j 4.4.0 or later in order to use this functionality. ' + + `Trying to impersonate ${impersonatedUser}.` + ) + + // unsupported API was used, consider this a fatal error for the current connection + onProtocolError(error.message) + observer.onError(error) + throw error + } +} + +export { assertDatabaseIsEmpty, assertTxConfigIsEmpty, assertImpersonatedUserIsEmpty } 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 new file mode 100644 index 000000000..20bf7a31b --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v1.js @@ -0,0 +1,493 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { + assertDatabaseIsEmpty, + assertTxConfigIsEmpty, + assertImpersonatedUserIsEmpty +} from './bolt-protocol-util.js' +// eslint-disable-next-line no-unused-vars +import { Chunker } from '../channel/index.js' +import { structure, v1 } from '../packstream/index.js' +import RequestMessage from './request-message.js' +import { + LoginObserver, + ResetObserver, + ResultStreamObserver, + // eslint-disable-next-line no-unused-vars + StreamObserver +} from './stream-observers.js' +import { internal } from '../../core/index.ts' +import transformersFactories from './bolt-protocol-v1.transformer.js' +import Transformer from './transformer.js' + +const { + bookmarks: { Bookmarks }, + constants: { ACCESS_MODE_WRITE, BOLT_PROTOCOL_V1 }, + // eslint-disable-next-line no-unused-vars + logger: { Logger }, + txConfig: { TxConfig } +} = internal + +export default class BoltProtocol { + /** + * @callback CreateResponseHandler Creates the response handler + * @param {BoltProtocol} protocol The bolt protocol + * @returns {ResponseHandler} The response handler + */ + /** + * @callback OnProtocolError Handles protocol error + * @param {string} error The description + */ + /** + * @constructor + * @param {Object} server the server informatio. + * @param {Chunker} chunker the chunker. + * @param {Object} packstreamConfig Packstream configuration + * @param {boolean} packstreamConfig.disableLosslessIntegers if this connection should convert all received integers to native JS numbers. + * @param {boolean} packstreamConfig.useBigInt if this connection should convert all received integers to native BigInt numbers. + * @param {CreateResponseHandler} createResponseHandler Function which creates the response handler + * @param {Logger} log the logger + * @param {OnProtocolError} onProtocolError handles protocol errors + */ + constructor ( + server, + chunker, + { disableLosslessIntegers, useBigInt } = {}, + createResponseHandler = () => null, + log, + onProtocolError + ) { + this._server = server || {} + this._chunker = chunker + this._packer = this._createPacker(chunker) + this._unpacker = this._createUnpacker(disableLosslessIntegers, useBigInt) + this._responseHandler = createResponseHandler(this) + this._log = log + this._onProtocolError = onProtocolError + this._fatalError = null + this._lastMessageSignature = null + this._config = { disableLosslessIntegers, useBigInt } + } + + get transformer () { + if (this._transformer === undefined) { + this._transformer = new Transformer(Object.values(transformersFactories).map(create => create(this._config, this._log))) + } + return this._transformer + } + + /** + * Returns the numerical version identifier for this protocol + */ + get version () { + return BOLT_PROTOCOL_V1 + } + + /** + * Get the packer. + * @return {Packer} the protocol's packer. + */ + packer () { + return this._packer + } + + /** + * Creates a packable function out of the provided value + * @param x the value to pack + * @returns Function + */ + packable (x) { + return this._packer.packable(x, this.transformer.toStructure) + } + + /** + * Get the unpacker. + * @return {Unpacker} the protocol's unpacker. + */ + unpacker () { + return this._unpacker + } + + /** + * Unpack a buffer + * @param {Buffer} buf + * @returns {any|null} The unpacked value + */ + unpack (buf) { + return this._unpacker.unpack(buf, this.transformer.fromStructure) + } + + /** + * Transform metadata received in SUCCESS message before it is passed to the handler. + * @param {Object} metadata the received metadata. + * @return {Object} transformed metadata. + */ + transformMetadata (metadata) { + return metadata + } + + /** + * Perform initialization and authentication of the underlying connection. + * @param {Object} param + * @param {string} param.userAgent the user agent. + * @param {Object} param.authToken the authentication token. + * @param {function(err: Error)} param.onError the callback to invoke on error. + * @param {function()} param.onComplete the callback to invoke on completion. + * @returns {StreamObserver} the stream observer that monitors the corresponding server response. + */ + initialize ({ userAgent, authToken, onError, onComplete } = {}) { + const observer = new LoginObserver({ + onError: error => this._onLoginError(error, onError), + onCompleted: metadata => this._onLoginCompleted(metadata, onComplete) + }) + + this.write(RequestMessage.init(userAgent, authToken), observer, true) + + return observer + } + + /** + * Perform protocol related operations for closing this connection + */ + prepareToClose () { + // no need to notify the database in this protocol version + } + + /** + * Begin an explicit transaction. + * @param {Object} param + * @param {Bookmarks} param.bookmarks the bookmarks. + * @param {TxConfig} param.txConfig the configuration. + * @param {string} param.database the target database name. + * @param {string} param.mode the access mode. + * @param {string} param.impersonatedUser the impersonated user + * @param {function(err: Error)} param.beforeError the callback to invoke before handling the error. + * @param {function(err: Error)} param.afterError the callback to invoke after handling the error. + * @param {function()} param.beforeComplete the callback to invoke before handling the completion. + * @param {function()} param.afterComplete the callback to invoke after handling the completion. + * @returns {StreamObserver} the stream observer that monitors the corresponding server response. + */ + beginTransaction ({ + bookmarks, + txConfig, + database, + mode, + impersonatedUser, + beforeError, + afterError, + beforeComplete, + afterComplete + } = {}) { + return this.run( + 'BEGIN', + bookmarks ? bookmarks.asBeginTransactionParameters() : {}, + { + bookmarks: bookmarks, + txConfig: txConfig, + database, + mode, + impersonatedUser, + beforeError, + afterError, + beforeComplete, + afterComplete, + flush: false + } + ) + } + + /** + * Commit the explicit transaction. + * @param {Object} param + * @param {function(err: Error)} param.beforeError the callback to invoke before handling the error. + * @param {function(err: Error)} param.afterError the callback to invoke after handling the error. + * @param {function()} param.beforeComplete the callback to invoke before handling the completion. + * @param {function()} param.afterComplete the callback to invoke after handling the completion. + * @returns {StreamObserver} the stream observer that monitors the corresponding server response. + */ + commitTransaction ({ + beforeError, + afterError, + beforeComplete, + afterComplete + } = {}) { + // WRITE access mode is used as a place holder here, it has + // no effect on behaviour for Bolt V1 & V2 + return this.run( + 'COMMIT', + {}, + { + bookmarks: Bookmarks.empty(), + txConfig: TxConfig.empty(), + mode: ACCESS_MODE_WRITE, + beforeError, + afterError, + beforeComplete, + afterComplete + } + ) + } + + /** + * Rollback the explicit transaction. + * @param {Object} param + * @param {function(err: Error)} param.beforeError the callback to invoke before handling the error. + * @param {function(err: Error)} param.afterError the callback to invoke after handling the error. + * @param {function()} param.beforeComplete the callback to invoke before handling the completion. + * @param {function()} param.afterComplete the callback to invoke after handling the completion. + * @returns {StreamObserver} the stream observer that monitors the corresponding server response. + */ + rollbackTransaction ({ + beforeError, + afterError, + beforeComplete, + afterComplete + } = {}) { + // WRITE access mode is used as a place holder here, it has + // no effect on behaviour for Bolt V1 & V2 + return this.run( + 'ROLLBACK', + {}, + { + bookmarks: Bookmarks.empty(), + txConfig: TxConfig.empty(), + mode: ACCESS_MODE_WRITE, + beforeError, + afterError, + beforeComplete, + afterComplete + } + ) + } + + /** + * Send a Cypher query through the underlying connection. + * @param {string} query the cypher query. + * @param {Object} parameters the query parameters. + * @param {Object} param + * @param {Bookmarks} param.bookmarks the bookmarks. + * @param {TxConfig} param.txConfig the transaction configuration. + * @param {string} param.database the target database name. + * @param {string} param.impersonatedUser the impersonated user + * @param {string} param.mode the access mode. + * @param {function(keys: string[])} param.beforeKeys the callback to invoke before handling the keys. + * @param {function(keys: string[])} param.afterKeys the callback to invoke after handling the keys. + * @param {function(err: Error)} param.beforeError the callback to invoke before handling the error. + * @param {function(err: Error)} param.afterError the callback to invoke after handling the error. + * @param {function()} param.beforeComplete the callback to invoke before handling the completion. + * @param {function()} param.afterComplete the callback to invoke after handling the completion. + * @param {boolean} param.flush whether to flush the buffered messages. + * @returns {StreamObserver} the stream observer that monitors the corresponding server response. + */ + run ( + query, + parameters, + { + bookmarks, + txConfig, + database, + mode, + impersonatedUser, + beforeKeys, + afterKeys, + beforeError, + afterError, + beforeComplete, + afterComplete, + flush = true, + highRecordWatermark = Number.MAX_VALUE, + lowRecordWatermark = Number.MAX_VALUE + } = {} + ) { + const observer = new ResultStreamObserver({ + server: this._server, + beforeKeys, + afterKeys, + beforeError, + afterError, + beforeComplete, + afterComplete, + highRecordWatermark, + lowRecordWatermark + }) + + // bookmarks and mode are ignored in this version of the protocol + assertTxConfigIsEmpty(txConfig, this._onProtocolError, observer) + // passing in a database name on this protocol version throws an error + assertDatabaseIsEmpty(database, this._onProtocolError, observer) + // passing impersonated user on this protocol version throws an error + assertImpersonatedUserIsEmpty(impersonatedUser, this._onProtocolError, observer) + + this.write(RequestMessage.run(query, parameters), observer, false) + this.write(RequestMessage.pullAll(), observer, flush) + + return observer + } + + get currentFailure () { + return this._responseHandler.currentFailure + } + + /** + * Send a RESET through the underlying connection. + * @param {Object} param + * @param {function(err: Error)} param.onError the callback to invoke on error. + * @param {function()} param.onComplete the callback to invoke on completion. + * @returns {StreamObserver} the stream observer that monitors the corresponding server response. + */ + reset ({ onError, onComplete } = {}) { + const observer = new ResetObserver({ + onProtocolError: this._onProtocolError, + onError, + onComplete + }) + + this.write(RequestMessage.reset(), observer, true) + + return observer + } + + _createPacker (chunker) { + return new v1.Packer(chunker) + } + + _createUnpacker (disableLosslessIntegers, useBigInt) { + return new v1.Unpacker(disableLosslessIntegers, useBigInt) + } + + /** + * Write a message to the network channel. + * @param {RequestMessage} message the message to write. + * @param {StreamObserver} observer the response observer. + * @param {boolean} flush `true` if flush should happen after the message is written to the buffer. + */ + write (message, observer, flush) { + const queued = this.queueObserverIfProtocolIsNotBroken(observer) + + if (queued) { + if (this._log.isDebugEnabled()) { + this._log.debug(`C: ${message}`) + } + + this._lastMessageSignature = message.signature + const messageStruct = new structure.Structure(message.signature, message.fields) + + this.packable(messageStruct)() + + this._chunker.messageBoundary() + + if (flush) { + this._chunker.flush() + } + } + } + + isLastMessageLogin () { + return this._lastMessageSignature === 0x01 + } + + isLastMessageReset () { + return this._lastMessageSignature === 0x0f + } + + /** + * Notifies faltal erros to the observers and mark the protocol in the fatal error state. + * @param {Error} error The error + */ + notifyFatalError (error) { + this._fatalError = error + return this._responseHandler._notifyErrorToObservers(error) + } + + /** + * Updates the the current observer with the next one on the queue. + */ + updateCurrentObserver () { + return this._responseHandler._updateCurrentObserver() + } + + /** + * Checks if exist an ongoing observable requests + * @return {boolean} + */ + hasOngoingObservableRequests () { + return this._responseHandler.hasOngoingObservableRequests() + } + + /** + * Enqueue the observer if the protocol is not broken. + * In case it's broken, the observer will be notified about the error. + * + * @param {StreamObserver} observer The observer + * @returns {boolean} if it was queued + */ + queueObserverIfProtocolIsNotBroken (observer) { + if (this.isBroken()) { + this.notifyFatalErrorToObserver(observer) + return false + } + + return this._responseHandler._queueObserver(observer) + } + + /** + * Veritfy the protocol is not broken. + * @returns {boolean} + */ + isBroken () { + return !!this._fatalError + } + + /** + * Notifies the current fatal error to the observer + * + * @param {StreamObserver} observer The observer + */ + notifyFatalErrorToObserver (observer) { + if (observer && observer.onError) { + observer.onError(this._fatalError) + } + } + + /** + * Reset current failure on the observable response handler to null. + */ + resetFailure () { + this._responseHandler._resetFailure() + } + + _onLoginCompleted (metadata, onCompleted) { + if (metadata) { + const serverVersion = metadata.server + if (!this._server.version) { + this._server.version = serverVersion + } + } + if (onCompleted) { + onCompleted(metadata) + } + } + + _onLoginError (error, onError) { + this._onProtocolError(error.message) + if (onError) { + onError(error) + } + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v1.transformer.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v1.transformer.js new file mode 100644 index 000000000..4756576c0 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v1.transformer.js @@ -0,0 +1,187 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { + Node, + newError, + error, + Relationship, + UnboundRelationship, + Path, + toNumber, + PathSegment +} from '../../core/index.ts' + +import { structure } from '../packstream/index.js' +import { TypeTransformer } from './transformer.js' + +const { PROTOCOL_ERROR } = error + +const NODE = 0x4e +const NODE_STRUCT_SIZE = 3 + +const RELATIONSHIP = 0x52 +const RELATIONSHIP_STRUCT_SIZE = 5 + +const UNBOUND_RELATIONSHIP = 0x72 +const UNBOUND_RELATIONSHIP_STRUCT_SIZE = 3 + +const PATH = 0x50 +const PATH_STRUCT_SIZE = 3 + +/** + * Creates the Node Transformer + * @returns {TypeTransformer} + */ +function createNodeTransformer () { + return new TypeTransformer({ + signature: NODE, + isTypeInstance: object => object instanceof Node, + toStructure: object => { + throw newError( + `It is not allowed to pass nodes in query parameters, given: ${object}`, + PROTOCOL_ERROR + ) + }, + fromStructure: struct => { + structure.verifyStructSize('Node', NODE_STRUCT_SIZE, struct.size) + + const [identity, labels, properties] = struct.fields + + return new Node(identity, labels, properties) + } + }) +} + +/** + * Creates the Relationship Transformer + * @returns {TypeTransformer} + */ +function createRelationshipTransformer () { + return new TypeTransformer({ + signature: RELATIONSHIP, + isTypeInstance: object => object instanceof Relationship, + toStructure: object => { + throw newError( + `It is not allowed to pass relationships in query parameters, given: ${object}`, + PROTOCOL_ERROR + ) + }, + fromStructure: struct => { + structure.verifyStructSize('Relationship', RELATIONSHIP_STRUCT_SIZE, struct.size) + + const [identity, startNodeIdentity, endNodeIdentity, type, properties] = struct.fields + + return new Relationship(identity, startNodeIdentity, endNodeIdentity, type, properties) + } + }) +} + +/** + * Creates the Unbound Relationship Transformer + * @returns {TypeTransformer} + */ +function createUnboundRelationshipTransformer () { + return new TypeTransformer({ + signature: UNBOUND_RELATIONSHIP, + isTypeInstance: object => object instanceof UnboundRelationship, + toStructure: object => { + throw newError( + `It is not allowed to pass unbound relationships in query parameters, given: ${object}`, + PROTOCOL_ERROR + ) + }, + fromStructure: struct => { + structure.verifyStructSize( + 'UnboundRelationship', + UNBOUND_RELATIONSHIP_STRUCT_SIZE, + struct.size + ) + + const [identity, type, properties] = struct.fields + + return new UnboundRelationship(identity, type, properties) + } + }) +} + +/** + * Creates the Path Transformer + * @returns {TypeTransformer} + */ +function createPathTransformer () { + return new TypeTransformer({ + signature: PATH, + isTypeInstance: object => object instanceof Path, + toStructure: object => { + throw newError( + `It is not allowed to pass paths in query parameters, given: ${object}`, + PROTOCOL_ERROR + ) + }, + fromStructure: struct => { + structure.verifyStructSize('Path', PATH_STRUCT_SIZE, struct.size) + + const [nodes, rels, sequence] = struct.fields + + const segments = [] + let prevNode = nodes[0] + + for (let i = 0; i < sequence.length; i += 2) { + const nextNode = nodes[sequence[i + 1]] + const relIndex = toNumber(sequence[i]) + let rel + + if (relIndex > 0) { + rel = rels[relIndex - 1] + if (rel instanceof UnboundRelationship) { + // To avoid duplication, relationships in a path do not contain + // information about their start and end nodes, that's instead + // inferred from the path sequence. This is us inferring (and, + // for performance reasons remembering) the start/end of a rel. + rels[relIndex - 1] = rel = rel.bindTo( + prevNode, + nextNode + ) + } + } else { + rel = rels[-relIndex - 1] + if (rel instanceof UnboundRelationship) { + // See above + rels[-relIndex - 1] = rel = rel.bindTo( + nextNode, + prevNode + ) + } + } + // Done hydrating one path segment. + segments.push(new PathSegment(prevNode, rel, nextNode)) + prevNode = nextNode + } + return new Path(nodes[0], nodes[nodes.length - 1], segments) + } + }) +} + +export default { + createNodeTransformer, + createRelationshipTransformer, + createUnboundRelationshipTransformer, + createPathTransformer +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v2.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v2.js new file mode 100644 index 000000000..fb627f399 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v2.js @@ -0,0 +1,48 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 BoltProtocolV1 from './bolt-protocol-v1.js' +import v2 from '../packstream/index.js' +import { internal } from '../../core/index.ts' +import transformersFactories from './bolt-protocol-v2.transformer.js' +import Transformer from './transformer.js' + +const { + constants: { BOLT_PROTOCOL_V2 } +} = internal + +export default class BoltProtocol extends BoltProtocolV1 { + _createPacker (chunker) { + return new v2.Packer(chunker) + } + + _createUnpacker (disableLosslessIntegers, useBigInt) { + return new v2.Unpacker(disableLosslessIntegers, useBigInt) + } + + get transformer () { + if (this._transformer === undefined) { + this._transformer = new Transformer(Object.values(transformersFactories).map(create => create(this._config, this._log))) + } + return this._transformer + } + + get version () { + return BOLT_PROTOCOL_V2 + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v2.transformer.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v2.transformer.js new file mode 100644 index 000000000..a8108d409 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v2.transformer.js @@ -0,0 +1,432 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { + isPoint, + int, + isDuration, + Duration, + isLocalDateTime, + isLocalTime, + internal, + isTime, + Time, + isDate, + isDateTime, + DateTime, + Point, + isInt +} from '../../core/index.ts' + +import { structure } from '../packstream/index.js' +import { TypeTransformer } from './transformer.js' + +import { + epochDayToDate, + nanoOfDayToLocalTime, + epochSecondAndNanoToLocalDateTime +} from './temporal-factory.js' + +import v1 from './bolt-protocol-v1.transformer.js' + +const { + temporalUtil: { + dateToEpochDay, + localDateTimeToEpochSecond, + localTimeToNanoOfDay + } +} = internal + +const POINT_2D = 0x58 +const POINT_2D_STRUCT_SIZE = 3 + +const POINT_3D = 0x59 +const POINT_3D_STRUCT_SIZE = 4 + +const DURATION = 0x45 +const DURATION_STRUCT_SIZE = 4 + +const LOCAL_TIME = 0x74 +const LOCAL_TIME_STRUCT_SIZE = 1 + +const TIME = 0x54 +const TIME_STRUCT_SIZE = 2 + +const DATE = 0x44 +const DATE_STRUCT_SIZE = 1 + +const LOCAL_DATE_TIME = 0x64 +const LOCAL_DATE_TIME_STRUCT_SIZE = 2 + +const DATE_TIME_WITH_ZONE_OFFSET = 0x46 +const DATE_TIME_WITH_ZONE_OFFSET_STRUCT_SIZE = 3 + +const DATE_TIME_WITH_ZONE_ID = 0x66 +const DATE_TIME_WITH_ZONE_ID_STRUCT_SIZE = 3 + +/** + * Creates the Point2D Transformer + * @returns {TypeTransformer} + */ +function createPoint2DTransformer () { + return new TypeTransformer({ + signature: POINT_2D, + isTypeInstance: point => isPoint(point) && (point.z === null || point.z === undefined), + toStructure: point => new structure.Structure(POINT_2D, [ + int(point.srid), + point.x, + point.y + ]), + fromStructure: struct => { + structure.verifyStructSize('Point2D', POINT_2D_STRUCT_SIZE, struct.size) + + const [srid, x, y] = struct.fields + return new Point( + srid, + x, + y, + undefined // z + ) + } + }) +} + +/** + * Creates the Point3D Transformer + * @returns {TypeTransformer} + */ +function createPoint3DTransformer () { + return new TypeTransformer({ + signature: POINT_3D, + isTypeInstance: point => isPoint(point) && point.z !== null && point.z !== undefined, + toStructure: point => new structure.Structure(POINT_3D, [ + int(point.srid), + point.x, + point.y, + point.z + ]), + fromStructure: struct => { + structure.verifyStructSize('Point3D', POINT_3D_STRUCT_SIZE, struct.size) + + const [srid, x, y, z] = struct.fields + return new Point( + srid, + x, + y, + z + ) + } + }) +} + +/** + * Creates the Duration Transformer + * @returns {TypeTransformer} + */ +function createDurationTransformer () { + return new TypeTransformer({ + signature: DURATION, + isTypeInstance: isDuration, + toStructure: value => { + const months = int(value.months) + const days = int(value.days) + const seconds = int(value.seconds) + const nanoseconds = int(value.nanoseconds) + + return new structure.Structure(DURATION, [months, days, seconds, nanoseconds]) + }, + fromStructure: struct => { + structure.verifyStructSize('Duration', DURATION_STRUCT_SIZE, struct.size) + + const [months, days, seconds, nanoseconds] = struct.fields + + return new Duration(months, days, seconds, nanoseconds) + } + }) +} + +/** + * Creates the LocalTime Transformer + * @param {Object} param + * @param {boolean} param.disableLosslessIntegers Disables lossless integers + * @param {boolean} param.useBigInt Uses BigInt instead of number or Integer + * @returns {TypeTransformer} + */ +function createLocalTimeTransformer ({ disableLosslessIntegers, useBigInt }) { + return new TypeTransformer({ + signature: LOCAL_TIME, + isTypeInstance: isLocalTime, + toStructure: value => { + const nanoOfDay = localTimeToNanoOfDay( + value.hour, + value.minute, + value.second, + value.nanosecond + ) + + return new structure.Structure(LOCAL_TIME, [nanoOfDay]) + }, + fromStructure: struct => { + structure.verifyStructSize('LocalTime', LOCAL_TIME_STRUCT_SIZE, struct.size) + + const [nanoOfDay] = struct.fields + const result = nanoOfDayToLocalTime(nanoOfDay) + return convertIntegerPropsIfNeeded(result, disableLosslessIntegers, useBigInt) + } + }) +} + +/** + * Creates the Time Transformer + * @param {Object} param + * @param {boolean} param.disableLosslessIntegers Disables lossless integers + * @param {boolean} param.useBigInt Uses BigInt instead of number or Integer + * @returns {TypeTransformer} + */ +function createTimeTransformer ({ disableLosslessIntegers, useBigInt }) { + return new TypeTransformer({ + signature: TIME, + isTypeInstance: isTime, + toStructure: value => { + const nanoOfDay = localTimeToNanoOfDay( + value.hour, + value.minute, + value.second, + value.nanosecond + ) + const offsetSeconds = int(value.timeZoneOffsetSeconds) + + return new structure.Structure(TIME, [nanoOfDay, offsetSeconds]) + }, + fromStructure: struct => { + structure.verifyStructSize('Time', TIME_STRUCT_SIZE, struct.size) + + const [nanoOfDay, offsetSeconds] = struct.fields + const localTime = nanoOfDayToLocalTime(nanoOfDay) + const result = new Time( + localTime.hour, + localTime.minute, + localTime.second, + localTime.nanosecond, + offsetSeconds + ) + return convertIntegerPropsIfNeeded(result, disableLosslessIntegers, useBigInt) + } + }) +} + +/** + * Creates the Date Transformer + * @param {Object} param + * @param {boolean} param.disableLosslessIntegers Disables lossless integers + * @param {boolean} param.useBigInt Uses BigInt instead of number or Integer + * @returns {TypeTransformer} + */ +function createDateTransformer ({ disableLosslessIntegers, useBigInt }) { + return new TypeTransformer({ + signature: DATE, + isTypeInstance: isDate, + toStructure: value => { + const epochDay = dateToEpochDay(value.year, value.month, value.day) + + return new structure.Structure(DATE, [epochDay]) + }, + fromStructure: struct => { + structure.verifyStructSize('Date', DATE_STRUCT_SIZE, struct.size) + + const [epochDay] = struct.fields + const result = epochDayToDate(epochDay) + return convertIntegerPropsIfNeeded(result, disableLosslessIntegers, useBigInt) + } + }) +} + +/** + * Creates the LocalDateTime Transformer + * @param {Object} param + * @param {boolean} param.disableLosslessIntegers Disables lossless integers + * @param {boolean} param.useBigInt Uses BigInt instead of number or Integer + * @returns {TypeTransformer} + */ +function createLocalDateTimeTransformer ({ disableLosslessIntegers, useBigInt }) { + return new TypeTransformer({ + signature: LOCAL_DATE_TIME, + isTypeInstance: isLocalDateTime, + toStructure: value => { + const epochSecond = localDateTimeToEpochSecond( + value.year, + value.month, + value.day, + value.hour, + value.minute, + value.second, + value.nanosecond + ) + const nano = int(value.nanosecond) + + return new structure.Structure(LOCAL_DATE_TIME, [epochSecond, nano]) + }, + fromStructure: struct => { + structure.verifyStructSize( + 'LocalDateTime', + LOCAL_DATE_TIME_STRUCT_SIZE, + struct.size + ) + + const [epochSecond, nano] = struct.fields + const result = epochSecondAndNanoToLocalDateTime(epochSecond, nano) + return convertIntegerPropsIfNeeded(result, disableLosslessIntegers, useBigInt) + } + }) +} + +/** + * Creates the DateTime with ZoneId Transformer + * @param {Object} param + * @param {boolean} param.disableLosslessIntegers Disables lossless integers + * @param {boolean} param.useBigInt Uses BigInt instead of number or Integer + * @returns {TypeTransformer} + */ +function createDateTimeWithZoneIdTransformer ({ disableLosslessIntegers, useBigInt }) { + return new TypeTransformer({ + signature: DATE_TIME_WITH_ZONE_ID, + isTypeInstance: object => isDateTime(object) && object.timeZoneId != null, + toStructure: value => { + const epochSecond = localDateTimeToEpochSecond( + value.year, + value.month, + value.day, + value.hour, + value.minute, + value.second, + value.nanosecond + ) + const nano = int(value.nanosecond) + const timeZoneId = value.timeZoneId + + return new structure.Structure(DATE_TIME_WITH_ZONE_ID, [epochSecond, nano, timeZoneId]) + }, + fromStructure: struct => { + structure.verifyStructSize( + 'DateTimeWithZoneId', + DATE_TIME_WITH_ZONE_ID_STRUCT_SIZE, + struct.size + ) + + const [epochSecond, nano, timeZoneId] = struct.fields + + const localDateTime = epochSecondAndNanoToLocalDateTime(epochSecond, nano) + const result = new DateTime( + localDateTime.year, + localDateTime.month, + localDateTime.day, + localDateTime.hour, + localDateTime.minute, + localDateTime.second, + localDateTime.nanosecond, + null, + timeZoneId + ) + return convertIntegerPropsIfNeeded(result, disableLosslessIntegers, useBigInt) + } + }) +} + +/** + * Creates the DateTime with Offset Transformer + * @param {Object} param + * @param {boolean} param.disableLosslessIntegers Disables lossless integers + * @param {boolean} param.useBigInt Uses BigInt instead of number or Integer + * @returns {TypeTransformer} + */ +function createDateTimeWithOffsetTransformer ({ disableLosslessIntegers, useBigInt }) { + return new TypeTransformer({ + signature: DATE_TIME_WITH_ZONE_OFFSET, + isTypeInstance: object => isDateTime(object) && object.timeZoneId == null, + toStructure: value => { + const epochSecond = localDateTimeToEpochSecond( + value.year, + value.month, + value.day, + value.hour, + value.minute, + value.second, + value.nanosecond + ) + const nano = int(value.nanosecond) + const timeZoneOffsetSeconds = int(value.timeZoneOffsetSeconds) + return new structure.Structure(DATE_TIME_WITH_ZONE_OFFSET, [epochSecond, nano, timeZoneOffsetSeconds]) + }, + fromStructure: struct => { + structure.verifyStructSize( + 'DateTimeWithZoneOffset', + DATE_TIME_WITH_ZONE_OFFSET_STRUCT_SIZE, + struct.size + ) + + const [epochSecond, nano, timeZoneOffsetSeconds] = struct.fields + + const localDateTime = epochSecondAndNanoToLocalDateTime(epochSecond, nano) + const result = new DateTime( + localDateTime.year, + localDateTime.month, + localDateTime.day, + localDateTime.hour, + localDateTime.minute, + localDateTime.second, + localDateTime.nanosecond, + timeZoneOffsetSeconds, + null + ) + return convertIntegerPropsIfNeeded(result, disableLosslessIntegers, useBigInt) + } + }) +} + +function convertIntegerPropsIfNeeded (obj, disableLosslessIntegers, useBigInt) { + if (!disableLosslessIntegers && !useBigInt) { + return obj + } + + const convert = value => + useBigInt ? value.toBigInt() : value.toNumberOrInfinity() + + const clone = Object.create(Object.getPrototypeOf(obj)) + for (const prop in obj) { + if (Object.prototype.hasOwnProperty.call(obj, prop) === true) { + const value = obj[prop] + clone[prop] = isInt(value) ? convert(value) : value + } + } + Object.freeze(clone) + return clone +} + +export default { + ...v1, + createPoint2DTransformer, + createPoint3DTransformer, + createDurationTransformer, + createLocalTimeTransformer, + createTimeTransformer, + createDateTransformer, + createLocalDateTimeTransformer, + createDateTimeWithZoneIdTransformer, + createDateTimeWithOffsetTransformer +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v3.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v3.js new file mode 100644 index 000000000..57b5632ae --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v3.js @@ -0,0 +1,246 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 BoltProtocolV2 from './bolt-protocol-v2.js' +import RequestMessage from './request-message.js' +import { assertDatabaseIsEmpty, assertImpersonatedUserIsEmpty } from './bolt-protocol-util.js' +import { + StreamObserver, + LoginObserver, + ResultStreamObserver, + ProcedureRouteObserver +} from './stream-observers.js' +import transformersFactories from './bolt-protocol-v3.transformer.js' +import Transformer from './transformer.js' +import { internal } from '../../core/index.ts' + +const { + // eslint-disable-next-line no-unused-vars + bookmarks: { Bookmarks }, + constants: { BOLT_PROTOCOL_V3 }, + txConfig: { TxConfig } +} = internal + +const CONTEXT = 'context' +const CALL_GET_ROUTING_TABLE = `CALL dbms.cluster.routing.getRoutingTable($${CONTEXT})` + +const noOpObserver = new StreamObserver() + +export default class BoltProtocol extends BoltProtocolV2 { + get version () { + return BOLT_PROTOCOL_V3 + } + + get transformer () { + if (this._transformer === undefined) { + this._transformer = new Transformer(Object.values(transformersFactories).map(create => create(this._config, this._log))) + } + return this._transformer + } + + transformMetadata (metadata) { + if ('t_first' in metadata) { + // Bolt V3 uses shorter key 't_first' to represent 'result_available_after' + // adjust the key to be the same as in Bolt V1 so that ResultSummary can retrieve the value + metadata.result_available_after = metadata.t_first + delete metadata.t_first + } + if ('t_last' in metadata) { + // Bolt V3 uses shorter key 't_last' to represent 'result_consumed_after' + // adjust the key to be the same as in Bolt V1 so that ResultSummary can retrieve the value + metadata.result_consumed_after = metadata.t_last + delete metadata.t_last + } + return metadata + } + + initialize ({ userAgent, authToken, onError, onComplete } = {}) { + const observer = new LoginObserver({ + onError: error => this._onLoginError(error, onError), + onCompleted: metadata => this._onLoginCompleted(metadata, onComplete) + }) + + this.write(RequestMessage.hello(userAgent, authToken), observer, true) + + return observer + } + + prepareToClose () { + this.write(RequestMessage.goodbye(), noOpObserver, true) + } + + beginTransaction ({ + bookmarks, + txConfig, + database, + impersonatedUser, + mode, + beforeError, + afterError, + beforeComplete, + afterComplete + } = {}) { + const observer = new ResultStreamObserver({ + server: this._server, + beforeError, + afterError, + beforeComplete, + afterComplete + }) + observer.prepareToHandleSingleResponse() + + // passing in a database name on this protocol version throws an error + assertDatabaseIsEmpty(database, this._onProtocolError, observer) + // passing impersonated user on this protocol version throws an error + assertImpersonatedUserIsEmpty(impersonatedUser, this._onProtocolError, observer) + + this.write( + RequestMessage.begin({ bookmarks, txConfig, mode }), + observer, + true + ) + + return observer + } + + commitTransaction ({ + beforeError, + afterError, + beforeComplete, + afterComplete + } = {}) { + const observer = new ResultStreamObserver({ + server: this._server, + beforeError, + afterError, + beforeComplete, + afterComplete + }) + observer.prepareToHandleSingleResponse() + + this.write(RequestMessage.commit(), observer, true) + + return observer + } + + rollbackTransaction ({ + beforeError, + afterError, + beforeComplete, + afterComplete + } = {}) { + const observer = new ResultStreamObserver({ + server: this._server, + beforeError, + afterError, + beforeComplete, + afterComplete + }) + observer.prepareToHandleSingleResponse() + + this.write(RequestMessage.rollback(), observer, true) + + return observer + } + + run ( + query, + parameters, + { + bookmarks, + txConfig, + database, + impersonatedUser, + mode, + beforeKeys, + afterKeys, + beforeError, + afterError, + beforeComplete, + afterComplete, + flush = true, + highRecordWatermark = Number.MAX_VALUE, + lowRecordWatermark = Number.MAX_VALUE + } = {} + ) { + const observer = new ResultStreamObserver({ + server: this._server, + beforeKeys, + afterKeys, + beforeError, + afterError, + beforeComplete, + afterComplete, + highRecordWatermark, + lowRecordWatermark + }) + + // passing in a database name on this protocol version throws an error + assertDatabaseIsEmpty(database, this._onProtocolError, observer) + // passing impersonated user on this protocol version throws an error + assertImpersonatedUserIsEmpty(impersonatedUser, this._onProtocolError, observer) + + this.write( + RequestMessage.runWithMetadata(query, parameters, { + bookmarks, + txConfig, + mode + }), + observer, + false + ) + this.write(RequestMessage.pullAll(), observer, flush) + + return observer + } + + /** + * Request routing information + * + * @param {Object} param - + * @param {object} param.routingContext The routing context used to define the routing table. + * Multi-datacenter deployments is one of its use cases + * @param {string} param.databaseName The database name + * @param {Bookmarks} params.sessionContext.bookmarks The bookmarks used for requesting the routing table + * @param {string} params.sessionContext.mode The session mode + * @param {string} params.sessionContext.database The database name used on the session + * @param {function()} params.sessionContext.afterComplete The session param used after the session closed + * @param {function(err: Error)} param.onError + * @param {function(RawRoutingTable)} param.onCompleted + * @returns {RouteObserver} the route observer + */ + requestRoutingInformation ({ + routingContext = {}, + sessionContext = {}, + onError, + onCompleted + }) { + const resultObserver = this.run( + CALL_GET_ROUTING_TABLE, + { [CONTEXT]: routingContext }, + { ...sessionContext, txConfig: TxConfig.empty() } + ) + + return new ProcedureRouteObserver({ + resultObserver, + onProtocolError: this._onProtocolError, + onError, + onCompleted + }) + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v3.transformer.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v3.transformer.js new file mode 100644 index 000000000..c8d9a407f --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v3.transformer.js @@ -0,0 +1,24 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 v2 from './bolt-protocol-v2.transformer.js' + +export default { + ...v2 +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v4x0.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v4x0.js new file mode 100644 index 000000000..a9eb5c856 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v4x0.js @@ -0,0 +1,194 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 BoltProtocolV3 from './bolt-protocol-v3.js' +import RequestMessage from './request-message.js' +import { assertImpersonatedUserIsEmpty } from './bolt-protocol-util.js' +import { + ResultStreamObserver, + ProcedureRouteObserver +} from './stream-observers.js' +import transformersFactories from './bolt-protocol-v4x0.transformer.js' +import Transformer from './transformer.js' + +import { internal } from '../../core/index.ts' + +const { + // eslint-disable-next-line no-unused-vars + bookmarks: { Bookmarks }, + constants: { BOLT_PROTOCOL_V4_0, FETCH_ALL }, + txConfig: { TxConfig } +} = internal + +const CONTEXT = 'context' +const DATABASE = 'database' +const CALL_GET_ROUTING_TABLE_MULTI_DB = `CALL dbms.routing.getRoutingTable($${CONTEXT}, $${DATABASE})` + +export default class BoltProtocol extends BoltProtocolV3 { + get version () { + return BOLT_PROTOCOL_V4_0 + } + + get transformer () { + if (this._transformer === undefined) { + this._transformer = new Transformer(Object.values(transformersFactories).map(create => create(this._config, this._log))) + } + return this._transformer + } + + beginTransaction ({ + bookmarks, + txConfig, + database, + impersonatedUser, + mode, + beforeError, + afterError, + beforeComplete, + afterComplete + } = {}) { + const observer = new ResultStreamObserver({ + server: this._server, + beforeError, + afterError, + beforeComplete, + afterComplete + }) + observer.prepareToHandleSingleResponse() + + // passing impersonated user on this protocol version throws an error + assertImpersonatedUserIsEmpty(impersonatedUser, this._onProtocolError, observer) + + this.write( + RequestMessage.begin({ bookmarks, txConfig, database, mode }), + observer, + true + ) + + return observer + } + + run ( + query, + parameters, + { + bookmarks, + txConfig, + database, + impersonatedUser, + mode, + beforeKeys, + afterKeys, + beforeError, + afterError, + beforeComplete, + afterComplete, + flush = true, + reactive = false, + fetchSize = FETCH_ALL, + highRecordWatermark = Number.MAX_VALUE, + lowRecordWatermark = Number.MAX_VALUE + } = {} + ) { + const observer = new ResultStreamObserver({ + server: this._server, + reactive: reactive, + fetchSize: fetchSize, + moreFunction: this._requestMore.bind(this), + discardFunction: this._requestDiscard.bind(this), + beforeKeys, + afterKeys, + beforeError, + afterError, + beforeComplete, + afterComplete, + highRecordWatermark, + lowRecordWatermark + }) + + // passing impersonated user on this protocol version throws an error + assertImpersonatedUserIsEmpty(impersonatedUser, this._onProtocolError, observer) + + const flushRun = reactive + this.write( + RequestMessage.runWithMetadata(query, parameters, { + bookmarks, + txConfig, + database, + mode + }), + observer, + flushRun && flush + ) + + if (!reactive) { + this.write(RequestMessage.pull({ n: fetchSize }), observer, flush) + } + + return observer + } + + _requestMore (stmtId, n, observer) { + this.write(RequestMessage.pull({ stmtId, n }), observer, true) + } + + _requestDiscard (stmtId, observer) { + this.write(RequestMessage.discard({ stmtId }), observer, true) + } + + _noOp () {} + + /** + * Request routing information + * + * @param {Object} param - + * @param {object} param.routingContext The routing context used to define the routing table. + * Multi-datacenter deployments is one of its use cases + * @param {string} param.databaseName The database name + * @param {Bookmarks} params.sessionContext.bookmarks The bookmarks used for requesting the routing table + * @param {string} params.sessionContext.mode The session mode + * @param {string} params.sessionContext.database The database name used on the session + * @param {function()} params.sessionContext.afterComplete The session param used after the session closed + * @param {function(err: Error)} param.onError + * @param {function(RawRoutingTable)} param.onCompleted + * @returns {RouteObserver} the route observer + */ + requestRoutingInformation ({ + routingContext = {}, + databaseName = null, + sessionContext = {}, + onError, + onCompleted + }) { + const resultObserver = this.run( + CALL_GET_ROUTING_TABLE_MULTI_DB, + { + [CONTEXT]: routingContext, + [DATABASE]: databaseName + }, + { ...sessionContext, txConfig: TxConfig.empty() } + ) + + return new ProcedureRouteObserver({ + resultObserver, + onProtocolError: this._onProtocolError, + onError, + onCompleted + }) + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v4x0.transformer.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v4x0.transformer.js new file mode 100644 index 000000000..967308414 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v4x0.transformer.js @@ -0,0 +1,24 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 v3 from './bolt-protocol-v3.transformer.js' + +export default { + ...v3 +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v4x1.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v4x1.js new file mode 100644 index 000000000..001f41e68 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v4x1.js @@ -0,0 +1,89 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 BoltProtocolV4 from './bolt-protocol-v4x0.js' +import RequestMessage from './request-message.js' +import { LoginObserver } from './stream-observers.js' +import { internal } from '../../core/index.ts' + +import transformersFactories from './bolt-protocol-v4x1.transformer.js' +import Transformer from './transformer.js' + +const { + constants: { BOLT_PROTOCOL_V4_1 } +} = internal + +export default class BoltProtocol extends BoltProtocolV4 { + /** + * @constructor + * @param {Object} server the server informatio. + * @param {Chunker} chunker the chunker. + * @param {Object} packstreamConfig Packstream configuration + * @param {boolean} packstreamConfig.disableLosslessIntegers if this connection should convert all received integers to native JS numbers. + * @param {boolean} packstreamConfig.useBigInt if this connection should convert all received integers to native BigInt numbers. + * @param {CreateResponseHandler} createResponseHandler Function which creates the response handler + * @param {Logger} log the logger + * @param {Object} serversideRouting + * + */ + constructor ( + server, + chunker, + packstreamConfig, + createResponseHandler = () => null, + log, + onProtocolError, + serversideRouting + ) { + super( + server, + chunker, + packstreamConfig, + createResponseHandler, + log, + onProtocolError + ) + this._serversideRouting = serversideRouting + } + + get version () { + return BOLT_PROTOCOL_V4_1 + } + + get transformer () { + if (this._transformer === undefined) { + this._transformer = new Transformer(Object.values(transformersFactories).map(create => create(this._config, this._log))) + } + return this._transformer + } + + initialize ({ userAgent, authToken, onError, onComplete } = {}) { + const observer = new LoginObserver({ + onError: error => this._onLoginError(error, onError), + onCompleted: metadata => this._onLoginCompleted(metadata, onComplete) + }) + + this.write( + RequestMessage.hello(userAgent, authToken, this._serversideRouting), + observer, + true + ) + + return observer + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v4x1.transformer.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v4x1.transformer.js new file mode 100644 index 000000000..7b3f46e6c --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v4x1.transformer.js @@ -0,0 +1,24 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 v4x0 from './bolt-protocol-v4x0.transformer.js' + +export default { + ...v4x0 +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v4x2.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v4x2.js new file mode 100644 index 000000000..176de4db0 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v4x2.js @@ -0,0 +1,41 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 BoltProtocolV41 from './bolt-protocol-v4x1.js' + +import { internal } from '../../core/index.ts' + +import transformersFactories from './bolt-protocol-v4x2.transformer.js' +import Transformer from './transformer.js' + +const { + constants: { BOLT_PROTOCOL_V4_2 } +} = internal + +export default class BoltProtocol extends BoltProtocolV41 { + get version () { + return BOLT_PROTOCOL_V4_2 + } + + get transformer () { + if (this._transformer === undefined) { + this._transformer = new Transformer(Object.values(transformersFactories).map(create => create(this._config, this._log))) + } + return this._transformer + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v4x2.transformer.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v4x2.transformer.js new file mode 100644 index 000000000..c4aa19bac --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v4x2.transformer.js @@ -0,0 +1,24 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 v4x1 from './bolt-protocol-v4x1.transformer.js' + +export default { + ...v4x1 +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v4x3.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v4x3.js new file mode 100644 index 000000000..86d62a1f1 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v4x3.js @@ -0,0 +1,126 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 BoltProtocolV42 from './bolt-protocol-v4x2.js' +import RequestMessage from './request-message.js' +import { LoginObserver, RouteObserver } from './stream-observers.js' + +import transformersFactories from './bolt-protocol-v4x3.transformer.js' +import utcTransformersFactories from './bolt-protocol-v5x0.utc.transformer.js' +import Transformer from './transformer.js' + +import { internal } from '../../core/index.ts' + +const { + bookmarks: { Bookmarks }, + constants: { BOLT_PROTOCOL_V4_3 } +} = internal + +export default class BoltProtocol extends BoltProtocolV42 { + get version () { + return BOLT_PROTOCOL_V4_3 + } + + get transformer () { + if (this._transformer === undefined) { + this._transformer = new Transformer(Object.values(transformersFactories).map(create => create(this._config, this._log))) + } + return this._transformer + } + + /** + * Request routing information + * + * @param {Object} param - + * @param {object} param.routingContext The routing context used to define the routing table. + * Multi-datacenter deployments is one of its use cases + * @param {string} param.databaseName The database name + * @param {Bookmarks} params.sessionContext.bookmarks The bookmarks used for requesting the routing table + * @param {function(err: Error)} param.onError + * @param {function(RawRoutingTable)} param.onCompleted + * @returns {RouteObserver} the route observer + */ + requestRoutingInformation ({ + routingContext = {}, + databaseName = null, + sessionContext = {}, + onError, + onCompleted + }) { + const observer = new RouteObserver({ + onProtocolError: this._onProtocolError, + onError, + onCompleted + }) + const bookmarks = sessionContext.bookmarks || Bookmarks.empty() + this.write( + RequestMessage.route(routingContext, bookmarks.values(), databaseName), + observer, + true + ) + + return observer + } + + /** + * Initialize a connection with the server + * + * @param {Object} param0 The params + * @param {string} param0.userAgent The user agent + * @param {any} param0.authToken The auth token + * @param {function(error)} param0.onError On error callback + * @param {function(onComplte)} param0.onComplete On complete callback + * @returns {LoginObserver} The Login observer + */ + initialize ({ userAgent, authToken, onError, onComplete } = {}) { + const observer = new LoginObserver({ + onError: error => this._onLoginError(error, onError), + onCompleted: metadata => { + if (metadata.patch_bolt !== undefined) { + this._applyPatches(metadata.patch_bolt) + } + return this._onLoginCompleted(metadata, onComplete) + } + }) + + this.write( + RequestMessage.hello(userAgent, authToken, this._serversideRouting, ['utc']), + observer, + true + ) + + return observer + } + + /** + * + * @param {string[]} patches Patches to be applied to the protocol + */ + _applyPatches (patches) { + if (patches.includes('utc')) { + this._applyUtcPatch() + } + } + + _applyUtcPatch () { + this._transformer = new Transformer(Object.values({ + ...transformersFactories, + ...utcTransformersFactories + }).map(create => create(this._config, this._log))) + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v4x3.transformer.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v4x3.transformer.js new file mode 100644 index 000000000..c98fef489 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v4x3.transformer.js @@ -0,0 +1,24 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 v4x2 from './bolt-protocol-v4x2.transformer.js' + +export default { + ...v4x2 +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v4x4.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v4x4.js new file mode 100644 index 000000000..22d95f52b --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v4x4.js @@ -0,0 +1,174 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 BoltProtocolV43 from './bolt-protocol-v4x3.js' + +import { internal } from '../../core/index.ts' +import RequestMessage from './request-message.js' +import { RouteObserver, ResultStreamObserver } from './stream-observers.js' + +import transformersFactories from './bolt-protocol-v4x4.transformer.js' +import utcTransformersFactories from './bolt-protocol-v5x0.utc.transformer.js' +import Transformer from './transformer.js' + +const { + constants: { BOLT_PROTOCOL_V4_4, FETCH_ALL }, + bookmarks: { Bookmarks } +} = internal + +export default class BoltProtocol extends BoltProtocolV43 { + get version () { + return BOLT_PROTOCOL_V4_4 + } + + get transformer () { + if (this._transformer === undefined) { + this._transformer = new Transformer(Object.values(transformersFactories).map(create => create(this._config, this._log))) + } + return this._transformer + } + + /** + * Request routing information + * + * @param {Object} param - + * @param {object} param.routingContext The routing context used to define the routing table. + * Multi-datacenter deployments is one of its use cases + * @param {string} param.databaseName The database name + * @param {Bookmarks} params.sessionContext.bookmarks The bookmarks used for requesting the routing table + * @param {function(err: Error)} param.onError + * @param {function(RawRoutingTable)} param.onCompleted + * @returns {RouteObserver} the route observer + */ + requestRoutingInformation ({ + routingContext = {}, + databaseName = null, + impersonatedUser = null, + sessionContext = {}, + onError, + onCompleted + }) { + const observer = new RouteObserver({ + onProtocolError: this._onProtocolError, + onError, + onCompleted + }) + const bookmarks = sessionContext.bookmarks || Bookmarks.empty() + this.write( + RequestMessage.routeV4x4(routingContext, bookmarks.values(), { databaseName, impersonatedUser }), + observer, + true + ) + + return observer + } + + run ( + query, + parameters, + { + bookmarks, + txConfig, + database, + mode, + impersonatedUser, + beforeKeys, + afterKeys, + beforeError, + afterError, + beforeComplete, + afterComplete, + flush = true, + reactive = false, + fetchSize = FETCH_ALL, + highRecordWatermark = Number.MAX_VALUE, + lowRecordWatermark = Number.MAX_VALUE + } = {} + ) { + const observer = new ResultStreamObserver({ + server: this._server, + reactive: reactive, + fetchSize: fetchSize, + moreFunction: this._requestMore.bind(this), + discardFunction: this._requestDiscard.bind(this), + beforeKeys, + afterKeys, + beforeError, + afterError, + beforeComplete, + afterComplete, + highRecordWatermark, + lowRecordWatermark + }) + + const flushRun = reactive + this.write( + RequestMessage.runWithMetadata(query, parameters, { + bookmarks, + txConfig, + database, + mode, + impersonatedUser + }), + observer, + flushRun && flush + ) + + if (!reactive) { + this.write(RequestMessage.pull({ n: fetchSize }), observer, flush) + } + + return observer + } + + beginTransaction ({ + bookmarks, + txConfig, + database, + mode, + impersonatedUser, + beforeError, + afterError, + beforeComplete, + afterComplete + } = {}) { + const observer = new ResultStreamObserver({ + server: this._server, + beforeError, + afterError, + beforeComplete, + afterComplete + }) + observer.prepareToHandleSingleResponse() + + this.write( + RequestMessage.begin({ bookmarks, txConfig, database, mode, impersonatedUser }), + observer, + true + ) + + return observer + } + + _applyUtcPatch () { + this._transformer = new Transformer(Object.values({ + ...transformersFactories, + ...utcTransformersFactories + }).map(create => create(this._config, this._log))) + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v4x4.transformer.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v4x4.transformer.js new file mode 100644 index 000000000..190439f85 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v4x4.transformer.js @@ -0,0 +1,24 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 v4x3 from './bolt-protocol-v4x3.transformer.js' + +export default { + ...v4x3 +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v5x0.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v5x0.js new file mode 100644 index 000000000..4e30ccb0b --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v5x0.js @@ -0,0 +1,68 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 BoltProtocolV44 from './bolt-protocol-v4x4.js' + +import transformersFactories from './bolt-protocol-v5x0.transformer.js' +import Transformer from './transformer.js' +import RequestMessage from './request-message.js' +import { LoginObserver } from './stream-observers.js' + +import { internal } from '../../core/index.ts' + +const { + constants: { BOLT_PROTOCOL_V5_0 } +} = internal + +export default class BoltProtocol extends BoltProtocolV44 { + get version () { + return BOLT_PROTOCOL_V5_0 + } + + get transformer () { + if (this._transformer === undefined) { + this._transformer = new Transformer(Object.values(transformersFactories).map(create => create(this._config, this._log))) + } + return this._transformer + } + + /** + * Initialize a connection with the server + * + * @param {Object} param0 The params + * @param {string} param0.userAgent The user agent + * @param {any} param0.authToken The auth token + * @param {function(error)} param0.onError On error callback + * @param {function(onComplte)} param0.onComplete On complete callback + * @returns {LoginObserver} The Login observer + */ + initialize ({ userAgent, authToken, onError, onComplete } = {}) { + const observer = new LoginObserver({ + onError: error => this._onLoginError(error, onError), + onCompleted: metadata => this._onLoginCompleted(metadata, onComplete) + }) + + this.write( + RequestMessage.hello(userAgent, authToken, this._serversideRouting), + observer, + true + ) + + return observer + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v5x0.transformer.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v5x0.transformer.js new file mode 100644 index 000000000..9527cc96a --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v5x0.transformer.js @@ -0,0 +1,131 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { structure } from '../packstream/index.js' +import { + Node, + Relationship, + UnboundRelationship +} from '../../core/index.ts' + +import v4x4 from './bolt-protocol-v4x4.transformer.js' +import v5x0Utc from './bolt-protocol-v5x0.utc.transformer.js' + +const NODE_STRUCT_SIZE = 4 +const RELATIONSHIP_STRUCT_SIZE = 8 +const UNBOUND_RELATIONSHIP_STRUCT_SIZE = 4 + +/** + * Create an extend Node transformer with support to elementId + * @param {any} config + * @returns {TypeTransformer} + */ +function createNodeTransformer (config) { + const node4x4Transformer = v4x4.createNodeTransformer(config) + return node4x4Transformer.extendsWith({ + fromStructure: struct => { + structure.verifyStructSize('Node', NODE_STRUCT_SIZE, struct.size) + + const [identity, lables, properties, elementId] = struct.fields + + return new Node( + identity, + lables, + properties, + elementId + ) + } + }) +} + +/** + * Create an extend Relationship transformer with support to elementId + * @param {any} config + * @returns {TypeTransformer} + */ +function createRelationshipTransformer (config) { + const relationship4x4Transformer = v4x4.createRelationshipTransformer(config) + return relationship4x4Transformer.extendsWith({ + fromStructure: struct => { + structure.verifyStructSize('Relationship', RELATIONSHIP_STRUCT_SIZE, struct.size) + + const [ + identity, + startNodeIdentity, + endNodeIdentity, + type, + properties, + elementId, + startNodeElementId, + endNodeElementId + ] = struct.fields + + return new Relationship( + identity, + startNodeIdentity, + endNodeIdentity, + type, + properties, + elementId, + startNodeElementId, + endNodeElementId + ) + } + }) +} + +/** + * Create an extend Unbound Relationship transformer with support to elementId + * @param {any} config + * @returns {TypeTransformer} + */ +function createUnboundRelationshipTransformer (config) { + const unboundRelationshipTransformer = v4x4.createUnboundRelationshipTransformer(config) + return unboundRelationshipTransformer.extendsWith({ + fromStructure: struct => { + structure.verifyStructSize( + 'UnboundRelationship', + UNBOUND_RELATIONSHIP_STRUCT_SIZE, + struct.size + ) + + const [ + identity, + type, + properties, + elementId + ] = struct.fields + + return new UnboundRelationship( + identity, + type, + properties, + elementId + ) + } + }) +} + +export default { + ...v4x4, + ...v5x0Utc, + createNodeTransformer, + createRelationshipTransformer, + createUnboundRelationshipTransformer +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v5x0.utc.transformer.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v5x0.utc.transformer.js new file mode 100644 index 000000000..821ba47bf --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/bolt-protocol-v5x0.utc.transformer.js @@ -0,0 +1,281 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { structure } from '../packstream/index.js' +import { + DateTime, + isInt, + int, + internal +} from '../../core/index.ts' + +import v4x4 from './bolt-protocol-v4x4.transformer.js' + +import { + epochSecondAndNanoToLocalDateTime +} from './temporal-factory.js' +import { identity } from '../lang/functional.js' + +const { + temporalUtil: { + localDateTimeToEpochSecond + } +} = internal + +const DATE_TIME_WITH_ZONE_OFFSET = 0x49 +const DATE_TIME_WITH_ZONE_OFFSET_STRUCT_SIZE = 3 + +const DATE_TIME_WITH_ZONE_ID = 0x69 +const DATE_TIME_WITH_ZONE_ID_STRUCT_SIZE = 3 + +function createDateTimeWithZoneIdTransformer (config, logger) { + const { disableLosslessIntegers, useBigInt } = config + const dateTimeWithZoneIdTransformer = v4x4.createDateTimeWithZoneIdTransformer(config) + return dateTimeWithZoneIdTransformer.extendsWith({ + signature: DATE_TIME_WITH_ZONE_ID, + fromStructure: struct => { + structure.verifyStructSize( + 'DateTimeWithZoneId', + DATE_TIME_WITH_ZONE_ID_STRUCT_SIZE, + struct.size + ) + + const [epochSecond, nano, timeZoneId] = struct.fields + + const localDateTime = getTimeInZoneId(timeZoneId, epochSecond, nano) + + const result = new DateTime( + localDateTime.year, + localDateTime.month, + localDateTime.day, + localDateTime.hour, + localDateTime.minute, + localDateTime.second, + int(nano), + localDateTime.timeZoneOffsetSeconds, + timeZoneId + ) + return convertIntegerPropsIfNeeded(result, disableLosslessIntegers, useBigInt) + }, + toStructure: value => { + const epochSecond = localDateTimeToEpochSecond( + value.year, + value.month, + value.day, + value.hour, + value.minute, + value.second, + value.nanosecond + ) + + const offset = value.timeZoneOffsetSeconds != null + ? value.timeZoneOffsetSeconds + : getOffsetFromZoneId(value.timeZoneId, epochSecond, value.nanosecond) + + if (value.timeZoneOffsetSeconds == null) { + logger.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.') + } + const utc = epochSecond.subtract(offset) + + const nano = int(value.nanosecond) + const timeZoneId = value.timeZoneId + + return new structure.Structure(DATE_TIME_WITH_ZONE_ID, [utc, nano, timeZoneId]) + } + }) +} + +/** + * Returns the offset for a given timezone id + * + * Javascript doesn't have support for direct getting the timezone offset from a given + * TimeZoneId and DateTime in the given TimeZoneId. For solving this issue, + * + * 1. The ZoneId is applied to the timestamp, so we could make the difference between the + * given timestamp and the new calculated one. This is the offset for the timezone + * in the utc is equal to epoch (some time in the future or past) + * 2. The offset is subtracted from the timestamp, so we have an estimated utc timestamp. + * 3. The ZoneId is applied to the new timestamp, se we could could make the difference + * between the new timestamp and the calculated one. This is the offset for the given timezone. + * + * Example: + * Input: 2022-3-27 1:59:59 'Europe/Berlin' + * Apply 1, 2022-3-27 1:59:59 => 2022-3-27 3:59:59 'Europe/Berlin' +2:00 + * Apply 2, 2022-3-27 1:59:59 - 2:00 => 2022-3-26 23:59:59 + * Apply 3, 2022-3-26 23:59:59 => 2022-3-27 00:59:59 'Europe/Berlin' +1:00 + * The offset is +1 hour. + * + * @param {string} timeZoneId The timezone id + * @param {Integer} epochSecond The epoch second in the timezone id + * @param {Integerable} nanosecond The nanoseconds in the timezone id + * @returns The timezone offset + */ +function getOffsetFromZoneId (timeZoneId, epochSecond, nanosecond) { + const dateTimeWithZoneAppliedTwice = getTimeInZoneId(timeZoneId, epochSecond, nanosecond) + + // The wallclock form the current date time + const epochWithZoneAppliedTwice = localDateTimeToEpochSecond( + dateTimeWithZoneAppliedTwice.year, + dateTimeWithZoneAppliedTwice.month, + dateTimeWithZoneAppliedTwice.day, + dateTimeWithZoneAppliedTwice.hour, + dateTimeWithZoneAppliedTwice.minute, + dateTimeWithZoneAppliedTwice.second, + nanosecond) + + const offsetOfZoneInTheFutureUtc = epochWithZoneAppliedTwice.subtract(epochSecond) + const guessedUtc = epochSecond.subtract(offsetOfZoneInTheFutureUtc) + + const zonedDateTimeFromGuessedUtc = getTimeInZoneId(timeZoneId, guessedUtc, nanosecond) + + const zonedEpochFromGuessedUtc = localDateTimeToEpochSecond( + zonedDateTimeFromGuessedUtc.year, + zonedDateTimeFromGuessedUtc.month, + zonedDateTimeFromGuessedUtc.day, + zonedDateTimeFromGuessedUtc.hour, + zonedDateTimeFromGuessedUtc.minute, + zonedDateTimeFromGuessedUtc.second, + nanosecond) + + const offset = zonedEpochFromGuessedUtc.subtract(guessedUtc) + return offset +} + +function getTimeInZoneId (timeZoneId, epochSecond, nano) { + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: timeZoneId, + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + hour12: false, + era: 'narrow' + }) + + const utc = int(epochSecond) + .multiply(1000) + .add(int(nano).div(1_000_000)) + .toNumber() + + const formattedUtcParts = formatter.formatToParts(utc) + + const localDateTime = formattedUtcParts.reduce((obj, currentValue) => { + if (currentValue.type === 'era') { + obj.adjustEra = + currentValue.value.toUpperCase() === 'B' + ? year => year.subtract(1).negate() // 1BC equals to year 0 in astronomical year numbering + : identity + } else if (currentValue.type !== 'literal') { + obj[currentValue.type] = int(currentValue.value) + } + return obj + }, {}) + + localDateTime.year = localDateTime.adjustEra(localDateTime.year) + + const epochInTimeZone = localDateTimeToEpochSecond( + localDateTime.year, + localDateTime.month, + localDateTime.day, + localDateTime.hour, + localDateTime.minute, + localDateTime.second, + localDateTime.nanosecond + ) + + localDateTime.timeZoneOffsetSeconds = epochInTimeZone.subtract(epochSecond) + localDateTime.hour = localDateTime.hour.modulo(24) + + return localDateTime +} + +function createDateTimeWithOffsetTransformer (config) { + const { disableLosslessIntegers, useBigInt } = config + const dateTimeWithOffsetTransformer = v4x4.createDateTimeWithOffsetTransformer(config) + return dateTimeWithOffsetTransformer.extendsWith({ + signature: DATE_TIME_WITH_ZONE_OFFSET, + toStructure: value => { + const epochSecond = localDateTimeToEpochSecond( + value.year, + value.month, + value.day, + value.hour, + value.minute, + value.second, + value.nanosecond + ) + const nano = int(value.nanosecond) + const timeZoneOffsetSeconds = int(value.timeZoneOffsetSeconds) + const utcSecond = epochSecond.subtract(timeZoneOffsetSeconds) + return new structure.Structure(DATE_TIME_WITH_ZONE_OFFSET, [utcSecond, nano, timeZoneOffsetSeconds]) + }, + fromStructure: struct => { + structure.verifyStructSize( + 'DateTimeWithZoneOffset', + DATE_TIME_WITH_ZONE_OFFSET_STRUCT_SIZE, + struct.size + ) + + const [utcSecond, nano, timeZoneOffsetSeconds] = struct.fields + + const epochSecond = int(utcSecond).add(timeZoneOffsetSeconds) + const localDateTime = epochSecondAndNanoToLocalDateTime(epochSecond, nano) + const result = new DateTime( + localDateTime.year, + localDateTime.month, + localDateTime.day, + localDateTime.hour, + localDateTime.minute, + localDateTime.second, + localDateTime.nanosecond, + timeZoneOffsetSeconds, + null + ) + return convertIntegerPropsIfNeeded(result, disableLosslessIntegers, useBigInt) + } + }) +} + +function convertIntegerPropsIfNeeded (obj, disableLosslessIntegers, useBigInt) { + if (!disableLosslessIntegers && !useBigInt) { + return obj + } + + const convert = value => + useBigInt ? value.toBigInt() : value.toNumberOrInfinity() + + const clone = Object.create(Object.getPrototypeOf(obj)) + for (const prop in obj) { + if (Object.prototype.hasOwnProperty.call(obj, prop) === true) { + const value = obj[prop] + clone[prop] = isInt(value) ? convert(value) : value + } + } + Object.freeze(clone) + return clone +} + +export default { + createDateTimeWithZoneIdTransformer, + createDateTimeWithOffsetTransformer +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/create.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/create.js new file mode 100644 index 000000000..9a4549cd6 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/create.js @@ -0,0 +1,197 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { newError } from '../../core/index.ts' +import BoltProtocolV1 from './bolt-protocol-v1.js' +import BoltProtocolV2 from './bolt-protocol-v2.js' +import BoltProtocolV3 from './bolt-protocol-v3.js' +import BoltProtocolV4x0 from './bolt-protocol-v4x0.js' +import BoltProtocolV4x1 from './bolt-protocol-v4x1.js' +import BoltProtocolV4x2 from './bolt-protocol-v4x2.js' +import BoltProtocolV4x3 from './bolt-protocol-v4x3.js' +import BoltProtocolV4x4 from './bolt-protocol-v4x4.js' +import BoltProtocolV5x0 from './bolt-protocol-v5x0.js' +// eslint-disable-next-line no-unused-vars +import { Chunker, Dechunker } from '../channel/index.js' +import ResponseHandler from './response-handler.js' + +/** + * Creates a protocol with a given version + * + * @param {object} config + * @param {number} config.version The version of the protocol + * @param {channel} config.channel The channel + * @param {Chunker} config.chunker The chunker + * @param {Dechunker} config.dechunker The dechunker + * @param {Logger} config.log The logger + * @param {ResponseHandler~Observer} config.observer Observer + * @param {boolean} config.disableLosslessIntegers Disable the lossless integers + * @param {boolean} packstreamConfig.useBigInt if this connection should convert all received integers to native BigInt numbers. + * @param {boolean} config.serversideRouting It's using server side routing + */ +export default function create ({ + version, + chunker, + dechunker, + channel, + disableLosslessIntegers, + useBigInt, + serversideRouting, + server, // server info + log, + observer +} = {}) { + const createResponseHandler = protocol => { + const responseHandler = new ResponseHandler({ + transformMetadata: protocol.transformMetadata.bind(protocol), + log, + observer + }) + + // reset the error handler to just handle errors and forget about the handshake promise + channel.onerror = observer.onError.bind(observer) + + // Ok, protocol running. Simply forward all messages to the dechunker + channel.onmessage = buf => dechunker.write(buf) + + // setup dechunker to dechunk messages and forward them to the message handler + dechunker.onmessage = buf => { + try { + responseHandler.handleResponse(protocol.unpack(buf)) + } catch (e) { + return observer.onError(e) + } + } + + return responseHandler + } + + return createProtocol( + version, + server, + chunker, + { disableLosslessIntegers, useBigInt }, + serversideRouting, + createResponseHandler, + observer.onProtocolError.bind(observer), + log + ) +} + +function createProtocol ( + version, + server, + chunker, + packingConfig, + serversideRouting, + createResponseHandler, + onProtocolError, + log +) { + switch (version) { + case 1: + return new BoltProtocolV1( + server, + chunker, + packingConfig, + createResponseHandler, + log, + onProtocolError + ) + case 2: + return new BoltProtocolV2( + server, + chunker, + packingConfig, + createResponseHandler, + log, + onProtocolError + ) + case 3: + return new BoltProtocolV3( + server, + chunker, + packingConfig, + createResponseHandler, + log, + onProtocolError + ) + case 4.0: + return new BoltProtocolV4x0( + server, + chunker, + packingConfig, + createResponseHandler, + log, + onProtocolError + ) + case 4.1: + return new BoltProtocolV4x1( + server, + chunker, + packingConfig, + createResponseHandler, + log, + onProtocolError, + serversideRouting + ) + case 4.2: + return new BoltProtocolV4x2( + server, + chunker, + packingConfig, + createResponseHandler, + log, + onProtocolError, + serversideRouting + ) + case 4.3: + return new BoltProtocolV4x3( + server, + chunker, + packingConfig, + createResponseHandler, + log, + onProtocolError, + serversideRouting + ) + case 4.4: + return new BoltProtocolV4x4( + server, + chunker, + packingConfig, + createResponseHandler, + log, + onProtocolError, + serversideRouting + ) + case 5.0: + return new BoltProtocolV5x0( + 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 new file mode 100644 index 000000000..515458ee5 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/handshake.js @@ -0,0 +1,133 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { alloc } from '../channel/index.js' +import { newError } from '../../core/index.ts' + +const BOLT_MAGIC_PREAMBLE = 0x6060b017 + +function version (major, minor) { + return { + major, + minor + } +} + +function createHandshakeMessage (versions) { + if (versions.length > 4) { + throw newError('It should not have more than 4 versions of the protocol') + } + const handshakeBuffer = alloc(5 * 4) + + handshakeBuffer.writeInt32(BOLT_MAGIC_PREAMBLE) + + versions.forEach(version => { + if (version instanceof Array) { + const { major, minor } = version[0] + const { minor: minMinor } = version[1] + const range = minor - minMinor + handshakeBuffer.writeInt32((range << 16) | (minor << 8) | major) + } else { + const { major, minor } = version + handshakeBuffer.writeInt32((minor << 8) | major) + } + }) + + handshakeBuffer.reset() + + return handshakeBuffer +} + +function parseNegotiatedResponse (buffer) { + const h = [ + buffer.readUInt8(), + buffer.readUInt8(), + buffer.readUInt8(), + buffer.readUInt8() + ] + if (h[0] === 0x48 && h[1] === 0x54 && h[2] === 0x54 && h[3] === 0x50) { + throw newError( + 'Server responded HTTP. Make sure you are not trying to connect to the http endpoint ' + + '(HTTP defaults to port 7474 whereas BOLT defaults to port 7687)' + ) + } + return Number(h[3] + '.' + h[2]) +} + +/** + * @return {BaseBuffer} + * @private + */ +function newHandshakeBuffer () { + return createHandshakeMessage([ + version(5, 0), + [version(4, 4), version(4, 2)], + version(4, 1), + version(3, 0) + ]) +} + +/** + * This callback is displayed as a global member. + * @callback BufferConsumerCallback + * @param {buffer} buffer the remaining buffer + */ +/** + * @typedef HandshakeResult + * @property {number} protocolVersion The protocol version negotiated in the handshake + * @property {function(BufferConsumerCallback)} consumeRemainingBuffer A function to consume the remaining buffer if it exists + */ +/** + * Shake hands using the channel and return the protocol version + * + * @param {Channel} channel the channel use to shake hands + * @returns {Promise} Promise of protocol version and consumeRemainingBuffer + */ +export default function handshake (channel) { + return new Promise((resolve, reject) => { + const handshakeErrorHandler = error => { + reject(error) + } + + channel.onerror = handshakeErrorHandler.bind(this) + if (channel._error) { + handshakeErrorHandler(channel._error) + } + + channel.onmessage = buffer => { + try { + // read the response buffer and initialize the protocol + const protocolVersion = parseNegotiatedResponse(buffer) + + resolve({ + protocolVersion, + consumeRemainingBuffer: consumer => { + if (buffer.hasRemaining()) { + consumer(buffer.readSlice(buffer.remaining())) + } + } + }) + } catch (e) { + reject(e) + } + } + + channel.write(newHandshakeBuffer()) + }) +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/index.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/index.js new file mode 100644 index 000000000..ff230b5af --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/index.js @@ -0,0 +1,32 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 handshake from './handshake.js' +import create from './create.js' +import _BoltProtocol from './bolt-protocol-v4x3.js' +import _RawRoutingTable from './routing-table-raw.js' + +export * from './stream-observers.js' + +export const BoltProtocol = _BoltProtocol +export const RawRoutingTable = _RawRoutingTable + +export default { + handshake, + create +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/request-message.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/request-message.js new file mode 100644 index 000000000..0ceb430c0 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/request-message.js @@ -0,0 +1,329 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { int, internal, json } from '../../core/index.ts' + +const { + constants: { ACCESS_MODE_READ, FETCH_ALL }, + util: { assertString } +} = internal + +/* eslint-disable no-unused-vars */ +// Signature bytes for each request message type +const INIT = 0x01 // 0000 0001 // INIT +const ACK_FAILURE = 0x0e // 0000 1110 // ACK_FAILURE - unused +const RESET = 0x0f // 0000 1111 // RESET +const RUN = 0x10 // 0001 0000 // RUN +const DISCARD_ALL = 0x2f // 0010 1111 // DISCARD_ALL - unused +const PULL_ALL = 0x3f // 0011 1111 // PULL_ALL + +const HELLO = 0x01 // 0000 0001 // HELLO +const GOODBYE = 0x02 // 0000 0010 // GOODBYE +const BEGIN = 0x11 // 0001 0001 // BEGIN +const COMMIT = 0x12 // 0001 0010 // COMMIT +const ROLLBACK = 0x13 // 0001 0011 // ROLLBACK +const ROUTE = 0x66 // 0110 0110 // ROUTE + +const DISCARD = 0x2f // 0010 1111 // DISCARD +const PULL = 0x3f // 0011 1111 // PULL + +const READ_MODE = 'r' +/* eslint-enable no-unused-vars */ + +const NO_STATEMENT_ID = -1 + +export default class RequestMessage { + constructor (signature, fields, toString) { + this.signature = signature + this.fields = fields + this.toString = toString + } + + /** + * Create a new INIT message. + * @param {string} clientName the client name. + * @param {Object} authToken the authentication token. + * @return {RequestMessage} new INIT message. + */ + static init (clientName, authToken) { + return new RequestMessage( + INIT, + [clientName, authToken], + () => `INIT ${clientName} {...}` + ) + } + + /** + * Create a new RUN message. + * @param {string} query the cypher query. + * @param {Object} parameters the query parameters. + * @return {RequestMessage} new RUN message. + */ + static run (query, parameters) { + return new RequestMessage( + RUN, + [query, parameters], + () => `RUN ${query} ${json.stringify(parameters)}` + ) + } + + /** + * Get a PULL_ALL message. + * @return {RequestMessage} the PULL_ALL message. + */ + static pullAll () { + return PULL_ALL_MESSAGE + } + + /** + * Get a RESET message. + * @return {RequestMessage} the RESET message. + */ + static reset () { + return RESET_MESSAGE + } + + /** + * Create a new HELLO message. + * @param {string} userAgent the user agent. + * @param {Object} authToken the authentication token. + * @param {Object} optional server side routing, set to routing context to turn on server side routing (> 4.1) + * @return {RequestMessage} new HELLO message. + */ + static hello (userAgent, authToken, routing = null, patchs = null) { + const metadata = Object.assign({ user_agent: userAgent }, authToken) + if (routing) { + metadata.routing = routing + } + if (patchs) { + metadata.patch_bolt = patchs + } + return new RequestMessage( + HELLO, + [metadata], + () => `HELLO {user_agent: '${userAgent}', ...}` + ) + } + + /** + * Create a new BEGIN message. + * @param {Bookmarks} bookmarks the bookmarks. + * @param {TxConfig} txConfig the configuration. + * @param {string} database the database name. + * @param {string} mode the access mode. + * @param {string} impersonatedUser the impersonated user. + * @return {RequestMessage} new BEGIN message. + */ + static begin ({ bookmarks, txConfig, database, mode, impersonatedUser } = {}) { + const metadata = buildTxMetadata(bookmarks, txConfig, database, mode, impersonatedUser) + return new RequestMessage( + BEGIN, + [metadata], + () => `BEGIN ${json.stringify(metadata)}` + ) + } + + /** + * Get a COMMIT message. + * @return {RequestMessage} the COMMIT message. + */ + static commit () { + return COMMIT_MESSAGE + } + + /** + * Get a ROLLBACK message. + * @return {RequestMessage} the ROLLBACK message. + */ + static rollback () { + return ROLLBACK_MESSAGE + } + + /** + * Create a new RUN message with additional metadata. + * @param {string} query the cypher query. + * @param {Object} parameters the query parameters. + * @param {Bookmarks} bookmarks the bookmarks. + * @param {TxConfig} txConfig the configuration. + * @param {string} database the database name. + * @param {string} mode the access mode. + * @param {string} impersonatedUser the impersonated user. + * @return {RequestMessage} new RUN message with additional metadata. + */ + static runWithMetadata ( + query, + parameters, + { bookmarks, txConfig, database, mode, impersonatedUser } = {} + ) { + const metadata = buildTxMetadata(bookmarks, txConfig, database, mode, impersonatedUser) + return new RequestMessage( + RUN, + [query, parameters, metadata], + () => + `RUN ${query} ${json.stringify(parameters)} ${json.stringify(metadata)}` + ) + } + + /** + * Get a GOODBYE message. + * @return {RequestMessage} the GOODBYE message. + */ + static goodbye () { + return GOODBYE_MESSAGE + } + + /** + * Generates a new PULL message with additional metadata. + * @param {Integer|number} stmtId + * @param {Integer|number} n + * @return {RequestMessage} the PULL message. + */ + static pull ({ stmtId = NO_STATEMENT_ID, n = FETCH_ALL } = {}) { + const metadata = buildStreamMetadata( + stmtId === null || stmtId === undefined ? NO_STATEMENT_ID : stmtId, + n || FETCH_ALL + ) + return new RequestMessage( + PULL, + [metadata], + () => `PULL ${json.stringify(metadata)}` + ) + } + + /** + * Generates a new DISCARD message with additional metadata. + * @param {Integer|number} stmtId + * @param {Integer|number} n + * @return {RequestMessage} the PULL message. + */ + static discard ({ stmtId = NO_STATEMENT_ID, n = FETCH_ALL } = {}) { + const metadata = buildStreamMetadata( + stmtId === null || stmtId === undefined ? NO_STATEMENT_ID : stmtId, + n || FETCH_ALL + ) + return new RequestMessage( + DISCARD, + [metadata], + () => `DISCARD ${json.stringify(metadata)}` + ) + } + + /** + * Generate the ROUTE message, this message is used to fetch the routing table from the server + * + * @param {object} routingContext The routing context used to define the routing table. Multi-datacenter deployments is one of its use cases + * @param {string[]} bookmarks The list of the bookmarks should be used + * @param {string} databaseName The name of the database to get the routing table for. + * @return {RequestMessage} the ROUTE message. + */ + static route (routingContext = {}, bookmarks = [], databaseName = null) { + return new RequestMessage( + ROUTE, + [routingContext, bookmarks, databaseName], + () => + `ROUTE ${json.stringify(routingContext)} ${json.stringify( + bookmarks + )} ${databaseName}` + ) + } + + /** + * Generate the ROUTE message, this message is used to fetch the routing table from the server + * + * @param {object} routingContext The routing context used to define the routing table. Multi-datacenter deployments is one of its use cases + * @param {string[]} bookmarks The list of the bookmarks should be used + * @param {object} databaseContext The context inforamtion of the database to get the routing table for. + * @param {string} databaseContext.databaseName The name of the database to get the routing table. + * @param {string} databaseContext.impersonatedUser The name of the user to impersonation when getting the routing table. + * @return {RequestMessage} the ROUTE message. + */ + static routeV4x4 (routingContext = {}, bookmarks = [], databaseContext = {}) { + const dbContext = {} + + if (databaseContext.databaseName) { + dbContext.db = databaseContext.databaseName + } + + if (databaseContext.impersonatedUser) { + dbContext.imp_user = databaseContext.impersonatedUser + } + + return new RequestMessage( + ROUTE, + [routingContext, bookmarks, dbContext], + () => + `ROUTE ${json.stringify(routingContext)} ${json.stringify( + bookmarks + )} ${json.stringify(dbContext)}` + ) + } +} + +/** + * Create an object that represent transaction metadata. + * @param {Bookmarks} bookmarks the bookmarks. + * @param {TxConfig} txConfig the configuration. + * @param {string} database the database name. + * @param {string} mode the access mode. + * @param {string} impersonatedUser the impersonated user mode. + * @return {Object} a metadata object. + */ +function buildTxMetadata (bookmarks, txConfig, database, mode, impersonatedUser) { + const metadata = {} + if (!bookmarks.isEmpty()) { + metadata.bookmarks = bookmarks.values() + } + if (txConfig.timeout !== null) { + metadata.tx_timeout = txConfig.timeout + } + if (txConfig.metadata) { + metadata.tx_metadata = txConfig.metadata + } + if (database) { + metadata.db = assertString(database, 'database') + } + if (impersonatedUser) { + metadata.imp_user = assertString(impersonatedUser, 'impersonatedUser') + } + if (mode === ACCESS_MODE_READ) { + metadata.mode = READ_MODE + } + return metadata +} + +/** + * Create an object that represents streaming metadata. + * @param {Integer|number} stmtId The query id to stream its results. + * @param {Integer|number} n The number of records to stream. + * @returns {Object} a metadata object. + */ +function buildStreamMetadata (stmtId, n) { + const metadata = { n: int(n) } + if (stmtId !== NO_STATEMENT_ID) { + metadata.qid = int(stmtId) + } + return metadata +} + +// constants for messages that never change +const PULL_ALL_MESSAGE = new RequestMessage(PULL_ALL, [], () => 'PULL_ALL') +const RESET_MESSAGE = new RequestMessage(RESET, [], () => 'RESET') +const COMMIT_MESSAGE = new RequestMessage(COMMIT, [], () => 'COMMIT') +const ROLLBACK_MESSAGE = new RequestMessage(ROLLBACK, [], () => 'ROLLBACK') +const GOODBYE_MESSAGE = new RequestMessage(GOODBYE, [], () => 'GOODBYE') 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 new file mode 100644 index 000000000..d4aa8f4a5 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/response-handler.js @@ -0,0 +1,215 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { newError, json } from '../../core/index.ts' + +// Signature bytes for each response message type +const SUCCESS = 0x70 // 0111 0000 // SUCCESS +const RECORD = 0x71 // 0111 0001 // RECORD +const IGNORED = 0x7e // 0111 1110 // IGNORED +const FAILURE = 0x7f // 0111 1111 // FAILURE + +function NO_OP () {} + +function NO_OP_IDENTITY (subject) { + return subject +} + +const NO_OP_OBSERVER = { + onNext: NO_OP, + onCompleted: NO_OP, + onError: NO_OP +} + +/** + * Treat the protocol responses and notify the observers + */ +export default class ResponseHandler { + /** + * Called when something went wrong with the connectio + * @callback ResponseHandler~Observer~OnErrorApplyTransformation + * @param {any} error The error + * @returns {any} The new error + */ + /** + * Called when something went wrong with the connectio + * @callback ResponseHandler~Observer~OnError + * @param {any} error The error + */ + /** + * Called when something went wrong with the connectio + * @callback ResponseHandler~MetadataTransformer + * @param {any} metadata The metadata got onSuccess + * @returns {any} The transformed metadata + */ + /** + * @typedef {Object} ResponseHandler~Observer + * @property {ResponseHandler~Observer~OnError} onError Invoke when a connection error occurs + * @property {ResponseHandler~Observer~OnError} onFailure Invoke when a protocol failure occurs + * @property {ResponseHandler~Observer~OnErrorApplyTransformation} onErrorApplyTransformation Invoke just after the failure occurs, + * before notify to respective observer. This method should transform the failure reason to the approprited one. + */ + /** + * Constructor + * @param {Object} param The params + * @param {ResponseHandler~MetadataTransformer} transformMetadata Transform metadata when the SUCCESS is received. + * @param {Channel} channel The channel used to exchange messages + * @param {Logger} log The logger + * @param {ResponseHandler~Observer} observer Object which will be notified about errors + */ + constructor ({ transformMetadata, log, observer } = {}) { + this._pendingObservers = [] + this._log = log + this._transformMetadata = transformMetadata || NO_OP_IDENTITY + this._observer = Object.assign( + { + onPendingObserversChange: NO_OP, + onError: NO_OP, + onFailure: NO_OP, + onErrorApplyTransformation: NO_OP_IDENTITY + }, + observer + ) + } + + get currentFailure () { + return this._currentFailure + } + + handleResponse (msg) { + const payload = msg.fields[0] + + switch (msg.signature) { + case RECORD: + if (this._log.isDebugEnabled()) { + this._log.debug(`S: RECORD ${json.stringify(msg)}`) + } + this._currentObserver.onNext(payload) + break + case SUCCESS: + if (this._log.isDebugEnabled()) { + this._log.debug(`S: SUCCESS ${json.stringify(msg)}`) + } + try { + const metadata = this._transformMetadata(payload) + this._currentObserver.onCompleted(metadata) + } finally { + this._updateCurrentObserver() + } + break + case FAILURE: + if (this._log.isDebugEnabled()) { + 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._currentObserver.onError(this._currentFailure) + } finally { + this._updateCurrentObserver() + // Things are now broken. Pending observers will get FAILURE messages routed until we are done handling this failure. + this._observer.onFailure(this._currentFailure) + } + break + case IGNORED: + if (this._log.isDebugEnabled()) { + this._log.debug(`S: IGNORED ${json.stringify(msg)}`) + } + try { + if (this._currentFailure && this._currentObserver.onError) { + this._currentObserver.onError(this._currentFailure) + } else if (this._currentObserver.onError) { + this._currentObserver.onError( + newError('Ignored either because of an error or RESET') + ) + } + } finally { + this._updateCurrentObserver() + } + break + default: + this._observer.onError( + newError('Unknown Bolt protocol message: ' + msg) + ) + } + } + + /* + * Pop next pending observer form the list of observers and make it current observer. + * @protected + */ + _updateCurrentObserver () { + this._currentObserver = this._pendingObservers.shift() + this._observer.onPendingObserversChange(this._pendingObservers.length) + } + + _queueObserver (observer) { + observer = observer || NO_OP_OBSERVER + observer.onCompleted = observer.onCompleted || NO_OP + observer.onError = observer.onError || NO_OP + observer.onNext = observer.onNext || NO_OP + if (this._currentObserver === undefined) { + this._currentObserver = observer + } else { + this._pendingObservers.push(observer) + } + this._observer.onPendingObserversChange(this._pendingObservers.length) + return true + } + + _notifyErrorToObservers (error) { + if (this._currentObserver && this._currentObserver.onError) { + this._currentObserver.onError(error) + } + while (this._pendingObservers.length > 0) { + const observer = this._pendingObservers.shift() + if (observer && observer.onError) { + observer.onError(error) + } + } + } + + hasOngoingObservableRequests () { + return this._currentObserver != null || this._pendingObservers.length > 0 + } + + _resetFailure () { + this._currentFailure = null + } +} + +/** + * Standardize error classification that are different between 5.x and previous versions. + * + * The transient error were clean-up for being retrieable and because of this + * `Terminated` and `LockClientStopped` were reclassified as `ClientError`. + * + * @param {string} code + * @returns {string} the standardized error code + */ +function _standardizeCode (code) { + if (code === 'Neo.TransientError.Transaction.Terminated') { + return 'Neo.ClientError.Transaction.Terminated' + } else if (code === 'Neo.TransientError.Transaction.LockClientStopped') { + return 'Neo.ClientError.Transaction.LockClientStopped' + } + return code +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/routing-table-raw.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/routing-table-raw.js new file mode 100644 index 000000000..39e03d4f3 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/routing-table-raw.js @@ -0,0 +1,158 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ +// eslint-disable-next-line no-unused-vars +import Record from '../../core/index.ts' + +/** + * Represente the raw version of the routing table + */ +export default class RawRoutingTable { + /** + * Constructs the raw routing table for Record based result + * @param {Record} record The record which will be used get the raw routing table + * @returns {RawRoutingTable} The raw routing table + */ + static ofRecord (record) { + if (record === null) { + return RawRoutingTable.ofNull() + } + return new RecordRawRoutingTable(record) + } + + /** + * Constructs the raw routing table for Success result for a Routing Message + * @param {object} response The result + * @returns {RawRoutingTable} The raw routing table + */ + static ofMessageResponse (response) { + if (response === null) { + return RawRoutingTable.ofNull() + } + return new ResponseRawRoutingTable(response) + } + + /** + * Construct the raw routing table of a null response + * + * @returns {RawRoutingTable} the raw routing table + */ + static ofNull () { + return new NullRawRoutingTable() + } + + /** + * Get raw ttl + * + * @returns {number|string} ttl Time to live + */ + get ttl () { + throw new Error('Not implemented') + } + + /** + * Get raw db + * + * @returns {string?} The database name + */ + get db () { + throw new Error('Not implemented') + } + + /** + * + * @typedef {Object} ServerRole + * @property {string} role the role of the address on the cluster + * @property {string[]} addresses the address within the role + * + * @return {ServerRole[]} list of servers addresses + */ + get servers () { + throw new Error('Not implemented') + } + + /** + * Indicates the result is null + * + * @returns {boolean} Is null + */ + get isNull () { + throw new Error('Not implemented') + } +} + +/** + * Get the raw routing table information from route message response + */ +class ResponseRawRoutingTable extends RawRoutingTable { + constructor (response) { + super() + this._response = response + } + + get ttl () { + return this._response.rt.ttl + } + + get servers () { + return this._response.rt.servers + } + + get db () { + return this._response.rt.db + } + + get isNull () { + return this._response === null + } +} + +/** + * Null routing table + */ +class NullRawRoutingTable extends RawRoutingTable { + get isNull () { + return true + } +} + +/** + * Get the raw routing table information from the record + */ +class RecordRawRoutingTable extends RawRoutingTable { + constructor (record) { + super() + this._record = record + } + + get ttl () { + return this._record.get('ttl') + } + + get servers () { + return this._record.get('servers') + } + + get db () { + return this._record.has('db') ? this._record.get('db') : null + } + + get isNull () { + return this._record === null + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/stream-observers.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/stream-observers.js new file mode 100644 index 000000000..d8ff0b88d --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/stream-observers.js @@ -0,0 +1,679 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { + newError, + error, + // eslint-disable-next-line no-unused-vars + Integer, + Record, + json, + internal +} from '../../core/index.ts' +import RawRoutingTable from './routing-table-raw.js' + +const { + constants: { FETCH_ALL } +} = internal +const { PROTOCOL_ERROR } = error +class StreamObserver { + onNext (rawRecord) {} + + onError (_error) {} + + onCompleted (meta) {} +} + +/** + * Handles a RUN/PULL_ALL, or RUN/DISCARD_ALL requests, maps the responses + * in a way that a user-provided observer can see these as a clean Stream + * of records. + * This class will queue up incoming messages until a user-provided observer + * for the incoming stream is registered. Thus, we keep fields around + * for tracking head/records/tail. These are only used if there is no + * observer registered. + * @access private + */ +class ResultStreamObserver extends StreamObserver { + /** + * + * @param {Object} param + * @param {Object} param.server + * @param {boolean} param.reactive + * @param {function(stmtId: number|Integer, n: number|Integer, observer: StreamObserver)} param.moreFunction - + * @param {function(stmtId: number|Integer, observer: StreamObserver)} param.discardFunction - + * @param {number|Integer} param.fetchSize - + * @param {function(err: Error): Promise|void} param.beforeError - + * @param {function(err: Error): Promise|void} param.afterError - + * @param {function(keys: string[]): Promise|void} param.beforeKeys - + * @param {function(keys: string[]): Promise|void} param.afterKeys - + * @param {function(metadata: Object): Promise|void} param.beforeComplete - + * @param {function(metadata: Object): Promise|void} param.afterComplete - + */ + constructor ({ + reactive = false, + moreFunction, + discardFunction, + fetchSize = FETCH_ALL, + beforeError, + afterError, + beforeKeys, + afterKeys, + beforeComplete, + afterComplete, + server, + highRecordWatermark = Number.MAX_VALUE, + lowRecordWatermark = Number.MAX_VALUE + } = {}) { + super() + + this._fieldKeys = null + this._fieldLookup = null + this._head = null + this._queuedRecords = [] + this._tail = null + this._error = null + this._observers = [] + this._meta = {} + this._server = server + + this._beforeError = beforeError + this._afterError = afterError + this._beforeKeys = beforeKeys + this._afterKeys = afterKeys + this._beforeComplete = beforeComplete + this._afterComplete = afterComplete + + this._queryId = null + this._moreFunction = moreFunction + this._discardFunction = discardFunction + this._discard = false + this._fetchSize = fetchSize + this._lowRecordWatermark = lowRecordWatermark + this._highRecordWatermark = highRecordWatermark + this._setState(reactive ? _states.READY : _states.READY_STREAMING) + this._setupAutoPull() + this._paused = false + } + + /** + * Pause the record consuming + * + * This function will supend the record consuming. It will not cancel the stream and the already + * requested records will be sent to the subscriber. + */ + pause () { + this._paused = true + } + + /** + * Resume the record consuming + * + * This function will resume the record consuming fetching more records from the server. + */ + resume () { + this._paused = false + this._setupAutoPull(true) + this._state.pull(this) + } + + /** + * Will be called on every record that comes in and transform a raw record + * to a Object. If user-provided observer is present, pass transformed record + * to it's onNext method, otherwise, push to record que. + * @param {Array} rawRecord - An array with the raw record + */ + onNext (rawRecord) { + const record = new Record(this._fieldKeys, rawRecord, this._fieldLookup) + if (this._observers.some(o => o.onNext)) { + this._observers.forEach(o => { + if (o.onNext) { + o.onNext(record) + } + }) + } else { + this._queuedRecords.push(record) + if (this._queuedRecords.length > this._highRecordWatermark) { + this._autoPull = false + } + } + } + + onCompleted (meta) { + this._state.onSuccess(this, meta) + } + + /** + * Will be called on errors. + * If user-provided observer is present, pass the error + * to it's onError method, otherwise set instance variable _error. + * @param {Object} error - An error object + */ + onError (error) { + this._state.onError(this, error) + } + + /** + * Cancel pending record stream + */ + cancel () { + this._discard = true + } + + /** + * Stream observer defaults to handling responses for two messages: RUN + PULL_ALL or RUN + DISCARD_ALL. + * Response for RUN initializes query keys. Response for PULL_ALL / DISCARD_ALL exposes the result stream. + * + * However, some operations can be represented as a single message which receives full metadata in a single response. + * For example, operations to begin, commit and rollback an explicit transaction use two messages in Bolt V1 but a single message in Bolt V3. + * Messages are `RUN "BEGIN" {}` + `PULL_ALL` in Bolt V1 and `BEGIN` in Bolt V3. + * + * This function prepares the observer to only handle a single response message. + */ + prepareToHandleSingleResponse () { + this._head = [] + this._fieldKeys = [] + this._setState(_states.STREAMING) + } + + /** + * Mark this observer as if it has completed with no metadata. + */ + markCompleted () { + this._head = [] + this._fieldKeys = [] + this._tail = {} + this._setState(_states.SUCCEEDED) + } + + /** + * Subscribe to events with provided observer. + * @param {Object} observer - Observer object + * @param {function(keys: String[])} observer.onKeys - Handle stream header, field keys. + * @param {function(record: Object)} observer.onNext - Handle records, one by one. + * @param {function(metadata: Object)} observer.onCompleted - Handle stream tail, the metadata. + * @param {function(error: Object)} observer.onError - Handle errors, should always be provided. + */ + subscribe (observer) { + if (this._head && observer.onKeys) { + observer.onKeys(this._head) + } + if (this._queuedRecords.length > 0 && observer.onNext) { + for (let i = 0; i < this._queuedRecords.length; i++) { + observer.onNext(this._queuedRecords[i]) + if (this._queuedRecords.length - i - 1 <= this._lowRecordWatermark) { + this._autoPull = true + if (this._state === _states.READY) { + this._handleStreaming() + } + } + } + } + if (this._tail && observer.onCompleted) { + observer.onCompleted(this._tail) + } + if (this._error) { + observer.onError(this._error) + } + this._observers.push(observer) + + if (this._state === _states.READY) { + this._handleStreaming() + } + } + + _handleHasMore (meta) { + // We've consumed current batch and server notified us that there're more + // records to stream. Let's invoke more or discard function based on whether + // the user wants to discard streaming or not + this._setState(_states.READY) // we've done streaming + this._handleStreaming() + delete meta.has_more + } + + _handlePullSuccess (meta) { + const completionMetadata = Object.assign( + this._server ? { server: this._server } : {}, + this._meta, + meta + ) + + if (![undefined, null, 'r', 'w', 'rw', 's'].includes(completionMetadata.type)) { + this.onError( + newError( + `Server returned invalid query type. Expected one of [undefined, null, "r", "w", "rw", "s"] but got '${completionMetadata.type}'`, + PROTOCOL_ERROR)) + return + } + + this._setState(_states.SUCCEEDED) + + let beforeHandlerResult = null + if (this._beforeComplete) { + beforeHandlerResult = this._beforeComplete(completionMetadata) + } + + const continuation = () => { + // End of stream + this._tail = completionMetadata + + if (this._observers.some(o => o.onCompleted)) { + this._observers.forEach(o => { + if (o.onCompleted) { + o.onCompleted(completionMetadata) + } + }) + } + + if (this._afterComplete) { + this._afterComplete(completionMetadata) + } + } + + if (beforeHandlerResult) { + Promise.resolve(beforeHandlerResult).then(() => continuation()) + } else { + continuation() + } + } + + _handleRunSuccess (meta, afterSuccess) { + if (this._fieldKeys === null) { + // Stream header, build a name->index field lookup table + // to be used by records. This is an optimization to make it + // faster to look up fields in a record by name, rather than by index. + // Since the records we get back via Bolt are just arrays of values. + this._fieldKeys = [] + this._fieldLookup = {} + if (meta.fields && meta.fields.length > 0) { + this._fieldKeys = meta.fields + for (let i = 0; i < meta.fields.length; i++) { + this._fieldLookup[meta.fields[i]] = i + } + + // remove fields key from metadata object + delete meta.fields + } + + // Extract server generated query id for use in requestMore and discard + // functions + if (meta.qid !== null && meta.qid !== undefined) { + this._queryId = meta.qid + + // remove qid from metadata object + delete meta.qid + } + + this._storeMetadataForCompletion(meta) + + let beforeHandlerResult = null + if (this._beforeKeys) { + beforeHandlerResult = this._beforeKeys(this._fieldKeys) + } + + const continuation = () => { + this._head = this._fieldKeys + + if (this._observers.some(o => o.onKeys)) { + this._observers.forEach(o => { + if (o.onKeys) { + o.onKeys(this._fieldKeys) + } + }) + } + + if (this._afterKeys) { + this._afterKeys(this._fieldKeys) + } + + afterSuccess() + } + + if (beforeHandlerResult) { + Promise.resolve(beforeHandlerResult).then(() => continuation()) + } else { + continuation() + } + } + } + + _handleError (error) { + this._setState(_states.FAILED) + this._error = error + + let beforeHandlerResult = null + if (this._beforeError) { + beforeHandlerResult = this._beforeError(error) + } + + const continuation = () => { + if (this._observers.some(o => o.onError)) { + this._observers.forEach(o => { + if (o.onError) { + o.onError(error) + } + }) + } + + if (this._afterError) { + this._afterError(error) + } + } + + if (beforeHandlerResult) { + Promise.resolve(beforeHandlerResult).then(() => continuation()) + } else { + continuation() + } + } + + _handleStreaming () { + if (this._head && this._observers.some(o => o.onNext || o.onCompleted)) { + if (!this._paused && (this._discard || this._autoPull)) { + this._more() + } + } + } + + _more () { + if (this._discard) { + this._discardFunction(this._queryId, this) + } else { + this._moreFunction(this._queryId, this._fetchSize, this) + } + this._setState(_states.STREAMING) + } + + _storeMetadataForCompletion (meta) { + const keys = Object.keys(meta) + let index = keys.length + let key = '' + + while (index--) { + key = keys[index] + this._meta[key] = meta[key] + } + } + + _setState (state) { + this._state = state + } + + _setupAutoPull () { + this._autoPull = true + } +} + +class LoginObserver extends StreamObserver { + /** + * + * @param {Object} param - + * @param {function(err: Error)} param.onError + * @param {function(metadata)} param.onCompleted + */ + constructor ({ onError, onCompleted } = {}) { + super() + this._onError = onError + this._onCompleted = onCompleted + } + + onNext (record) { + this.onError( + newError('Received RECORD when initializing ' + json.stringify(record)) + ) + } + + onError (error) { + if (this._onError) { + this._onError(error) + } + } + + onCompleted (metadata) { + if (this._onCompleted) { + this._onCompleted(metadata) + } + } +} + +class ResetObserver extends StreamObserver { + /** + * + * @param {Object} param - + * @param {function(err: String)} param.onProtocolError + * @param {function(err: Error)} param.onError + * @param {function(metadata)} param.onComplete + */ + constructor ({ onProtocolError, onError, onComplete } = {}) { + super() + + this._onProtocolError = onProtocolError + this._onError = onError + this._onComplete = onComplete + } + + onNext (record) { + this.onError( + newError( + 'Received RECORD when resetting: received record is: ' + + json.stringify(record), + PROTOCOL_ERROR + ) + ) + } + + onError (error) { + if (error.code === PROTOCOL_ERROR && this._onProtocolError) { + this._onProtocolError(error.message) + } + + if (this._onError) { + this._onError(error) + } + } + + onCompleted (metadata) { + if (this._onComplete) { + this._onComplete(metadata) + } + } +} + +class FailedObserver extends ResultStreamObserver { + constructor ({ error, onError }) { + super({ beforeError: onError }) + + this.onError(error) + } +} + +class CompletedObserver extends ResultStreamObserver { + constructor () { + super() + super.markCompleted() + } +} + +class ProcedureRouteObserver extends StreamObserver { + constructor ({ resultObserver, onProtocolError, onError, onCompleted }) { + super() + + this._resultObserver = resultObserver + this._onError = onError + this._onCompleted = onCompleted + this._records = [] + this._onProtocolError = onProtocolError + resultObserver.subscribe(this) + } + + onNext (record) { + this._records.push(record) + } + + onError (error) { + if (error.code === PROTOCOL_ERROR && this._onProtocolError) { + this._onProtocolError(error.message) + } + + if (this._onError) { + this._onError(error) + } + } + + onCompleted () { + if (this._records !== null && this._records.length !== 1) { + this.onError( + newError( + 'Illegal response from router. Received ' + + this._records.length + + ' records but expected only one.\n' + + json.stringify(this._records), + PROTOCOL_ERROR + ) + ) + return + } + + if (this._onCompleted) { + this._onCompleted(RawRoutingTable.ofRecord(this._records[0])) + } + } +} + +class RouteObserver extends StreamObserver { + /** + * + * @param {Object} param - + * @param {function(err: String)} param.onProtocolError + * @param {function(err: Error)} param.onError + * @param {function(RawRoutingTable)} param.onCompleted + */ + constructor ({ onProtocolError, onError, onCompleted } = {}) { + super() + + this._onProtocolError = onProtocolError + this._onError = onError + this._onCompleted = onCompleted + } + + onNext (record) { + this.onError( + newError( + 'Received RECORD when resetting: received record is: ' + + json.stringify(record), + PROTOCOL_ERROR + ) + ) + } + + onError (error) { + if (error.code === PROTOCOL_ERROR && this._onProtocolError) { + this._onProtocolError(error.message) + } + + if (this._onError) { + this._onError(error) + } + } + + onCompleted (metadata) { + if (this._onCompleted) { + this._onCompleted(RawRoutingTable.ofMessageResponse(metadata)) + } + } +} + +const _states = { + READY_STREAMING: { + // async start state + onSuccess: (streamObserver, meta) => { + streamObserver._handleRunSuccess( + meta, + () => { + streamObserver._setState(_states.STREAMING) + } // after run succeeded, async directly move to streaming + // state + ) + }, + onError: (streamObserver, error) => { + streamObserver._handleError(error) + }, + name: () => { + return 'READY_STREAMING' + }, + pull: () => {} + }, + READY: { + // reactive start state + onSuccess: (streamObserver, meta) => { + streamObserver._handleRunSuccess( + meta, + () => streamObserver._handleStreaming() // after run succeeded received, reactive shall start pulling + ) + }, + onError: (streamObserver, error) => { + streamObserver._handleError(error) + }, + name: () => { + return 'READY' + }, + pull: streamObserver => streamObserver._more() + }, + STREAMING: { + onSuccess: (streamObserver, meta) => { + if (meta.has_more) { + streamObserver._handleHasMore(meta) + } else { + streamObserver._handlePullSuccess(meta) + } + }, + onError: (streamObserver, error) => { + streamObserver._handleError(error) + }, + name: () => { + return 'STREAMING' + }, + pull: () => {} + }, + FAILED: { + onError: _error => { + // more errors are ignored + }, + name: () => { + return 'FAILED' + }, + pull: () => {} + }, + SUCCEEDED: { + name: () => { + return 'SUCCEEDED' + }, + pull: () => {} + } +} + +export { + StreamObserver, + ResultStreamObserver, + LoginObserver, + ResetObserver, + FailedObserver, + CompletedObserver, + RouteObserver, + ProcedureRouteObserver +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/temporal-factory.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/temporal-factory.js new file mode 100644 index 000000000..063e57627 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/temporal-factory.js @@ -0,0 +1,144 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { + int, + Date, + LocalDateTime, + LocalTime, + internal +} from '../../core/index.ts' +const { + temporalUtil: { + DAYS_0000_TO_1970, + DAYS_PER_400_YEAR_CYCLE, + NANOS_PER_HOUR, + NANOS_PER_MINUTE, + NANOS_PER_SECOND, + SECONDS_PER_DAY, + floorDiv, + floorMod + } +} = internal + +/** + * Converts given epoch day to a local date. + * @param {Integer|number|string} epochDay the epoch day to convert. + * @return {Date} the date representing the epoch day in years, months and days. + */ +export function epochDayToDate (epochDay) { + epochDay = int(epochDay) + + let zeroDay = epochDay.add(DAYS_0000_TO_1970).subtract(60) + let adjust = int(0) + if (zeroDay.lessThan(0)) { + const adjustCycles = zeroDay + .add(1) + .div(DAYS_PER_400_YEAR_CYCLE) + .subtract(1) + adjust = adjustCycles.multiply(400) + zeroDay = zeroDay.add(adjustCycles.multiply(-DAYS_PER_400_YEAR_CYCLE)) + } + let year = zeroDay + .multiply(400) + .add(591) + .div(DAYS_PER_400_YEAR_CYCLE) + let dayOfYearEst = zeroDay.subtract( + year + .multiply(365) + .add(year.div(4)) + .subtract(year.div(100)) + .add(year.div(400)) + ) + if (dayOfYearEst.lessThan(0)) { + year = year.subtract(1) + dayOfYearEst = zeroDay.subtract( + year + .multiply(365) + .add(year.div(4)) + .subtract(year.div(100)) + .add(year.div(400)) + ) + } + year = year.add(adjust) + const marchDayOfYear = dayOfYearEst + + const marchMonth = marchDayOfYear + .multiply(5) + .add(2) + .div(153) + const month = marchMonth + .add(2) + .modulo(12) + .add(1) + const day = marchDayOfYear + .subtract( + marchMonth + .multiply(306) + .add(5) + .div(10) + ) + .add(1) + year = year.add(marchMonth.div(10)) + + return new Date(year, month, day) +} + +/** + * Converts nanoseconds of the day into local time. + * @param {Integer|number|string} nanoOfDay the nanoseconds of the day to convert. + * @return {LocalTime} the local time representing given nanoseconds of the day. + */ +export function nanoOfDayToLocalTime (nanoOfDay) { + nanoOfDay = int(nanoOfDay) + + const hour = nanoOfDay.div(NANOS_PER_HOUR) + nanoOfDay = nanoOfDay.subtract(hour.multiply(NANOS_PER_HOUR)) + + const minute = nanoOfDay.div(NANOS_PER_MINUTE) + nanoOfDay = nanoOfDay.subtract(minute.multiply(NANOS_PER_MINUTE)) + + const second = nanoOfDay.div(NANOS_PER_SECOND) + const nanosecond = nanoOfDay.subtract(second.multiply(NANOS_PER_SECOND)) + + return new LocalTime(hour, minute, second, nanosecond) +} + +/** + * Converts given epoch second and nanosecond adjustment into a local date time object. + * @param {Integer|number|string} epochSecond the epoch second to use. + * @param {Integer|number|string} nano the nanosecond to use. + * @return {LocalDateTime} the local date time representing given epoch second and nano. + */ +export function epochSecondAndNanoToLocalDateTime (epochSecond, nano) { + const epochDay = floorDiv(epochSecond, SECONDS_PER_DAY) + const secondsOfDay = floorMod(epochSecond, SECONDS_PER_DAY) + const nanoOfDay = secondsOfDay.multiply(NANOS_PER_SECOND).add(nano) + + const localDate = epochDayToDate(epochDay) + const localTime = nanoOfDayToLocalTime(nanoOfDay) + return new LocalDateTime( + localDate.year, + localDate.month, + localDate.day, + localTime.hour, + localTime.minute, + localTime.second, + localTime.nanosecond + ) +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/bolt/transformer.js b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/transformer.js new file mode 100644 index 000000000..4424e6d82 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/bolt/transformer.js @@ -0,0 +1,130 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { structure } from '../packstream/index.js' +import { internal } from '../../core/index.ts' + +const { objectUtil } = internal + +/** + * Class responsible for applying the expected {@link TypeTransformer} to + * transform the driver types from and to {@link struct.Structure} + */ +export default class Transformer { + /** + * Constructor + * @param {TypeTransformer[]} transformers The type transformers + */ + constructor (transformers) { + this._transformers = transformers + this._transformersPerSignature = new Map(transformers.map(typeTransformer => [typeTransformer.signature, typeTransformer])) + this.fromStructure = this.fromStructure.bind(this) + this.toStructure = this.toStructure.bind(this) + Object.freeze(this) + } + + /** + * Transform from structure to specific object + * + * @param {struct.Structure} struct The structure + * @returns {|structure.Structure} The driver object or the structure if the transformer was not found. + */ + fromStructure (struct) { + try { + if (struct instanceof structure.Structure && this._transformersPerSignature.has(struct.signature)) { + const { fromStructure } = this._transformersPerSignature.get(struct.signature) + return fromStructure(struct) + } + return struct + } catch (error) { + return objectUtil.createBrokenObject(error) + } + } + + /** + * Transform from object to structure + * @param {} type The object to be transoformed in structure + * @returns {|structure.Structure} The structure or the object, if any transformer was found + */ + toStructure (type) { + const transformer = this._transformers.find(({ isTypeInstance }) => isTypeInstance(type)) + if (transformer !== undefined) { + return transformer.toStructure(type) + } + return type + } +} + +/** + * @callback isTypeInstanceFunction + * @param {any} object The object + * @return {boolean} is instance of + */ + +/** + * @callback toStructureFunction + * @param {any} object The object + * @return {structure.Structure} The structure + */ + +/** + * @callback fromStructureFunction + * @param {structure.Structure} struct The structure + * @return {any} The object + */ + +/** + * Class responsible for grouping the properties of a TypeTransformer + */ +export class TypeTransformer { + /** + * @param {Object} param + * @param {number} param.signature The signature of the structure + * @param {isTypeInstanceFunction} param.isTypeInstance The function which checks if object is + * instance of the type described by the TypeTransformer + * @param {toStructureFunction} param.toStructure The function which gets the object and converts to structure + * @param {fromStructureFunction} param.fromStructure The function which get the structure and covnverts to object + */ + constructor ({ signature, fromStructure, toStructure, isTypeInstance }) { + this.signature = signature + this.isTypeInstance = isTypeInstance + this.fromStructure = fromStructure + this.toStructure = toStructure + + Object.freeze(this) + } + + /** + * @param {Object} param + * @param {number} [param.signature] The signature of the structure + * @param {isTypeInstanceFunction} [param.isTypeInstance] The function which checks if object is + * instance of the type described by the TypeTransformer + * @param {toStructureFunction} [param.toStructure] The function which gets the object and converts to structure + * @param {fromStructureFunction} pparam.fromStructure] The function which get the structure and covnverts to object + * @returns {TypeTransformer} A new type transform extends with new methods + */ + extendsWith ({ signature, fromStructure, toStructure, isTypeInstance }) { + return new TypeTransformer({ + signature: signature || this.signature, + fromStructure: fromStructure || this.fromStructure, + toStructure: toStructure || this.toStructure, + isTypeInstance: isTypeInstance || this.isTypeInstance + }) + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/buf/base-buf.js b/packages/neo4j-driver-deno/lib/bolt-connection/buf/base-buf.js new file mode 100644 index 000000000..2e19e75ff --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/buf/base-buf.js @@ -0,0 +1,417 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ + +/** + * Common base with default implementation for most buffer methods. + * Buffers are stateful - they track a current "position", this helps greatly + * when reading and writing from them incrementally. You can also ignore the + * stateful read/write methods. + * readXXX and writeXXX-methods move the inner position of the buffer. + * putXXX and getXXX-methods do not. + * @access private + */ +export default class BaseBuffer { + /** + * Create a instance with the injected size. + * @constructor + * @param {Integer} size + */ + constructor (size) { + this.position = 0 + this.length = size + } + + getUInt8 (position) { + throw new Error('Not implemented') + } + + getInt8 (position) { + throw new Error('Not implemented') + } + + getFloat64 (position) { + throw new Error('Not implemented') + } + + putUInt8 (position, val) { + throw new Error('Not implemented') + } + + putInt8 (position, val) { + throw new Error('Not implemented') + } + + putFloat64 (position, val) { + throw new Error('Not implemented') + } + + /** + * @param p + */ + getInt16 (p) { + return (this.getInt8(p) << 8) | this.getUInt8(p + 1) + } + + /** + * @param p + */ + getUInt16 (p) { + return (this.getUInt8(p) << 8) | this.getUInt8(p + 1) + } + + /** + * @param p + */ + getInt32 (p) { + return ( + (this.getInt8(p) << 24) | + (this.getUInt8(p + 1) << 16) | + (this.getUInt8(p + 2) << 8) | + this.getUInt8(p + 3) + ) + } + + /** + * @param p + */ + getUInt32 (p) { + return ( + (this.getUInt8(p) << 24) | + (this.getUInt8(p + 1) << 16) | + (this.getUInt8(p + 2) << 8) | + this.getUInt8(p + 3) + ) + } + + /** + * @param p + */ + getInt64 (p) { + return ( + (this.getInt8(p) << 56) | + (this.getUInt8(p + 1) << 48) | + (this.getUInt8(p + 2) << 40) | + (this.getUInt8(p + 3) << 32) | + (this.getUInt8(p + 4) << 24) | + (this.getUInt8(p + 5) << 16) | + (this.getUInt8(p + 6) << 8) | + this.getUInt8(p + 7) + ) + } + + /** + * Get a slice of this buffer. This method does not copy any data, + * but simply provides a slice view of this buffer + * @param start + * @param length + */ + getSlice (start, length) { + return new SliceBuffer(start, length, this) + } + + /** + * @param p + * @param val + */ + putInt16 (p, val) { + this.putInt8(p, val >> 8) + this.putUInt8(p + 1, val & 0xff) + } + + /** + * @param p + * @param val + */ + putUInt16 (p, val) { + this.putUInt8(p, (val >> 8) & 0xff) + this.putUInt8(p + 1, val & 0xff) + } + + /** + * @param p + * @param val + */ + putInt32 (p, val) { + this.putInt8(p, val >> 24) + this.putUInt8(p + 1, (val >> 16) & 0xff) + this.putUInt8(p + 2, (val >> 8) & 0xff) + this.putUInt8(p + 3, val & 0xff) + } + + /** + * @param p + * @param val + */ + putUInt32 (p, val) { + this.putUInt8(p, (val >> 24) & 0xff) + this.putUInt8(p + 1, (val >> 16) & 0xff) + this.putUInt8(p + 2, (val >> 8) & 0xff) + this.putUInt8(p + 3, val & 0xff) + } + + /** + * @param p + * @param val + */ + putInt64 (p, val) { + this.putInt8(p, val >> 48) + this.putUInt8(p + 1, (val >> 42) & 0xff) + this.putUInt8(p + 2, (val >> 36) & 0xff) + this.putUInt8(p + 3, (val >> 30) & 0xff) + this.putUInt8(p + 4, (val >> 24) & 0xff) + this.putUInt8(p + 5, (val >> 16) & 0xff) + this.putUInt8(p + 6, (val >> 8) & 0xff) + this.putUInt8(p + 7, val & 0xff) + } + + /** + * @param position + * @param other + */ + putBytes (position, other) { + for (let i = 0, end = other.remaining(); i < end; i++) { + this.putUInt8(position + i, other.readUInt8()) + } + } + + /** + * Read from state position. + */ + readUInt8 () { + return this.getUInt8(this._updatePos(1)) + } + + /** + * Read from state position. + */ + readInt8 () { + return this.getInt8(this._updatePos(1)) + } + + /** + * Read from state position. + */ + readUInt16 () { + return this.getUInt16(this._updatePos(2)) + } + + /** + * Read from state position. + */ + readUInt32 () { + return this.getUInt32(this._updatePos(4)) + } + + /** + * Read from state position. + */ + readInt16 () { + return this.getInt16(this._updatePos(2)) + } + + /** + * Read from state position. + */ + readInt32 () { + return this.getInt32(this._updatePos(4)) + } + + /** + * Read from state position. + */ + readInt64 () { + return this.getInt32(this._updatePos(8)) + } + + /** + * Read from state position. + */ + readFloat64 () { + return this.getFloat64(this._updatePos(8)) + } + + /** + * Write to state position. + * @param val + */ + writeUInt8 (val) { + this.putUInt8(this._updatePos(1), val) + } + + /** + * Write to state position. + * @param val + */ + writeInt8 (val) { + this.putInt8(this._updatePos(1), val) + } + + /** + * Write to state position. + * @param val + */ + writeInt16 (val) { + this.putInt16(this._updatePos(2), val) + } + + /** + * Write to state position. + * @param val + */ + writeInt32 (val) { + this.putInt32(this._updatePos(4), val) + } + + /** + * Write to state position. + * @param val + */ + writeUInt32 (val) { + this.putUInt32(this._updatePos(4), val) + } + + /** + * Write to state position. + * @param val + */ + writeInt64 (val) { + this.putInt64(this._updatePos(8), val) + } + + /** + * Write to state position. + * @param val + */ + writeFloat64 (val) { + this.putFloat64(this._updatePos(8), val) + } + + /** + * Write to state position. + * @param val + */ + writeBytes (val) { + this.putBytes(this._updatePos(val.remaining()), val) + } + + /** + * Get a slice of this buffer. This method does not copy any data, + * but simply provides a slice view of this buffer + * @param length + */ + readSlice (length) { + return this.getSlice(this._updatePos(length), length) + } + + _updatePos (length) { + const p = this.position + this.position += length + return p + } + + /** + * Get remaining + */ + remaining () { + return this.length - this.position + } + + /** + * Has remaining + */ + hasRemaining () { + return this.remaining() > 0 + } + + /** + * Reset position state + */ + reset () { + this.position = 0 + } + + /** + * Get string representation of buffer and it's state. + * @return {string} Buffer as a string + */ + toString () { + return ( + this.constructor.name + + '( position=' + + this.position + + ' )\n ' + + this.toHex() + ) + } + + /** + * Get string representation of buffer. + * @return {string} Buffer as a string + */ + toHex () { + let out = '' + for (let i = 0; i < this.length; i++) { + let hexByte = this.getUInt8(i).toString(16) + if (hexByte.length === 1) { + hexByte = '0' + hexByte + } + out += hexByte + if (i !== this.length - 1) { + out += ' ' + } + } + return out + } +} + +/** + * Represents a view as slice of another buffer. + * @access private + */ +class SliceBuffer extends BaseBuffer { + constructor (start, length, inner) { + super(length) + this._start = start + this._inner = inner + } + + putUInt8 (position, val) { + this._inner.putUInt8(this._start + position, val) + } + + getUInt8 (position) { + return this._inner.getUInt8(this._start + position) + } + + putInt8 (position, val) { + this._inner.putInt8(this._start + position, val) + } + + putFloat64 (position, val) { + this._inner.putFloat64(this._start + position, val) + } + + getInt8 (position) { + return this._inner.getInt8(this._start + position) + } + + getFloat64 (position) { + return this._inner.getFloat64(this._start + position) + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/buf/index.js b/packages/neo4j-driver-deno/lib/bolt-connection/buf/index.js new file mode 100644 index 000000000..abf1a3394 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/buf/index.js @@ -0,0 +1,23 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 BaseBuffer from './base-buf.js' + +export default BaseBuffer +export { BaseBuffer } diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/channel/browser/browser-channel.js b/packages/neo4j-driver-deno/lib/bolt-connection/channel/browser/browser-channel.js new file mode 100644 index 000000000..565c99167 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/channel/browser/browser-channel.js @@ -0,0 +1,419 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ +/* eslint-env browser */ +import ChannelBuffer from '../channel-buf.js' +import { newError, internal } from '../../../core/index.ts' + +const { + util: { ENCRYPTION_OFF, ENCRYPTION_ON } +} = internal + +// Just to be sure that these values are with us even after WebSocket is injected +// for tests. +// eslint-disable-next-line no-unused-vars +const WS_CONNECTING = 0 +const WS_OPEN = 1 +// eslint-disable-next-line no-unused-vars +const WS_CLOSING = 2 +const WS_CLOSED = 3 + +/** + * Create a new WebSocketChannel to be used in web browsers. + * @access private + */ +export default class WebSocketChannel { + /** + * Create new instance + * @param {ChannelConfig} config - configuration for this channel. + * @param {function(): string} protocolSupplier - function that detects protocol of the web page. Should only be used in tests. + */ + constructor ( + config, + protocolSupplier = detectWebPageProtocol, + socketFactory = url => new WebSocket(url) + ) { + this._open = true + this._pending = [] + this._error = null + this._handleConnectionError = this._handleConnectionError.bind(this) + this._config = config + this._receiveTimeout = null + this._receiveTimeoutStarted = false + this._receiveTimeoutId = null + + const { scheme, error } = determineWebSocketScheme(config, protocolSupplier) + if (error) { + this._error = error + return + } + + this._ws = createWebSocket(scheme, config.address, socketFactory) + this._ws.binaryType = 'arraybuffer' + + const self = this + // All connection errors are not sent to the error handler + // we must also check for dirty close calls + this._ws.onclose = function (e) { + if (e && !e.wasClean) { + self._handleConnectionError() + } + self._open = false + } + this._ws.onopen = function () { + // Connected! Cancel the connection timeout + self._clearConnectionTimeout() + + // Drain all pending messages + const pending = self._pending + self._pending = null + for (let i = 0; i < pending.length; i++) { + self.write(pending[i]) + } + } + this._ws.onmessage = event => { + this._resetTimeout() + if (self.onmessage) { + const b = new ChannelBuffer(event.data) + self.onmessage(b) + } + } + + this._ws.onerror = this._handleConnectionError + + this._connectionTimeoutFired = false + this._connectionTimeoutId = this._setupConnectionTimeout() + } + + _handleConnectionError () { + if (this._connectionTimeoutFired) { + // timeout fired - not connected within configured time + this._error = newError( + `Failed to establish connection in ${this._config.connectionTimeout}ms`, + this._config.connectionErrorCode + ) + + if (this.onerror) { + this.onerror(this._error) + } + return + } + + // onerror triggers on websocket close as well.. don't get me started. + if (this._open && !this._timedout) { + // http://stackoverflow.com/questions/25779831/how-to-catch-websocket-connection-to-ws-xxxnn-failed-connection-closed-be + this._error = newError( + 'WebSocket connection failure. Due to security ' + + 'constraints in your web browser, the reason for the failure is not available ' + + 'to this Neo4j Driver. Please use your browsers development console to determine ' + + 'the root cause of the failure. Common reasons include the database being ' + + 'unavailable, using the wrong connection URL or temporary network problems. ' + + 'If you have enabled encryption, ensure your browser is configured to trust the ' + + 'certificate Neo4j is configured to use. WebSocket `readyState` is: ' + + this._ws.readyState, + this._config.connectionErrorCode + ) + if (this.onerror) { + this.onerror(this._error) + } + } + } + + /** + * Write the passed in buffer to connection + * @param {ChannelBuffer} buffer - Buffer to write + */ + write (buffer) { + // If there is a pending queue, push this on that queue. This means + // we are not yet connected, so we queue things locally. + if (this._pending !== null) { + this._pending.push(buffer) + } else if (buffer instanceof ChannelBuffer) { + try { + this._ws.send(buffer._buffer) + } catch (error) { + if (this._ws.readyState !== WS_OPEN) { + // Websocket has been closed + this._handleConnectionError() + } else { + // Some other error occured + throw error + } + } + } else { + throw newError("Don't know how to send buffer: " + buffer) + } + } + + /** + * Close the connection + * @returns {Promise} A promise that will be resolved after channel is closed + */ + close () { + return new Promise((resolve, reject) => { + if (this._ws && this._ws.readyState !== WS_CLOSED) { + this._open = false + this._clearConnectionTimeout() + this._ws.onclose = () => resolve() + this._ws.close() + } else { + resolve() + } + }) + } + + /** + * Setup the receive timeout for the channel. + * + * Not supported for the browser channel. + * + * @param {number} receiveTimeout The amount of time the channel will keep without receive any data before timeout (ms) + * @returns {void} + */ + setupReceiveTimeout (receiveTimeout) { + this._receiveTimeout = receiveTimeout + } + + /** + * Stops the receive timeout for the channel. + */ + stopReceiveTimeout () { + if (this._receiveTimeout !== null && this._receiveTimeoutStarted) { + this._receiveTimeoutStarted = false + if (this._receiveTimeoutId != null) { + clearTimeout(this._receiveTimeoutId) + } + this._receiveTimeoutId = null + } + } + + /** + * Start the receive timeout for the channel. + */ + startReceiveTimeout () { + if (this._receiveTimeout !== null && !this._receiveTimeoutStarted) { + this._receiveTimeoutStarted = true + this._resetTimeout() + } + } + + _resetTimeout () { + if (!this._receiveTimeoutStarted) { + return + } + + if (this._receiveTimeoutId !== null) { + clearTimeout(this._receiveTimeoutId) + } + + this._receiveTimeoutId = setTimeout(() => { + this._receiveTimeoutId = null + this._timedout = true + this.stopReceiveTimeout() + this._error = newError( + `Connection lost. Server didn't respond in ${this._receiveTimeout}ms`, + this._config.connectionErrorCode + ) + + this.close() + if (this.onerror) { + this.onerror(this._error) + } + }, this._receiveTimeout) + } + + /** + * Set connection timeout on the given WebSocket, if configured. + * @return {number} the timeout id or null. + * @private + */ + _setupConnectionTimeout () { + const timeout = this._config.connectionTimeout + if (timeout) { + const webSocket = this._ws + + return setTimeout(() => { + if (webSocket.readyState !== WS_OPEN) { + this._connectionTimeoutFired = true + webSocket.close() + } + }, timeout) + } + return null + } + + /** + * Remove active connection timeout, if any. + * @private + */ + _clearConnectionTimeout () { + const timeoutId = this._connectionTimeoutId + if (timeoutId || timeoutId === 0) { + this._connectionTimeoutFired = false + this._connectionTimeoutId = null + clearTimeout(timeoutId) + } + } +} + +function createWebSocket (scheme, address, socketFactory) { + const url = scheme + '://' + address.asHostPort() + + try { + return socketFactory(url) + } catch (error) { + if (isIPv6AddressIssueOnWindows(error, address)) { + // WebSocket in IE and Edge browsers on Windows do not support regular IPv6 address syntax because they contain ':'. + // It's an invalid character for UNC (https://en.wikipedia.org/wiki/IPv6_address#Literal_IPv6_addresses_in_UNC_path_names) + // and Windows requires IPv6 to be changes in the following way: + // 1) replace all ':' with '-' + // 2) replace '%' with 's' for link-local address + // 3) append '.ipv6-literal.net' suffix + // only then resulting string can be considered a valid IPv6 address. Yes, this is extremely weird! + // For more details see: + // https://social.msdn.microsoft.com/Forums/ie/en-US/06cca73b-63c2-4bf9-899b-b229c50449ff/whether-ie10-websocket-support-ipv6?forum=iewebdevelopment + // https://www.itdojo.com/ipv6-addresses-and-unc-path-names-overcoming-illegal/ + // Creation of WebSocket with unconverted address results in SyntaxError without message or stacktrace. + // That is why here we "catch" SyntaxError and rewrite IPv6 address if needed. + + const windowsFriendlyUrl = asWindowsFriendlyIPv6Address(scheme, address) + return socketFactory(windowsFriendlyUrl) + } else { + throw error + } + } +} + +function isIPv6AddressIssueOnWindows (error, address) { + return error.name === 'SyntaxError' && isIPv6Address(address.asHostPort()) +} + +function isIPv6Address (hostAndPort) { + return hostAndPort.charAt(0) === '[' && hostAndPort.indexOf(']') !== -1 +} + +function asWindowsFriendlyIPv6Address (scheme, address) { + // replace all ':' with '-' + const hostWithoutColons = address.host().replace(/:/g, '-') + + // replace '%' with 's' for link-local IPv6 address like 'fe80::1%lo0' + const hostWithoutPercent = hostWithoutColons.replace('%', 's') + + // append magic '.ipv6-literal.net' suffix + const ipv6Host = hostWithoutPercent + '.ipv6-literal.net' + + return `${scheme}://${ipv6Host}:${address.port()}` +} + +/** + * @param {ChannelConfig} config - configuration for the channel. + * @param {function(): string} protocolSupplier - function that detects protocol of the web page. + * @return {{scheme: string|null, error: Neo4jError|null}} object containing either scheme or error. + */ +function determineWebSocketScheme (config, protocolSupplier) { + const encryptionOn = isEncryptionExplicitlyTurnedOn(config) + const encryptionOff = isEncryptionExplicitlyTurnedOff(config) + const trust = config.trust + const secureProtocol = isProtocolSecure(protocolSupplier) + verifyEncryptionSettings(encryptionOn, encryptionOff, secureProtocol) + + if (encryptionOff) { + // encryption explicitly turned off in the config + return { scheme: 'ws', error: null } + } + + if (secureProtocol) { + // driver is used in a secure https web page, use 'wss' + return { scheme: 'wss', error: null } + } + + if (encryptionOn) { + // encryption explicitly requested in the config + if (!trust || trust === 'TRUST_SYSTEM_CA_SIGNED_CERTIFICATES') { + // trust strategy not specified or the only supported strategy is specified + return { scheme: 'wss', error: null } + } else { + const error = newError( + 'The browser version of this driver only supports one trust ' + + "strategy, 'TRUST_SYSTEM_CA_SIGNED_CERTIFICATES'. " + + trust + + ' is not supported. Please ' + + 'either use TRUST_SYSTEM_CA_SIGNED_CERTIFICATES or disable encryption by setting ' + + '`encrypted:"' + + ENCRYPTION_OFF + + '"` in the driver configuration.' + ) + return { scheme: null, error: error } + } + } + + // default to unencrypted web socket + return { scheme: 'ws', error: null } +} + +/** + * @param {ChannelConfig} config - configuration for the channel. + * @return {boolean} `true` if encryption enabled in the config, `false` otherwise. + */ +function isEncryptionExplicitlyTurnedOn (config) { + return config.encrypted === true || config.encrypted === ENCRYPTION_ON +} + +/** + * @param {ChannelConfig} config - configuration for the channel. + * @return {boolean} `true` if encryption disabled in the config, `false` otherwise. + */ +function isEncryptionExplicitlyTurnedOff (config) { + return config.encrypted === false || config.encrypted === ENCRYPTION_OFF +} + +/** + * @param {function(): string} protocolSupplier - function that detects protocol of the web page. + * @return {boolean} `true` if protocol returned by the given function is secure, `false` otherwise. + */ +function isProtocolSecure (protocolSupplier) { + const protocol = + typeof protocolSupplier === 'function' ? protocolSupplier() : '' + return protocol && protocol.toLowerCase().indexOf('https') >= 0 +} + +function verifyEncryptionSettings (encryptionOn, encryptionOff, secureProtocol) { + if (secureProtocol === null) { + // do nothing sice the protocol could not be identified + } else if (encryptionOn && !secureProtocol) { + // encryption explicitly turned on for a driver used on a HTTP web page + console.warn( + 'Neo4j driver is configured to use secure WebSocket on a HTTP web page. ' + + 'WebSockets might not work in a mixed content environment. ' + + 'Please consider configuring driver to not use encryption.' + ) + } else if (encryptionOff && secureProtocol) { + // encryption explicitly turned off for a driver used on a HTTPS web page + console.warn( + 'Neo4j driver is configured to use insecure WebSocket on a HTTPS web page. ' + + 'WebSockets might not work in a mixed content environment. ' + + 'Please consider configuring driver to use encryption.' + ) + } +} + +function detectWebPageProtocol () { + return typeof window !== 'undefined' && window.location + ? window.location.protocol + : null +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/channel/browser/browser-host-name-resolver.js b/packages/neo4j-driver-deno/lib/bolt-connection/channel/browser/browser-host-name-resolver.js new file mode 100644 index 000000000..883c3474c --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/channel/browser/browser-host-name-resolver.js @@ -0,0 +1,30 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { internal } from '../../../core/index.ts' + +const { + resolver: { BaseHostNameResolver } +} = internal + +export default class BrowserHostNameResolver extends BaseHostNameResolver { + resolve (address) { + return this._resolveToItself(address) + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/channel/browser/index.js b/packages/neo4j-driver-deno/lib/bolt-connection/channel/browser/index.js new file mode 100644 index 000000000..c006716aa --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/channel/browser/index.js @@ -0,0 +1,34 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 WebSocketChannel from './browser-channel.js' +import BrowserHosNameResolver from './browser-host-name-resolver.js' + +/* + +This module exports a set of components to be used in browser environment. +They are not compatible with NodeJS environment. +All files import/require APIs from `node/index.js` by default. +Such imports are replaced at build time with `browser/index.js` when building a browser bundle. + +NOTE: exports in this module should have exactly the same names/structure as exports in `node/index.js`. + + */ +export const Channel = WebSocketChannel +export const HostNameResolver = BrowserHosNameResolver diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/channel/channel-buf.js b/packages/neo4j-driver-deno/lib/bolt-connection/channel/channel-buf.js new file mode 100644 index 000000000..9a6074363 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/channel/channel-buf.js @@ -0,0 +1,101 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 buffer from 'https://deno.land/std@0.119.0/node/buffer.ts' +import BaseBuffer from '../buf/index.js' + +export default class ChannelBuffer extends BaseBuffer { + constructor (arg) { + const buffer = newChannelJSBuffer(arg) + super(buffer.length) + this._buffer = buffer + } + + getUInt8 (position) { + return this._buffer.readUInt8(position) + } + + getInt8 (position) { + return this._buffer.readInt8(position) + } + + getFloat64 (position) { + return this._buffer.readDoubleBE(position) + } + + putUInt8 (position, val) { + this._buffer.writeUInt8(val, position) + } + + putInt8 (position, val) { + this._buffer.writeInt8(val, position) + } + + putFloat64 (position, val) { + this._buffer.writeDoubleBE(val, position) + } + + putBytes (position, val) { + if (val instanceof ChannelBuffer) { + const bytesToCopy = Math.min( + val.length - val.position, + this.length - position + ) + val._buffer.copy( + this._buffer, + position, + val.position, + val.position + bytesToCopy + ) + val.position += bytesToCopy + } else { + super.putBytes(position, val) + } + } + + getSlice (start, length) { + return new ChannelBuffer(this._buffer.slice(start, start + length)) + } +} + +/** + * Allocate a buffer + * + * @param {number} size The buffer sizzer + * @returns {BaseBuffer} The buffer + */ +export function alloc (size) { + return new ChannelBuffer(size) +} + +function newChannelJSBuffer (arg) { + if (arg instanceof buffer.Buffer) { + return arg + } else if ( + typeof arg === 'number' && + typeof buffer.Buffer.alloc === 'function' + ) { + // use static factory function present in newer NodeJS versions to allocate new buffer with specified size + return buffer.Buffer.alloc(arg) + } else { + // fallback to the old, potentially deprecated constructor + // eslint-disable-next-line node/no-deprecated-api + return new buffer.Buffer(arg) + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/channel/channel-config.js b/packages/neo4j-driver-deno/lib/bolt-connection/channel/channel-config.js new file mode 100644 index 000000000..76c39903e --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/channel/channel-config.js @@ -0,0 +1,89 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { newError, error, internal } from '../../core/index.ts' + +const { + util: { ENCRYPTION_OFF, ENCRYPTION_ON } +} = internal + +const { SERVICE_UNAVAILABLE } = error + +const ALLOWED_VALUES_ENCRYPTED = [ + null, + undefined, + true, + false, + ENCRYPTION_ON, + ENCRYPTION_OFF +] + +const ALLOWED_VALUES_TRUST = [ + null, + undefined, + 'TRUST_ALL_CERTIFICATES', + 'TRUST_CUSTOM_CA_SIGNED_CERTIFICATES', + 'TRUST_SYSTEM_CA_SIGNED_CERTIFICATES' +] + +export default class ChannelConfig { + /** + * @constructor + * @param {ServerAddress} address the address for the channel to connect to. + * @param {Object} driverConfig the driver config provided by the user when driver is created. + * @param {string} connectionErrorCode the default error code to use on connection errors. + */ + constructor (address, driverConfig, connectionErrorCode) { + this.address = address + this.encrypted = extractEncrypted(driverConfig) + this.trust = extractTrust(driverConfig) + this.trustedCertificates = extractTrustedCertificates(driverConfig) + this.knownHostsPath = extractKnownHostsPath(driverConfig) + this.connectionErrorCode = connectionErrorCode || SERVICE_UNAVAILABLE + this.connectionTimeout = driverConfig.connectionTimeout + } +} + +function extractEncrypted (driverConfig) { + const value = driverConfig.encrypted + if (ALLOWED_VALUES_ENCRYPTED.indexOf(value) === -1) { + throw newError( + `Illegal value of the encrypted setting ${value}. Expected one of ${ALLOWED_VALUES_ENCRYPTED}` + ) + } + return value +} + +function extractTrust (driverConfig) { + const value = driverConfig.trust + if (ALLOWED_VALUES_TRUST.indexOf(value) === -1) { + throw newError( + `Illegal value of the trust setting ${value}. Expected one of ${ALLOWED_VALUES_TRUST}` + ) + } + return value +} + +function extractTrustedCertificates (driverConfig) { + return driverConfig.trustedCertificates || [] +} + +function extractKnownHostsPath (driverConfig) { + return driverConfig.knownHosts || null +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/channel/chunking.js b/packages/neo4j-driver-deno/lib/bolt-connection/channel/chunking.js new file mode 100644 index 000000000..d4c6e40e1 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/channel/chunking.js @@ -0,0 +1,209 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 BaseBuffer from '../buf/base-buf.js' +import { alloc } from './channel-buf.js' +import CombinedBuffer from './combined-buf.js' + +const _CHUNK_HEADER_SIZE = 2 +const _MESSAGE_BOUNDARY = 0x00 +const _DEFAULT_BUFFER_SIZE = 1400 // http://stackoverflow.com/questions/2613734/maximum-packet-size-for-a-tcp-connection + +/** + * Looks like a writable buffer, chunks output transparently into a channel below. + * @access private + */ +class Chunker extends BaseBuffer { + constructor (channel, bufferSize) { + super(0) + this._bufferSize = bufferSize || _DEFAULT_BUFFER_SIZE + this._ch = channel + this._buffer = alloc(this._bufferSize) + this._currentChunkStart = 0 + this._chunkOpen = false + } + + putUInt8 (position, val) { + this._ensure(1) + this._buffer.writeUInt8(val) + } + + putInt8 (position, val) { + this._ensure(1) + this._buffer.writeInt8(val) + } + + putFloat64 (position, val) { + this._ensure(8) + this._buffer.writeFloat64(val) + } + + putBytes (position, data) { + // TODO: If data is larger than our chunk size or so, we're very likely better off just passing this buffer on + // rather than doing the copy here TODO: *however* note that we need some way to find out when the data has been + // written (and thus the buffer can be re-used) if we take that approach + while (data.remaining() > 0) { + // Ensure there is an open chunk, and that it has at least one byte of space left + this._ensure(1) + if (this._buffer.remaining() > data.remaining()) { + this._buffer.writeBytes(data) + } else { + this._buffer.writeBytes(data.readSlice(this._buffer.remaining())) + } + } + return this + } + + flush () { + if (this._buffer.position > 0) { + this._closeChunkIfOpen() + + // Local copy and clear the buffer field. This ensures that the buffer is not re-released if the flush call fails + const out = this._buffer + this._buffer = null + + this._ch.write(out.getSlice(0, out.position)) + + // Alloc a new output buffer. We assume we're using NodeJS's buffer pooling under the hood here! + this._buffer = alloc(this._bufferSize) + this._chunkOpen = false + } + return this + } + + /** + * Bolt messages are encoded in one or more chunks, and the boundary between two messages + * is encoded as a 0-length chunk, `00 00`. This inserts such a message boundary, closing + * any currently open chunk as needed + */ + messageBoundary () { + this._closeChunkIfOpen() + + if (this._buffer.remaining() < _CHUNK_HEADER_SIZE) { + this.flush() + } + + // Write message boundary + this._buffer.writeInt16(_MESSAGE_BOUNDARY) + } + + /** Ensure at least the given size is available for writing */ + _ensure (size) { + const toWriteSize = this._chunkOpen ? size : size + _CHUNK_HEADER_SIZE + if (this._buffer.remaining() < toWriteSize) { + this.flush() + } + + if (!this._chunkOpen) { + this._currentChunkStart = this._buffer.position + this._buffer.position = this._buffer.position + _CHUNK_HEADER_SIZE + this._chunkOpen = true + } + } + + _closeChunkIfOpen () { + if (this._chunkOpen) { + const chunkSize = + this._buffer.position - (this._currentChunkStart + _CHUNK_HEADER_SIZE) + this._buffer.putUInt16(this._currentChunkStart, chunkSize) + this._chunkOpen = false + } + } +} + +/** + * Combines chunks until a complete message is gathered up, and then forwards that + * message to an 'onmessage' listener. + * @access private + */ +class Dechunker { + constructor () { + this._currentMessage = [] + this._partialChunkHeader = 0 + this._state = this.AWAITING_CHUNK + } + + AWAITING_CHUNK (buf) { + if (buf.remaining() >= 2) { + // Whole header available, read that + return this._onHeader(buf.readUInt16()) + } else { + // Only one byte available, read that and wait for the second byte + this._partialChunkHeader = buf.readUInt8() << 8 + return this.IN_HEADER + } + } + + IN_HEADER (buf) { + // First header byte read, now we read the next one + return this._onHeader((this._partialChunkHeader | buf.readUInt8()) & 0xffff) + } + + IN_CHUNK (buf) { + if (this._chunkSize <= buf.remaining()) { + // Current packet is larger than current chunk, or same size: + this._currentMessage.push(buf.readSlice(this._chunkSize)) + return this.AWAITING_CHUNK + } else { + // Current packet is smaller than the chunk we're reading, split the current chunk itself up + this._chunkSize -= buf.remaining() + this._currentMessage.push(buf.readSlice(buf.remaining())) + return this.IN_CHUNK + } + } + + CLOSED (buf) { + // no-op + } + + /** Called when a complete chunk header has been received */ + _onHeader (header) { + if (header === 0) { + // Message boundary + let message + switch (this._currentMessage.length) { + case 0: + // Keep alive chunk, sent by server to keep network alive. + return this.AWAITING_CHUNK + case 1: + // All data in one chunk, this signals the end of that chunk. + message = this._currentMessage[0] + break + default: + // A large chunk of data received, this signals that the last chunk has been received. + message = new CombinedBuffer(this._currentMessage) + break + } + this._currentMessage = [] + this.onmessage(message) + return this.AWAITING_CHUNK + } else { + this._chunkSize = header + return this.IN_CHUNK + } + } + + write (buf) { + while (buf.hasRemaining()) { + this._state = this._state(buf) + } + } +} + +export { Chunker, Dechunker } diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/channel/combined-buf.js b/packages/neo4j-driver-deno/lib/bolt-connection/channel/combined-buf.js new file mode 100644 index 000000000..81233eb1b --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/channel/combined-buf.js @@ -0,0 +1,71 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { BaseBuffer } from '../buf/index.js' +import { alloc } from './channel-buf.js' + +/** + * Buffer that combines multiple buffers, exposing them as one single buffer. + */ +export default class CombinedBuffer extends BaseBuffer { + constructor (buffers) { + let length = 0 + for (let i = 0; i < buffers.length; i++) { + length += buffers[i].length + } + super(length) + this._buffers = buffers + } + + getUInt8 (position) { + // Surely there's a faster way to do this.. some sort of lookup table thing? + for (let i = 0; i < this._buffers.length; i++) { + const buffer = this._buffers[i] + // If the position is not in the current buffer, skip the current buffer + if (position >= buffer.length) { + position -= buffer.length + } else { + return buffer.getUInt8(position) + } + } + } + + getInt8 (position) { + // Surely there's a faster way to do this.. some sort of lookup table thing? + for (let i = 0; i < this._buffers.length; i++) { + const buffer = this._buffers[i] + // If the position is not in the current buffer, skip the current buffer + if (position >= buffer.length) { + position -= buffer.length + } else { + return buffer.getInt8(position) + } + } + } + + getFloat64 (position) { + // At some point, a more efficient impl. For now, we copy the 8 bytes + // we want to read and depend on the platform impl of IEEE 754. + const b = alloc(8) + for (let i = 0; i < 8; i++) { + b.putUInt8(i, this.getUInt8(position + i)) + } + return b.getFloat64(0) + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/channel/deno/deno-channel.js b/packages/neo4j-driver-deno/lib/bolt-connection/channel/deno/deno-channel.js new file mode 100644 index 000000000..a1322b4c7 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/channel/deno/deno-channel.js @@ -0,0 +1,336 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ +/* eslint-disable */ +import ChannelBuffer from '../channel-buf.js' +import { newError, internal } from '../../../core/index.ts' +import { iterateReader } from 'https://deno.land/std@0.157.0/streams/conversion.ts'; + +const { + util: { ENCRYPTION_OFF, ENCRYPTION_ON } +} = internal + +let _CONNECTION_IDGEN = 0 +/** + * Create a new DenoChannel to be used in Deno runtime. + * @access private + */ +export default class DenoChannel { + /** + * Create new instance + * @param {ChannelConfig} config - configuration for this channel. + */ + constructor ( + config, + connect = _connect + ) { + this.id = _CONNECTION_IDGEN++ + this._conn = null + this._pending = [] + this._open = true + this._error = null + this._handleConnectionError = this._handleConnectionError.bind(this) + this._handleConnectionTerminated = this._handleConnectionTerminated.bind( + this + ) + this._connectionErrorCode = config.connectionErrorCode + this._receiveTimeout = null + this._receiveTimeoutStarted = false + this._receiveTimeoutId = null + + this._config = config + + connect(config) + .then(conn => { + this._clearConnectionTimeout() + if (!this._open) { + return conn.close() + } + this._conn = conn + + setupReader(this) + .catch(this._handleConnectionError) + + const pending = this._pending + this._pending = null + for (let i = 0; i < pending.length; i++) { + this.write(pending[i]) + } + }) + .catch(this._handleConnectionError) + + this._connectionTimeoutFired = false + this._connectionTimeoutId = this._setupConnectionTimeout() + } + + _setupConnectionTimeout () { + const timeout = this._config.connectionTimeout + if (timeout) { + return setTimeout(() => { + this._connectionTimeoutFired = true + this.close() + .then(e => this._handleConnectionError(newError(`Connection timeout after ${timeout} ms`))) + .catch(this._handleConnectionError) + }, timeout) + } + return null + } + + /** + * Remove active connection timeout, if any. + * @private + */ + _clearConnectionTimeout () { + const timeoutId = this._connectionTimeoutId + if (timeoutId !== null) { + this._connectionTimeoutFired = false + this._connectionTimeoutId = null + clearTimeout(timeoutId) + } + } + + _handleConnectionError (err) { + let msg = + 'Failed to connect to server. ' + + 'Please ensure that your database is listening on the correct host and port ' + + 'and that you have compatible encryption settings both on Neo4j server and driver. ' + + 'Note that the default encryption setting has changed in Neo4j 4.0.' + if (err.message) msg += ' Caused by: ' + err.message + this._error = newError(msg, this._connectionErrorCode) + if (this.onerror) { + this.onerror(this._error) + } + } + + _handleConnectionTerminated () { + this._open = false + this._error = newError( + 'Connection was closed by server', + this._connectionErrorCode + ) + if (this.onerror) { + this.onerror(this._error) + } + } + + + /** + * Write the passed in buffer to connection + * @param {ChannelBuffer} buffer - Buffer to write + */ + write (buffer) { + if (this._pending !== null) { + this._pending.push(buffer) + } else if (buffer instanceof ChannelBuffer) { + this._conn.write(buffer._buffer).catch(this._handleConnectionError) + } else { + throw newError("Don't know how to send buffer: " + buffer) + } + } + + /** + * Close the connection + * @returns {Promise} A promise that will be resolved after channel is closed + */ + async close () { + if (this._open) { + this._open = false + if (this._conn != null) { + await this._conn.close() + } + } + } + + /** + * Setup the receive timeout for the channel. + * + * Not supported for the browser channel. + * + * @param {number} receiveTimeout The amount of time the channel will keep without receive any data before timeout (ms) + * @returns {void} + */ + setupReceiveTimeout (receiveTimeout) { + this._receiveTimeout = receiveTimeout + } + + /** + * Stops the receive timeout for the channel. + */ + stopReceiveTimeout () { + if (this._receiveTimeout !== null && this._receiveTimeoutStarted) { + this._receiveTimeoutStarted = false + if (this._receiveTimeoutId != null) { + clearTimeout(this._receiveTimeoutId) + } + this._receiveTimeoutId = null + } + } + + /** + * Start the receive timeout for the channel. + */ + startReceiveTimeout () { + if (this._receiveTimeout !== null && !this._receiveTimeoutStarted) { + this._receiveTimeoutStarted = true + this._resetTimeout() + } + } + + _resetTimeout () { + if (!this._receiveTimeoutStarted) { + return + } + + if (this._receiveTimeoutId !== null) { + clearTimeout(this._receiveTimeoutId) + } + + this._receiveTimeoutId = setTimeout(() => { + this._receiveTimeoutId = null + this.stopReceiveTimeout() + this._error = newError( + `Connection lost. Server didn't respond in ${this._receiveTimeout}ms`, + this._config.connectionErrorCode + ) + + this.close() + .catch(() => { + // ignoring error during the close timeout connections since they + // not valid + }) + .finally(() => { + if (this.onerror) { + this.onerror(this._error) + } + }) + }, this._receiveTimeout) + } +} + +const TrustStrategy = { + TRUST_CUSTOM_CA_SIGNED_CERTIFICATES: async function (config) { + if ( + !config.trustedCertificates || + config.trustedCertificates.length === 0 + ) { + throw newError( + 'You are using TRUST_CUSTOM_CA_SIGNED_CERTIFICATES as the method ' + + 'to verify trust for encrypted connections, but have not configured any ' + + 'trustedCertificates. You must specify the path to at least one trusted ' + + 'X.509 certificate for this to work. Two other alternatives is to use ' + + 'TRUST_ALL_CERTIFICATES or to disable encryption by setting encrypted="' + + ENCRYPTION_OFF + + '"' + + 'in your driver configuration.' + ); + } + + const caCerts = await Promise.all( + config.trustedCertificates.map(f => Deno.readTextFile(f)) + ) + + return Deno.connectTls({ + hostname: config.address.resolvedHost(), + port: config.address.port(), + caCerts + }) + }, + TRUST_SYSTEM_CA_SIGNED_CERTIFICATES: function (config) { + return Deno.connectTls({ + hostname: config.address.resolvedHost(), + port: config.address.port() + }) + }, + TRUST_ALL_CERTIFICATES: function (config) { + throw newError( + `"${config.trust}" is not available in DenoJS. ` + + 'For trust in any certificates, you should use the DenoJS flag ' + + '"--unsafely-ignore-certificate-errors". '+ + 'See, https://deno.com/blog/v1.13#disable-tls-verification' + ) + } +} + +async function _connect (config) { + if (!isEncrypted(config)) { + return Deno.connect({ + hostname: config.address.resolvedHost(), + port: config.address.port() + }) + } + const trustStrategyName = getTrustStrategyName(config) + const trustStrategy = TrustStrategy[trustStrategyName] + + if (trustStrategy != null) { + return await trustStrategy(config) + } + + throw newError( + 'Unknown trust strategy: ' + + config.trust + + '. Please use either ' + + "trust:'TRUST_CUSTOM_CA_SIGNED_CERTIFICATES' configuration " + + 'or the System CA. ' + + 'Alternatively, you can disable encryption by setting ' + + '`encrypted:"' + + ENCRYPTION_OFF + + '"`. There is no mechanism to use encryption without trust verification, ' + + 'because this incurs the overhead of encryption without improving security. If ' + + 'the driver does not verify that the peer it is connected to is really Neo4j, it ' + + 'is very easy for an attacker to bypass the encryption by pretending to be Neo4j.' + + ) +} + +function isEncrypted (config) { + const encryptionNotConfigured = + config.encrypted == null || config.encrypted === undefined + if (encryptionNotConfigured) { + // default to using encryption if trust-all-certificates is available + return false + } + return config.encrypted === true || config.encrypted === ENCRYPTION_ON +} + +function getTrustStrategyName (config) { + if (config.trust) { + return config.trust + } + return 'TRUST_SYSTEM_CA_SIGNED_CERTIFICATES' +} + +async function setupReader (channel) { + try { + for await (const message of iterateReader(channel._conn)) { + channel._resetTimeout() + + if (!channel._open) { + return + } + if (channel.onmessage) { + channel.onmessage(new ChannelBuffer(message)) + } + } + channel._handleConnectionTerminated() + } catch (error) { + if (channel._open) { + channel._handleConnectionError(error) + } + } +} + diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/channel/deno/deno-host-name-resolver.js b/packages/neo4j-driver-deno/lib/bolt-connection/channel/deno/deno-host-name-resolver.js new file mode 100644 index 000000000..c56094653 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/channel/deno/deno-host-name-resolver.js @@ -0,0 +1,29 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { internal } from '../../../core/index.ts' + +const { + resolver: { BaseHostNameResolver } +} = internal + +export default class DenoHostNameResolver extends BaseHostNameResolver { + resolve (address) { + return this._resolveToItself(address) + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/channel/deno/index.js b/packages/neo4j-driver-deno/lib/bolt-connection/channel/deno/index.js new file mode 100644 index 000000000..7a72241e1 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/channel/deno/index.js @@ -0,0 +1,34 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 DenoChannel from './deno-channel.js' +import DenoHostNameResolver from './deno-host-name-resolver.js' + +/* + + This module exports a set of components to be used in deno environment. + They are not compatible with NodeJS environment. + All files import/require APIs from `node/index.js` by default. + Such imports are replaced at build time with `deno/index.js` when building a deno bundle. + + NOTE: exports in this module should have exactly the same names/structure as exports in `node/index.js`. + + */ +export const Channel = DenoChannel +export const HostNameResolver = DenoHostNameResolver diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/channel/index.js b/packages/neo4j-driver-deno/lib/bolt-connection/channel/index.js new file mode 100644 index 000000000..eff243c72 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/channel/index.js @@ -0,0 +1,24 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ + +export * from './deno/index.js' +export * from './chunking.js' +export { default as ChannelConfig } from './channel-config.js' +export { alloc } from './channel-buf.js' +export { default as utf8 } from './utf8.js' diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/channel/node/index.js b/packages/neo4j-driver-deno/lib/bolt-connection/channel/node/index.js new file mode 100644 index 000000000..ff258f56c --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/channel/node/index.js @@ -0,0 +1,35 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 NodeChannel from './node-channel.js' +import NodeHostNameResolver from './node-host-name-resolver.js' + +/* + +This module exports a set of components to be used in NodeJS environment. +They are not compatible with browser environment. +All files that require environment-dependent APIs should import this file by default. +Imports/requires are replaced at build time with `browser/index.js` when building a browser bundle. + +NOTE: exports in this module should have exactly the same names/structure as exports in `browser/index.js`. + + */ + +export const Channel = NodeChannel +export const HostNameResolver = NodeHostNameResolver diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/channel/node/node-channel.js b/packages/neo4j-driver-deno/lib/bolt-connection/channel/node/node-channel.js new file mode 100644 index 000000000..c545adbbf --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/channel/node/node-channel.js @@ -0,0 +1,423 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 net from 'net' +import tls from 'tls' +import fs from 'fs' +import ChannelBuffer from '../channel-buf.js' +import { newError, internal } from '../../../core/index.ts' + +const { + util: { ENCRYPTION_OFF, ENCRYPTION_ON, isEmptyObjectOrNull } +} = internal + +let _CONNECTION_IDGEN = 0 + +const TrustStrategy = { + TRUST_CUSTOM_CA_SIGNED_CERTIFICATES: function (config, onSuccess, onFailure) { + if ( + !config.trustedCertificates || + config.trustedCertificates.length === 0 + ) { + onFailure( + newError( + 'You are using TRUST_CUSTOM_CA_SIGNED_CERTIFICATES as the method ' + + 'to verify trust for encrypted connections, but have not configured any ' + + 'trustedCertificates. You must specify the path to at least one trusted ' + + 'X.509 certificate for this to work. Two other alternatives is to use ' + + 'TRUST_ALL_CERTIFICATES or to disable encryption by setting encrypted="' + + ENCRYPTION_OFF + + '"' + + 'in your driver configuration.' + ) + ) + return + } + + const tlsOpts = newTlsOptions( + config.address.host(), + config.trustedCertificates.map(f => fs.readFileSync(f)) + ) + const socket = tls.connect( + config.address.port(), + config.address.resolvedHost(), + tlsOpts, + function () { + if (!socket.authorized) { + onFailure( + newError( + 'Server certificate is not trusted. If you trust the database you are connecting to, add' + + ' the signing certificate, or the server certificate, to the list of certificates trusted by this driver' + + " using `neo4j.driver(.., { trustedCertificates:['path/to/certificate.crt']}). This " + + ' is a security measure to protect against man-in-the-middle attacks. If you are just trying ' + + ' Neo4j out and are not concerned about encryption, simply disable it using `encrypted="' + + ENCRYPTION_OFF + + '"`' + + ' in the driver options. Socket responded with: ' + + socket.authorizationError + ) + ) + } else { + onSuccess() + } + } + ) + socket.on('error', onFailure) + return configureSocket(socket) + }, + TRUST_SYSTEM_CA_SIGNED_CERTIFICATES: function (config, onSuccess, onFailure) { + const tlsOpts = newTlsOptions(config.address.host()) + const socket = tls.connect( + config.address.port(), + config.address.resolvedHost(), + tlsOpts, + function () { + if (!socket.authorized) { + onFailure( + newError( + 'Server certificate is not trusted. If you trust the database you are connecting to, use ' + + 'TRUST_CUSTOM_CA_SIGNED_CERTIFICATES and add' + + ' the signing certificate, or the server certificate, to the list of certificates trusted by this driver' + + " using `neo4j.driver(.., { trustedCertificates:['path/to/certificate.crt']}). This " + + ' is a security measure to protect against man-in-the-middle attacks. If you are just trying ' + + ' Neo4j out and are not concerned about encryption, simply disable it using `encrypted="' + + ENCRYPTION_OFF + + '"`' + + ' in the driver options. Socket responded with: ' + + socket.authorizationError + ) + ) + } else { + onSuccess() + } + } + ) + socket.on('error', onFailure) + return configureSocket(socket) + }, + TRUST_ALL_CERTIFICATES: function (config, onSuccess, onFailure) { + const tlsOpts = newTlsOptions(config.address.host()) + const socket = tls.connect( + config.address.port(), + config.address.resolvedHost(), + tlsOpts, + function () { + const certificate = socket.getPeerCertificate() + if (isEmptyObjectOrNull(certificate)) { + onFailure( + newError( + 'Secure connection was successful but server did not return any valid ' + + 'certificates. Such connection can not be trusted. If you are just trying ' + + ' Neo4j out and are not concerned about encryption, simply disable it using ' + + '`encrypted="' + + ENCRYPTION_OFF + + '"` in the driver options. ' + + 'Socket responded with: ' + + socket.authorizationError + ) + ) + } else { + onSuccess() + } + } + ) + socket.on('error', onFailure) + return configureSocket(socket) + } +} + +/** + * Connect using node socket. + * @param {ChannelConfig} config - configuration of this channel. + * @param {function} onSuccess - callback to execute on connection success. + * @param {function} onFailure - callback to execute on connection failure. + * @return {*} socket connection. + */ +function _connect (config, onSuccess, onFailure = () => null) { + const trustStrategy = trustStrategyName(config) + if (!isEncrypted(config)) { + const socket = net.connect( + config.address.port(), + config.address.resolvedHost(), + onSuccess + ) + socket.on('error', onFailure) + return configureSocket(socket) + } else if (TrustStrategy[trustStrategy]) { + return TrustStrategy[trustStrategy](config, onSuccess, onFailure) + } else { + onFailure( + newError( + 'Unknown trust strategy: ' + + config.trust + + '. Please use either ' + + "trust:'TRUST_CUSTOM_CA_SIGNED_CERTIFICATES' or trust:'TRUST_ALL_CERTIFICATES' in your driver " + + 'configuration. Alternatively, you can disable encryption by setting ' + + '`encrypted:"' + + ENCRYPTION_OFF + + '"`. There is no mechanism to use encryption without trust verification, ' + + 'because this incurs the overhead of encryption without improving security. If ' + + 'the driver does not verify that the peer it is connected to is really Neo4j, it ' + + 'is very easy for an attacker to bypass the encryption by pretending to be Neo4j.' + ) + ) + } +} + +function isEncrypted (config) { + const encryptionNotConfigured = + config.encrypted == null || config.encrypted === undefined + if (encryptionNotConfigured) { + // default to using encryption if trust-all-certificates is available + return false + } + return config.encrypted === true || config.encrypted === ENCRYPTION_ON +} + +function trustStrategyName (config) { + if (config.trust) { + return config.trust + } + return 'TRUST_SYSTEM_CA_SIGNED_CERTIFICATES' +} + +/** + * Create a new configuration options object for the {@code tls.connect()} call. + * @param {string} hostname the target hostname. + * @param {string|undefined} ca an optional CA. + * @return {Object} a new options object. + */ +function newTlsOptions (hostname, ca = undefined) { + return { + rejectUnauthorized: false, // we manually check for this in the connect callback, to give a more helpful error to the user + servername: hostname, // server name for the SNI (Server Name Indication) TLS extension + ca: ca // optional CA useful for TRUST_CUSTOM_CA_SIGNED_CERTIFICATES trust mode + } +} + +/** + * Update socket options for the newly created socket. Accepts either `net.Socket` or its subclass `tls.TLSSocket`. + * @param {net.Socket} socket the socket to configure. + * @return {net.Socket} the given socket. + */ +function configureSocket (socket) { + socket.setKeepAlive(true) + return socket +} + +/** + * In a Node.js environment the 'net' module is used + * as transport. + * @access private + */ +export default class NodeChannel { + /** + * Create new instance + * @param {ChannelConfig} config - configuration for this channel. + */ + constructor (config, connect = _connect) { + const self = this + + this.id = _CONNECTION_IDGEN++ + this._pending = [] + this._open = true + this._error = null + this._handleConnectionError = this._handleConnectionError.bind(this) + this._handleConnectionTerminated = this._handleConnectionTerminated.bind( + this + ) + this._connectionErrorCode = config.connectionErrorCode + this._receiveTimeout = null + this._receiveTimeoutStarted = false + + this._conn = connect( + config, + () => { + if (!self._open) { + return + } + + self._conn.on('data', buffer => { + if (self.onmessage) { + self.onmessage(new ChannelBuffer(buffer)) + } + }) + + self._conn.on('error', self._handleConnectionError) + self._conn.on('end', self._handleConnectionTerminated) + + // Drain all pending messages + const pending = self._pending + self._pending = null + for (let i = 0; i < pending.length; i++) { + self.write(pending[i]) + } + }, + this._handleConnectionError + ) + + this._setupConnectionTimeout(config, this._conn) + } + + _handleConnectionError (err) { + let msg = + 'Failed to connect to server. ' + + 'Please ensure that your database is listening on the correct host and port ' + + 'and that you have compatible encryption settings both on Neo4j server and driver. ' + + 'Note that the default encryption setting has changed in Neo4j 4.0.' + if (err.message) msg += ' Caused by: ' + err.message + this._error = newError(msg, this._connectionErrorCode) + if (this.onerror) { + this.onerror(this._error) + } + } + + _handleConnectionTerminated () { + this._open = false + this._error = newError( + 'Connection was closed by server', + this._connectionErrorCode + ) + if (this.onerror) { + this.onerror(this._error) + } + } + + /** + * Setup connection timeout on the socket, if configured. + * @param {ChannelConfig} config - configuration of this channel. + * @param {Object} socket - `net.Socket` or `tls.TLSSocket` object. + * @private + */ + _setupConnectionTimeout (config, socket) { + const timeout = config.connectionTimeout + if (timeout) { + const connectListener = () => { + // connected - clear connection timeout + socket.setTimeout(0) + } + + const timeoutListener = () => { + // timeout fired - not connected within configured time. cancel timeout and destroy socket + socket.setTimeout(0) + socket.destroy( + newError( + `Failed to establish connection in ${timeout}ms`, + config.connectionErrorCode + ) + ) + } + + socket.on('connect', connectListener) + socket.on('timeout', timeoutListener) + + this._removeConnectionTimeoutListeners = () => { + this._conn.off('connect', connectListener) + this._conn.off('timeout', timeoutListener) + } + + socket.setTimeout(timeout) + } + } + + /** + * Setup the receive timeout for the channel. + * + * @param {number} receiveTimeout How long the channel will wait for receiving data before timing out (ms) + * @returns {void} + */ + setupReceiveTimeout (receiveTimeout) { + if (this._removeConnectionTimeoutListeners) { + this._removeConnectionTimeoutListeners() + } + + this._conn.on('timeout', () => { + this._conn.destroy( + newError( + `Connection lost. Server didn't respond in ${receiveTimeout}ms`, + this._connectionErrorCode + ) + ) + }) + + this._receiveTimeout = receiveTimeout + } + + /** + * Stops the receive timeout for the channel. + */ + stopReceiveTimeout () { + if (this._receiveTimeout !== null && this._receiveTimeoutStarted) { + this._receiveTimeoutStarted = false + this._conn.setTimeout(0) + } + } + + /** + * Start the receive timeout for the channel. + */ + startReceiveTimeout () { + if (this._receiveTimeout !== null && !this._receiveTimeoutStarted) { + this._receiveTimeoutStarted = true + this._conn.setTimeout(this._receiveTimeout) + } + } + + /** + * Write the passed in buffer to connection + * @param {ChannelBuffer} buffer - Buffer to write + */ + write (buffer) { + // If there is a pending queue, push this on that queue. This means + // we are not yet connected, so we queue things locally. + if (this._pending !== null) { + this._pending.push(buffer) + } else if (buffer instanceof ChannelBuffer) { + this._conn.write(buffer._buffer) + } else { + throw newError("Don't know how to write: " + buffer) + } + } + + /** + * Close the connection + * @returns {Promise} A promise that will be resolved after channel is closed + */ + close () { + return new Promise((resolve, reject) => { + const cleanup = () => { + if (!this._conn.destroyed) { + this._conn.destroy() + } + + resolve() + } + + if (this._open) { + this._open = false + this._conn.removeListener('end', this._handleConnectionTerminated) + this._conn.on('end', () => cleanup()) + this._conn.on('close', () => cleanup()) + this._conn.end() + this._conn.destroy() + } else { + cleanup() + } + }) + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/channel/node/node-host-name-resolver.js b/packages/neo4j-driver-deno/lib/bolt-connection/channel/node/node-host-name-resolver.js new file mode 100644 index 000000000..6b1040bdd --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/channel/node/node-host-name-resolver.js @@ -0,0 +1,42 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 nodeDns from 'dns' +import { internal } from '../../../core/index.ts' + +const { + resolver: { BaseHostNameResolver } +} = internal + +export default class NodeHostNameResolver extends BaseHostNameResolver { + resolve (address) { + return new Promise(resolve => { + nodeDns.lookup(address.host(), { all: true }, (error, resolvedTo) => { + if (error) { + resolve([address]) + } else { + const resolvedAddresses = resolvedTo.map(a => + address.resolveWith(a.address) + ) + resolve(resolvedAddresses) + } + }) + }) + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/channel/utf8.js b/packages/neo4j-driver-deno/lib/bolt-connection/channel/utf8.js new file mode 100644 index 000000000..4a6eb8eb8 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/channel/utf8.js @@ -0,0 +1,104 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 ChannelBuffer from './channel-buf.js' +import { newError } from '../../core/index.ts' +import buffer from 'https://deno.land/std@0.119.0/node/buffer.ts' +import { StringDecoder } from 'https://deno.land/std@0.119.0/node/string_decoder.ts' + +const decoder = new StringDecoder('utf8') + +function encode (str) { + return new ChannelBuffer(newBuffer(str)) +} + +function decode (buffer, length) { + if (Object.prototype.hasOwnProperty.call(buffer, '_buffer')) { + return decodeChannelBuffer(buffer, length) + } else if (Object.prototype.hasOwnProperty.call(buffer, '_buffers')) { + return decodeCombinedBuffer(buffer, length) + } else { + throw newError(`Don't know how to decode strings from '${buffer}'`) + } +} + +function decodeChannelBuffer (buffer, length) { + const start = buffer.position + const end = start + length + buffer.position = Math.min(end, buffer.length) + return buffer._buffer.toString('utf8', start, end) +} + +function decodeCombinedBuffer (buffer, length) { + return streamDecodeCombinedBuffer( + buffer, + length, + partBuffer => decoder.write(partBuffer._buffer), + () => decoder.end() + ) +} + +function streamDecodeCombinedBuffer (combinedBuffers, length, decodeFn, endFn) { + let remainingBytesToRead = length + let position = combinedBuffers.position + combinedBuffers._updatePos( + Math.min(length, combinedBuffers.length - position) + ) + // Reduce CombinedBuffers to a decoded string + const out = combinedBuffers._buffers.reduce(function (last, partBuffer) { + if (remainingBytesToRead <= 0) { + return last + } else if (position >= partBuffer.length) { + position -= partBuffer.length + return '' + } else { + partBuffer._updatePos(position - partBuffer.position) + const bytesToRead = Math.min( + partBuffer.length - position, + remainingBytesToRead + ) + const lastSlice = partBuffer.readSlice(bytesToRead) + partBuffer._updatePos(bytesToRead) + remainingBytesToRead = Math.max( + remainingBytesToRead - lastSlice.length, + 0 + ) + position = 0 + return last + decodeFn(lastSlice) + } + }, '') + return out + endFn() +} + +function newBuffer (str) { + // use static factory function present in newer NodeJS versions to create a buffer containing the given string + // or fallback to the old, potentially deprecated constructor + + if (typeof buffer.Buffer.from === 'function') { + return buffer.Buffer.from(str, 'utf8') + } else { + // eslint-disable-next-line node/no-deprecated-api + return new buffer.Buffer(str, 'utf8') + } +} + +export default { + encode, + decode +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/connection-provider/connection-provider-direct.js b/packages/neo4j-driver-deno/lib/bolt-connection/connection-provider/connection-provider-direct.js new file mode 100644 index 000000000..9895701c7 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/connection-provider/connection-provider-direct.js @@ -0,0 +1,117 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 PooledConnectionProvider from './connection-provider-pooled.js' +import { + createChannelConnection, + DelegateConnection, + ConnectionErrorHandler +} from '../connection/index.js' +import { internal, error } from '../../core/index.ts' + +const { + constants: { BOLT_PROTOCOL_V3, BOLT_PROTOCOL_V4_0, BOLT_PROTOCOL_V4_4 } +} = internal + +const { SERVICE_UNAVAILABLE } = error + +export default class DirectConnectionProvider extends PooledConnectionProvider { + constructor ({ id, config, log, address, userAgent, authToken }) { + super({ id, config, log, userAgent, authToken }) + + this._address = address + } + + /** + * See {@link ConnectionProvider} for more information about this method and + * its arguments. + */ + acquireConnection ({ accessMode, database, bookmarks } = {}) { + const databaseSpecificErrorHandler = ConnectionErrorHandler.create({ + errorCode: SERVICE_UNAVAILABLE, + handleAuthorizationExpired: (error, address) => + this._handleAuthorizationExpired(error, address, database) + }) + + return this._connectionPool + .acquire(this._address) + .then( + connection => + new DelegateConnection(connection, databaseSpecificErrorHandler) + ) + } + + _handleAuthorizationExpired (error, address, database) { + this._log.warn( + `Direct driver ${this._id} will close connection to ${address} for database '${database}' because of an error ${error.code} '${error.message}'` + ) + this._connectionPool.purge(address).catch(() => {}) + return error + } + + async _hasProtocolVersion (versionPredicate) { + const connection = await createChannelConnection( + this._address, + this._config, + this._createConnectionErrorHandler(), + this._log + ) + + const protocolVersion = connection.protocol() + ? connection.protocol().version + : null + + await connection.close() + + if (protocolVersion) { + return versionPredicate(protocolVersion) + } + + return false + } + + async supportsMultiDb () { + return await this._hasProtocolVersion( + version => version >= BOLT_PROTOCOL_V4_0 + ) + } + + getNegotiatedProtocolVersion () { + return new Promise((resolve, reject) => { + this._hasProtocolVersion(resolve) + .catch(reject) + }) + } + + async supportsTransactionConfig () { + return await this._hasProtocolVersion( + version => version >= BOLT_PROTOCOL_V3 + ) + } + + async supportsUserImpersonation () { + return await this._hasProtocolVersion( + version => version >= BOLT_PROTOCOL_V4_4 + ) + } + + async verifyConnectivityAndGetServerInfo () { + return await this._verifyConnectivityAndGetServerVersion({ address: this._address }) + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/connection-provider/connection-provider-pooled.js b/packages/neo4j-driver-deno/lib/bolt-connection/connection-provider/connection-provider-pooled.js new file mode 100644 index 000000000..208cbd585 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/connection-provider/connection-provider-pooled.js @@ -0,0 +1,149 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { createChannelConnection, ConnectionErrorHandler } from '../connection/index.js' +import Pool, { PoolConfig } from '../pool/index.js' +import { error, ConnectionProvider, ServerInfo } from '../../core/index.ts' + +const { SERVICE_UNAVAILABLE } = error +export default class PooledConnectionProvider extends ConnectionProvider { + constructor ( + { id, config, log, userAgent, authToken }, + createChannelConnectionHook = null + ) { + super() + + this._id = id + this._config = config + this._log = log + this._userAgent = userAgent + this._authToken = authToken + this._createChannelConnection = + createChannelConnectionHook || + (address => { + return createChannelConnection( + address, + this._config, + this._createConnectionErrorHandler(), + this._log + ) + }) + this._connectionPool = new Pool({ + create: this._createConnection.bind(this), + destroy: this._destroyConnection.bind(this), + validate: this._validateConnection.bind(this), + installIdleObserver: PooledConnectionProvider._installIdleObserverOnConnection.bind( + this + ), + removeIdleObserver: PooledConnectionProvider._removeIdleObserverOnConnection.bind( + this + ), + config: PoolConfig.fromDriverConfig(config), + log: this._log + }) + this._openConnections = {} + } + + _createConnectionErrorHandler () { + return new ConnectionErrorHandler(SERVICE_UNAVAILABLE) + } + + /** + * Create a new connection and initialize it. + * @return {Promise} promise resolved with a new connection or rejected when failed to connect. + * @access private + */ + _createConnection (address, release) { + return this._createChannelConnection(address).then(connection => { + connection._release = () => { + return release(address, connection) + } + this._openConnections[connection.id] = connection + return connection + .connect(this._userAgent, this._authToken) + .catch(error => { + // let's destroy this connection + this._destroyConnection(connection) + // propagate the error because connection failed to connect / initialize + throw error + }) + }) + } + + /** + * Check that a connection is usable + * @return {boolean} true if the connection is open + * @access private + **/ + _validateConnection (conn) { + if (!conn.isOpen()) { + return false + } + + const maxConnectionLifetime = this._config.maxConnectionLifetime + const lifetime = Date.now() - conn.creationTimestamp + return lifetime <= maxConnectionLifetime + } + + /** + * Dispose of a connection. + * @return {Connection} the connection to dispose. + * @access private + */ + _destroyConnection (conn) { + delete this._openConnections[conn.id] + return conn.close() + } + + /** + * Acquire a connection from the pool and return it ServerInfo + * @param {object} param + * @param {string} param.address the server address + * @return {Promise} the server info + */ + async _verifyConnectivityAndGetServerVersion ({ address }) { + const connection = await this._connectionPool.acquire(address) + const serverInfo = new ServerInfo(connection.server, connection.protocol().version) + try { + if (!connection.protocol().isLastMessageLogin()) { + await connection.resetAndFlush() + } + } finally { + await connection._release() + } + return serverInfo + } + + async close () { + // purge all idle connections in the connection pool + await this._connectionPool.close() + + // then close all connections driver has ever created + // it is needed to close connections that are active right now and are acquired from the pool + await Promise.all(Object.values(this._openConnections).map(c => c.close())) + } + + static _installIdleObserverOnConnection (conn, observer) { + conn._queueObserver(observer) + } + + static _removeIdleObserverOnConnection (conn) { + conn._updateCurrentObserver() + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/connection-provider/connection-provider-routing.js b/packages/neo4j-driver-deno/lib/bolt-connection/connection-provider/connection-provider-routing.js new file mode 100644 index 000000000..d07010cfc --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/connection-provider/connection-provider-routing.js @@ -0,0 +1,715 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { newError, error, int, Session, internal } from '../../core/index.ts' +import Rediscovery, { RoutingTable } from '../rediscovery/index.js' +import { HostNameResolver } from '../channel/index.js' +import SingleConnectionProvider from './connection-provider-single.js' +import PooledConnectionProvider from './connection-provider-pooled.js' +import { LeastConnectedLoadBalancingStrategy } from '../load-balancing/index.js' +import { + createChannelConnection, + ConnectionErrorHandler, + DelegateConnection +} from '../connection/index.js' + +const { SERVICE_UNAVAILABLE, SESSION_EXPIRED } = error +const { + bookmarks: { Bookmarks }, + constants: { + ACCESS_MODE_READ: READ, + ACCESS_MODE_WRITE: WRITE, + BOLT_PROTOCOL_V3, + BOLT_PROTOCOL_V4_0, + BOLT_PROTOCOL_V4_4 + } +} = internal + +const PROCEDURE_NOT_FOUND_CODE = 'Neo.ClientError.Procedure.ProcedureNotFound' +const DATABASE_NOT_FOUND_CODE = 'Neo.ClientError.Database.DatabaseNotFound' +const INVALID_BOOKMARK_CODE = 'Neo.ClientError.Transaction.InvalidBookmark' +const INVALID_BOOKMARK_MIXTURE_CODE = + 'Neo.ClientError.Transaction.InvalidBookmarkMixture' +const AUTHORIZATION_EXPIRED_CODE = + 'Neo.ClientError.Security.AuthorizationExpired' + +const SYSTEM_DB_NAME = 'system' +const DEFAULT_DB_NAME = null +const DEFAULT_ROUTING_TABLE_PURGE_DELAY = int(30000) + +export default class RoutingConnectionProvider extends PooledConnectionProvider { + constructor ({ + id, + address, + routingContext, + hostNameResolver, + config, + log, + userAgent, + authToken, + routingTablePurgeDelay + }) { + super({ id, config, log, userAgent, authToken }, address => { + return createChannelConnection( + address, + this._config, + this._createConnectionErrorHandler(), + this._log, + this._routingContext + ) + }) + + this._routingContext = { ...routingContext, address: address.toString() } + this._seedRouter = address + this._rediscovery = new Rediscovery(this._routingContext) + this._loadBalancingStrategy = new LeastConnectedLoadBalancingStrategy( + this._connectionPool + ) + this._hostNameResolver = hostNameResolver + this._dnsResolver = new HostNameResolver() + this._log = log + this._useSeedRouter = true + this._routingTableRegistry = new RoutingTableRegistry( + routingTablePurgeDelay + ? int(routingTablePurgeDelay) + : DEFAULT_ROUTING_TABLE_PURGE_DELAY + ) + } + + _createConnectionErrorHandler () { + // connection errors mean SERVICE_UNAVAILABLE for direct driver but for routing driver they should only + // result in SESSION_EXPIRED because there might still exist other servers capable of serving the request + return new ConnectionErrorHandler(SESSION_EXPIRED) + } + + _handleUnavailability (error, address, database) { + this._log.warn( + `Routing driver ${this._id} will forget ${address} for database '${database}' because of an error ${error.code} '${error.message}'` + ) + this.forget(address, database || DEFAULT_DB_NAME) + return error + } + + _handleAuthorizationExpired (error, address, database) { + this._log.warn( + `Routing driver ${this._id} will close connections to ${address} for database '${database}' because of an error ${error.code} '${error.message}'` + ) + this._connectionPool.purge(address).catch(() => {}) + return error + } + + _handleWriteFailure (error, address, database) { + this._log.warn( + `Routing driver ${this._id} will forget writer ${address} for database '${database}' because of an error ${error.code} '${error.message}'` + ) + this.forgetWriter(address, database || DEFAULT_DB_NAME) + return newError( + 'No longer possible to write to server at ' + address, + SESSION_EXPIRED, + error + ) + } + + /** + * See {@link ConnectionProvider} for more information about this method and + * its arguments. + */ + async acquireConnection ({ accessMode, database, bookmarks, impersonatedUser, onDatabaseNameResolved } = {}) { + let name + let address + const context = { database: database || DEFAULT_DB_NAME } + + const databaseSpecificErrorHandler = new ConnectionErrorHandler( + SESSION_EXPIRED, + (error, address) => this._handleUnavailability(error, address, context.database), + (error, address) => this._handleWriteFailure(error, address, context.database), + (error, address) => + this._handleAuthorizationExpired(error, address, context.database) + ) + + const routingTable = await this._freshRoutingTable({ + accessMode, + database: context.database, + bookmarks, + impersonatedUser, + onDatabaseNameResolved: (databaseName) => { + context.database = context.database || databaseName + if (onDatabaseNameResolved) { + onDatabaseNameResolved(databaseName) + } + } + }) + + // select a target server based on specified access mode + if (accessMode === READ) { + address = this._loadBalancingStrategy.selectReader(routingTable.readers) + name = 'read' + } else if (accessMode === WRITE) { + address = this._loadBalancingStrategy.selectWriter(routingTable.writers) + name = 'write' + } else { + throw newError('Illegal mode ' + accessMode) + } + + // we couldn't select a target server + if (!address) { + throw newError( + `Failed to obtain connection towards ${name} server. Known routing table is: ${routingTable}`, + SESSION_EXPIRED + ) + } + + try { + const connection = await this._acquireConnectionToServer( + address, + name, + routingTable + ) + + return new DelegateConnection(connection, databaseSpecificErrorHandler) + } catch (error) { + const transformed = databaseSpecificErrorHandler.handleAndTransformError( + error, + address + ) + throw transformed + } + } + + async _hasProtocolVersion (versionPredicate) { + const addresses = await this._resolveSeedRouter(this._seedRouter) + + let lastError + for (let i = 0; i < addresses.length; i++) { + try { + const connection = await createChannelConnection( + addresses[i], + this._config, + this._createConnectionErrorHandler(), + this._log + ) + const protocolVersion = connection.protocol() + ? connection.protocol().version + : null + + await connection.close() + + if (protocolVersion) { + return versionPredicate(protocolVersion) + } + + return false + } catch (error) { + lastError = error + } + } + + if (lastError) { + throw lastError + } + + return false + } + + async supportsMultiDb () { + return await this._hasProtocolVersion( + version => version >= BOLT_PROTOCOL_V4_0 + ) + } + + async supportsTransactionConfig () { + return await this._hasProtocolVersion( + version => version >= BOLT_PROTOCOL_V3 + ) + } + + async supportsUserImpersonation () { + return await this._hasProtocolVersion( + version => version >= BOLT_PROTOCOL_V4_4 + ) + } + + getNegotiatedProtocolVersion () { + return new Promise((resolve, reject) => { + this._hasProtocolVersion(resolve) + .catch(reject) + }) + } + + async verifyConnectivityAndGetServerInfo ({ database, accessMode }) { + const context = { database: database || DEFAULT_DB_NAME } + + const routingTable = await this._freshRoutingTable({ + accessMode, + database: context.database, + onDatabaseNameResolved: (databaseName) => { + context.database = context.database || databaseName + } + }) + + const servers = accessMode === WRITE ? routingTable.writers : routingTable.readers + + let error = newError( + `No servers available for database '${context.database}' with access mode '${accessMode}'`, + SERVICE_UNAVAILABLE + ) + + for (const address of servers) { + try { + const serverInfo = await this._verifyConnectivityAndGetServerVersion({ address }) + return serverInfo + } catch (e) { + error = e + } + } + throw error + } + + forget (address, database) { + this._routingTableRegistry.apply(database, { + applyWhenExists: routingTable => routingTable.forget(address) + }) + + // We're firing and forgetting this operation explicitly and listening for any + // errors to avoid unhandled promise rejection + this._connectionPool.purge(address).catch(() => {}) + } + + forgetWriter (address, database) { + this._routingTableRegistry.apply(database, { + applyWhenExists: routingTable => routingTable.forgetWriter(address) + }) + } + + _acquireConnectionToServer (address, serverName, routingTable) { + return this._connectionPool.acquire(address) + } + + _freshRoutingTable ({ accessMode, database, bookmarks, impersonatedUser, onDatabaseNameResolved } = {}) { + const currentRoutingTable = this._routingTableRegistry.get( + database, + () => new RoutingTable({ database }) + ) + + if (!currentRoutingTable.isStaleFor(accessMode)) { + return currentRoutingTable + } + this._log.info( + `Routing table is stale for database: "${database}" and access mode: "${accessMode}": ${currentRoutingTable}` + ) + return this._refreshRoutingTable(currentRoutingTable, bookmarks, impersonatedUser, onDatabaseNameResolved) + } + + _refreshRoutingTable (currentRoutingTable, bookmarks, impersonatedUser, onDatabaseNameResolved) { + const knownRouters = currentRoutingTable.routers + + if (this._useSeedRouter) { + return this._fetchRoutingTableFromSeedRouterFallbackToKnownRouters( + knownRouters, + currentRoutingTable, + bookmarks, + impersonatedUser, + onDatabaseNameResolved + ) + } + return this._fetchRoutingTableFromKnownRoutersFallbackToSeedRouter( + knownRouters, + currentRoutingTable, + bookmarks, + impersonatedUser, + onDatabaseNameResolved + ) + } + + async _fetchRoutingTableFromSeedRouterFallbackToKnownRouters ( + knownRouters, + currentRoutingTable, + bookmarks, + impersonatedUser, + onDatabaseNameResolved + ) { + // we start with seed router, no routers were probed before + const seenRouters = [] + let [newRoutingTable, error] = await this._fetchRoutingTableUsingSeedRouter( + seenRouters, + this._seedRouter, + currentRoutingTable, + bookmarks, + impersonatedUser + ) + + if (newRoutingTable) { + this._useSeedRouter = false + } else { + // seed router did not return a valid routing table - try to use other known routers + const [newRoutingTable2, error2] = await this._fetchRoutingTableUsingKnownRouters( + knownRouters, + currentRoutingTable, + bookmarks, + impersonatedUser + ) + newRoutingTable = newRoutingTable2 + error = error2 || error + } + + return await this._applyRoutingTableIfPossible( + currentRoutingTable, + newRoutingTable, + onDatabaseNameResolved, + error + ) + } + + async _fetchRoutingTableFromKnownRoutersFallbackToSeedRouter ( + knownRouters, + currentRoutingTable, + bookmarks, + impersonatedUser, + onDatabaseNameResolved + ) { + let [newRoutingTable, error] = await this._fetchRoutingTableUsingKnownRouters( + knownRouters, + currentRoutingTable, + bookmarks, + impersonatedUser + ) + + if (!newRoutingTable) { + // none of the known routers returned a valid routing table - try to use seed router address for rediscovery + [newRoutingTable, error] = await this._fetchRoutingTableUsingSeedRouter( + knownRouters, + this._seedRouter, + currentRoutingTable, + bookmarks, + impersonatedUser + ) + } + + return await this._applyRoutingTableIfPossible( + currentRoutingTable, + newRoutingTable, + onDatabaseNameResolved, + error + ) + } + + async _fetchRoutingTableUsingKnownRouters ( + knownRouters, + currentRoutingTable, + bookmarks, + impersonatedUser + ) { + const [newRoutingTable, error] = await this._fetchRoutingTable( + knownRouters, + currentRoutingTable, + bookmarks, + impersonatedUser + ) + + if (newRoutingTable) { + // one of the known routers returned a valid routing table - use it + return [newRoutingTable, null] + } + + // returned routing table was undefined, this means a connection error happened and the last known + // router did not return a valid routing table, so we need to forget it + const lastRouterIndex = knownRouters.length - 1 + RoutingConnectionProvider._forgetRouter( + currentRoutingTable, + knownRouters, + lastRouterIndex + ) + + return [null, error] + } + + async _fetchRoutingTableUsingSeedRouter ( + seenRouters, + seedRouter, + routingTable, + bookmarks, + impersonatedUser + ) { + const resolvedAddresses = await this._resolveSeedRouter(seedRouter) + + // filter out all addresses that we've already tried + const newAddresses = resolvedAddresses.filter( + address => seenRouters.indexOf(address) < 0 + ) + + return await this._fetchRoutingTable(newAddresses, routingTable, bookmarks, impersonatedUser) + } + + async _resolveSeedRouter (seedRouter) { + const resolvedAddresses = await this._hostNameResolver.resolve(seedRouter) + const dnsResolvedAddresses = await Promise.all( + resolvedAddresses.map(address => this._dnsResolver.resolve(address)) + ) + + return [].concat.apply([], dnsResolvedAddresses) + } + + async _fetchRoutingTable (routerAddresses, routingTable, bookmarks, impersonatedUser) { + return routerAddresses.reduce( + async (refreshedTablePromise, currentRouter, currentIndex) => { + const [newRoutingTable] = await refreshedTablePromise + + if (newRoutingTable) { + // valid routing table was fetched - just return it, try next router otherwise + return [newRoutingTable, null] + } else { + // returned routing table was undefined, this means a connection error happened and we need to forget the + // previous router and try the next one + const previousRouterIndex = currentIndex - 1 + RoutingConnectionProvider._forgetRouter( + routingTable, + routerAddresses, + previousRouterIndex + ) + } + + // try next router + const [session, error] = await this._createSessionForRediscovery( + currentRouter, + bookmarks, + impersonatedUser + ) + if (session) { + try { + return [await this._rediscovery.lookupRoutingTableOnRouter( + session, + routingTable.database, + currentRouter, + impersonatedUser + ), null] + } catch (error) { + return this._handleRediscoveryError(error, currentRouter) + } finally { + session.close() + } + } else { + // unable to acquire connection and create session towards the current router + // return null to signal that the next router should be tried + return [null, error] + } + }, + Promise.resolve([null, null]) + ) + } + + async _createSessionForRediscovery (routerAddress, bookmarks, impersonatedUser) { + try { + const connection = await this._connectionPool.acquire(routerAddress) + + const databaseSpecificErrorHandler = ConnectionErrorHandler.create({ + errorCode: SESSION_EXPIRED, + handleAuthorizationExpired: (error, address) => this._handleAuthorizationExpired(error, address) + }) + + const connectionProvider = new SingleConnectionProvider( + new DelegateConnection(connection, databaseSpecificErrorHandler)) + + const protocolVersion = connection.protocol().version + if (protocolVersion < 4.0) { + return [new Session({ + mode: WRITE, + bookmarks: Bookmarks.empty(), + connectionProvider + }), null] + } + + return [new Session({ + mode: READ, + database: SYSTEM_DB_NAME, + bookmarks, + connectionProvider, + impersonatedUser + }), null] + } catch (error) { + return this._handleRediscoveryError(error, routerAddress) + } + } + + _handleRediscoveryError (error, routerAddress) { + if (_isFailFastError(error) || _isFailFastSecurityError(error)) { + throw error + } else if (error.code === PROCEDURE_NOT_FOUND_CODE) { + // throw when getServers procedure not found because this is clearly a configuration issue + throw newError( + `Server at ${routerAddress.asHostPort()} can't perform routing. Make sure you are connecting to a causal cluster`, + SERVICE_UNAVAILABLE, + error + ) + } + this._log.warn( + `unable to fetch routing table because of an error ${error}` + ) + return [null, error] + } + + async _applyRoutingTableIfPossible (currentRoutingTable, newRoutingTable, onDatabaseNameResolved, error) { + if (!newRoutingTable) { + // none of routing servers returned valid routing table, throw exception + throw newError( + `Could not perform discovery. No routing servers available. Known routing table: ${currentRoutingTable}`, + SERVICE_UNAVAILABLE, + error + ) + } + + if (newRoutingTable.writers.length === 0) { + // use seed router next time. this is important when cluster is partitioned. it tries to make sure driver + // does not always get routing table without writers because it talks exclusively to a minority partition + this._useSeedRouter = true + } + + await this._updateRoutingTable(newRoutingTable, onDatabaseNameResolved) + + return newRoutingTable + } + + async _updateRoutingTable (newRoutingTable, onDatabaseNameResolved) { + // close old connections to servers not present in the new routing table + await this._connectionPool.keepAll(newRoutingTable.allServers()) + this._routingTableRegistry.removeExpired() + this._routingTableRegistry.register( + newRoutingTable + ) + + onDatabaseNameResolved(newRoutingTable.database) + + this._log.info(`Updated routing table ${newRoutingTable}`) + } + + static _forgetRouter (routingTable, routersArray, routerIndex) { + const address = routersArray[routerIndex] + if (routingTable && address) { + routingTable.forgetRouter(address) + } + } +} + +/** + * Responsible for keeping track of the existing routing tables + */ +class RoutingTableRegistry { + /** + * Constructor + * @param {int} routingTablePurgeDelay The routing table purge delay + */ + constructor (routingTablePurgeDelay) { + this._tables = new Map() + this._routingTablePurgeDelay = routingTablePurgeDelay + } + + /** + * Put a routing table in the registry + * + * @param {RoutingTable} table The routing table + * @returns {RoutingTableRegistry} this + */ + register (table) { + this._tables.set(table.database, table) + return this + } + + /** + * Apply function in the routing table for an specific database. If the database name is not defined, the function will + * be applied for each element + * + * @param {string} database The database name + * @param {object} callbacks The actions + * @param {function (RoutingTable)} callbacks.applyWhenExists Call when the db exists or when the database property is not informed + * @param {function ()} callbacks.applyWhenDontExists Call when the database doesn't have the routing table registred + * @returns {RoutingTableRegistry} this + */ + apply (database, { applyWhenExists, applyWhenDontExists = () => {} } = {}) { + if (this._tables.has(database)) { + applyWhenExists(this._tables.get(database)) + } else if (typeof database === 'string' || database === null) { + applyWhenDontExists() + } else { + this._forEach(applyWhenExists) + } + return this + } + + /** + * Retrieves a routing table from a given database name + * + * @param {string|impersonatedUser} impersonatedUser The impersonated User + * @param {string} database The database name + * @param {function()|RoutingTable} defaultSupplier The routing table supplier, if it's not a function or not exists, it will return itself as default value + * @returns {RoutingTable} The routing table for the respective database + */ + get (database, defaultSupplier) { + if (this._tables.has(database)) { + return this._tables.get(database) + } + return typeof defaultSupplier === 'function' + ? defaultSupplier() + : defaultSupplier + } + + /** + * Remove the routing table which is already expired + * @returns {RoutingTableRegistry} this + */ + removeExpired () { + return this._removeIf(value => + value.isExpiredFor(this._routingTablePurgeDelay) + ) + } + + _forEach (apply) { + for (const [, value] of this._tables) { + apply(value) + } + return this + } + + _remove (key) { + this._tables.delete(key) + return this + } + + _removeIf (predicate) { + for (const [key, value] of this._tables) { + if (predicate(value)) { + this._remove(key) + } + } + return this + } +} + +function _isFailFastError (error) { + return [ + DATABASE_NOT_FOUND_CODE, + INVALID_BOOKMARK_CODE, + INVALID_BOOKMARK_MIXTURE_CODE + ].includes(error.code) +} + +function _isFailFastSecurityError (error) { + return error.code.startsWith('Neo.ClientError.Security.') && + ![ + AUTHORIZATION_EXPIRED_CODE + ].includes(error.code) +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/connection-provider/connection-provider-single.js b/packages/neo4j-driver-deno/lib/bolt-connection/connection-provider/connection-provider-single.js new file mode 100644 index 000000000..52b618725 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/connection-provider/connection-provider-single.js @@ -0,0 +1,37 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { ConnectionProvider } from '../../core/index.ts' + +export default class SingleConnectionProvider extends ConnectionProvider { + constructor (connection) { + super() + this._connection = connection + } + + /** + * See {@link ConnectionProvider} for more information about this method and + * its arguments. + */ + acquireConnection ({ accessMode, database, bookmarks } = {}) { + const connection = this._connection + this._connection = null + return Promise.resolve(connection) + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/connection-provider/index.js b/packages/neo4j-driver-deno/lib/bolt-connection/connection-provider/index.js new file mode 100644 index 000000000..e6d230b1f --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/connection-provider/index.js @@ -0,0 +1,22 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ +export { default as SingleConnectionProvider } from './connection-provider-single.js' +export { default as PooledConnectionProvider } from './connection-provider-pooled.js' +export { default as DirectConnectionProvider } from './connection-provider-direct.js' +export { default as RoutingConnectionProvider } from './connection-provider-routing.js' 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 new file mode 100644 index 000000000..8dbcbc509 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/connection/connection-channel.js @@ -0,0 +1,448 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { Chunker, Dechunker, ChannelConfig, Channel } from '../channel/index.js' +import { newError, error, json, internal, toNumber } from '../../core/index.ts' +import Connection from './connection.js' +import Bolt from '../bolt/index.js' + +const { PROTOCOL_ERROR } = error +const { + logger: { Logger } +} = internal + +let idGenerator = 0 + +/** + * Crete new connection to the provided address. Returned connection is not connected. + * @param {ServerAddress} address - the Bolt endpoint to connect to. + * @param {Object} config - the driver configuration. + * @param {ConnectionErrorHandler} errorHandler - the error handler for connection errors. + * @param {Logger} log - configured logger. + * @return {Connection} - new connection. + */ +export function createChannelConnection ( + address, + config, + errorHandler, + log, + serversideRouting = null, + createChannel = channelConfig => new Channel(channelConfig) +) { + const channelConfig = new ChannelConfig( + address, + config, + errorHandler.errorCode() + ) + + const channel = createChannel(channelConfig) + + return Bolt.handshake(channel) + .then(({ protocolVersion: version, consumeRemainingBuffer }) => { + const chunker = new Chunker(channel) + const dechunker = new Dechunker() + const createProtocol = conn => + Bolt.create({ + version, + channel, + chunker, + dechunker, + disableLosslessIntegers: config.disableLosslessIntegers, + useBigInt: config.useBigInt, + serversideRouting, + server: conn.server, + log: conn.logger, + observer: { + onPendingObserversChange: conn._handleOngoingRequestsNumberChange.bind(conn), + onError: conn._handleFatalError.bind(conn), + onFailure: conn._resetOnFailure.bind(conn), + onProtocolError: conn._handleProtocolError.bind(conn), + onErrorApplyTransformation: error => + conn.handleAndTransformError(error, conn._address) + } + }) + + const connection = new ChannelConnection( + channel, + errorHandler, + address, + log, + config.disableLosslessIntegers, + serversideRouting, + chunker, + createProtocol + ) + + // forward all pending bytes to the dechunker + consumeRemainingBuffer(buffer => dechunker.write(buffer)) + + return connection + }) + .catch(reason => + channel.close().then(() => { + throw reason + }) + ) +} +export default class ChannelConnection extends Connection { + /** + * @constructor + * @param {Channel} channel - channel with a 'write' function and a 'onmessage' callback property. + * @param {ConnectionErrorHandler} errorHandler the error handler. + * @param {ServerAddress} address - the server address to connect to. + * @param {Logger} log - the configured logger. + * @param {boolean} disableLosslessIntegers if this connection should convert all received integers to native JS numbers. + * @param {Chunker} chunker the chunker + * @param protocolSupplier Bolt protocol supplier + */ + constructor ( + channel, + errorHandler, + address, + log, + disableLosslessIntegers = false, + serversideRouting = null, + chunker, // to be removed, + protocolSupplier + ) { + super(errorHandler) + + this._reseting = false + this._resetObservers = [] + this._id = idGenerator++ + this._address = address + this._server = { address: address.asHostPort() } + this.creationTimestamp = Date.now() + this._disableLosslessIntegers = disableLosslessIntegers + this._ch = channel + this._chunker = chunker + this._log = createConnectionLogger(this, log) + this._serversideRouting = serversideRouting + + // connection from the database, returned in response for HELLO message and might not be available + this._dbConnectionId = null + + // bolt protocol is initially not initialized + /** + * @private + * @type {BoltProtocol} + */ + this._protocol = protocolSupplier(this) + + // Set to true on fatal errors, to get this out of connection pool. + this._isBroken = false + + if (this._log.isDebugEnabled()) { + this._log.debug(`created towards ${address}`) + } + } + + get id () { + return this._id + } + + get databaseId () { + return this._dbConnectionId + } + + set databaseId (value) { + this._dbConnectionId = value + } + + /** + * Send initialization message. + * @param {string} userAgent the user agent for this driver. + * @param {Object} authToken the object containing auth information. + * @return {Promise} promise resolved with the current connection if connection is successful. Rejected promise otherwise. + */ + connect (userAgent, authToken) { + return this._initialize(userAgent, authToken) + } + + /** + * Perform protocol-specific initialization which includes authentication. + * @param {string} userAgent the user agent for this driver. + * @param {Object} authToken the object containing auth information. + * @return {Promise} promise resolved with the current connection if initialization is successful. Rejected promise otherwise. + */ + _initialize (userAgent, authToken) { + const self = this + return new Promise((resolve, reject) => { + this._protocol.initialize({ + userAgent, + authToken, + onError: err => reject(err), + onComplete: metadata => { + if (metadata) { + // read server version from the response metadata, if it is available + const serverVersion = metadata.server + if (!this.version || serverVersion) { + this.version = serverVersion + } + + // read database connection id from the response metadata, if it is available + const dbConnectionId = metadata.connection_id + if (!this.databaseId) { + this.databaseId = dbConnectionId + } + + if (metadata.hints) { + const receiveTimeoutRaw = + metadata.hints['connection.recv_timeout_seconds'] + if ( + receiveTimeoutRaw !== null && + receiveTimeoutRaw !== undefined + ) { + const receiveTimeoutInSeconds = toNumber(receiveTimeoutRaw) + if ( + Number.isInteger(receiveTimeoutInSeconds) && + receiveTimeoutInSeconds > 0 + ) { + this._ch.setupReceiveTimeout(receiveTimeoutInSeconds * 1000) + } else { + this._log.info( + `Server located at ${this._address} supplied an invalid connection receive timeout value (${receiveTimeoutInSeconds}). ` + + 'Please, verify the server configuration and status because this can be the symptom of a bigger issue.' + ) + } + } + } + } + resolve(self) + } + }) + }) + } + + /** + * Get the Bolt protocol for the connection. + * @return {BoltProtocol} the protocol. + */ + protocol () { + return this._protocol + } + + get address () { + return this._address + } + + /** + * Get the version of the connected server. + * Available only after initialization + * + * @returns {ServerVersion} version + */ + get version () { + return this._server.version + } + + set version (value) { + this._server.version = value + } + + get server () { + return this._server + } + + get logger () { + return this._log + } + + /** + * "Fatal" means the connection is dead. Only call this if something + * happens that cannot be recovered from. This will lead to all subscribers + * failing, and the connection getting ejected from the session pool. + * + * @param error an error object, forwarded to all current and future subscribers + */ + _handleFatalError (error) { + this._isBroken = true + this._error = this.handleAndTransformError( + this._protocol.currentFailure || error, + this._address + ) + + if (this._log.isErrorEnabled()) { + this._log.error( + `experienced a fatal error caused by ${this._error} (${json.stringify(this._error)})` + ) + } + + this._protocol.notifyFatalError(this._error) + } + + /** + * This method still here because it's used by the {@link PooledConnectionProvider} + * + * @param {any} observer + */ + _queueObserver (observer) { + return this._protocol.queueObserverIfProtocolIsNotBroken(observer) + } + + hasOngoingObservableRequests () { + return this._protocol.hasOngoingObservableRequests() + } + + /** + * Send a RESET-message to the database. Message is immediately flushed to the network. + * @return {Promise} promise resolved when SUCCESS-message response arrives, or failed when other response messages arrives. + */ + resetAndFlush () { + return new Promise((resolve, reject) => { + this._reset({ + onError: error => { + if (this._isBroken) { + // handling a fatal error, no need to raise a protocol violation + reject(error) + } else { + const neo4jError = this._handleProtocolError( + 'Received FAILURE as a response for RESET: ' + error + ) + reject(neo4jError) + } + }, + onComplete: () => { + resolve() + } + }) + }) + } + + _resetOnFailure () { + if (!this.isOpen()) { + return + } + + this._reset({ + onError: () => { + this._protocol.resetFailure() + }, + onComplete: () => { + this._protocol.resetFailure() + } + }) + } + + _reset(observer) { + if (this._reseting) { + if (!this._protocol.isLastMessageReset()) { + this._protocol.reset({ + onError: error => { + observer.onError(error) + }, onComplete: () => { + observer.onComplete() + } + }) + } else { + this._resetObservers.push(observer) + } + return + } + + this._resetObservers.push(observer) + this._reseting = true + + const notifyFinish = (notify) => { + this._reseting = false + const observers = this._resetObservers + this._resetObservers = [] + observers.forEach(notify) + } + + this._protocol.reset({ + onError: error => { + notifyFinish(obs => obs.onError(error)) + }, onComplete: () => { + notifyFinish(obs => obs.onComplete()) + } + }) + } + + /* + * Pop next pending observer form the list of observers and make it current observer. + * @protected + */ + _updateCurrentObserver () { + this._protocol.updateCurrentObserver() + } + + /** Check if this connection is in working condition */ + isOpen () { + return !this._isBroken && this._ch._open + } + + /** + * Starts and stops the receive timeout timer. + * @param {number} requestsNumber Ongoing requests number + */ + _handleOngoingRequestsNumberChange (requestsNumber) { + if (requestsNumber === 0) { + this._ch.stopReceiveTimeout() + } else { + this._ch.startReceiveTimeout() + } + } + + /** + * Call close on the channel. + * @returns {Promise} - A promise that will be resolved when the underlying channel is closed. + */ + async close () { + if (this._log.isDebugEnabled()) { + this._log.debug('closing') + } + + if (this._protocol && this.isOpen()) { + // protocol has been initialized and this connection is healthy + // notify the database about the upcoming close of the connection + this._protocol.prepareToClose() + } + + await this._ch.close() + + if (this._log.isDebugEnabled()) { + this._log.debug('closed') + } + } + + toString () { + return `Connection [${this.id}][${this.databaseId || ''}]` + } + + _handleProtocolError (message) { + this._protocol.resetFailure() + this._updateCurrentObserver() + const error = newError(message, PROTOCOL_ERROR) + this._handleFatalError(error) + return error + } +} + +/** + * Creates a log with the connection info as prefix + * @param {Connection} connection The connection + * @param {Logger} logger The logger + * @returns {Logger} The new logger with enriched messages + */ +function createConnectionLogger (connection, logger) { + return new Logger(logger._level, (level, message) => + logger._loggerFunction(level, `${connection} ${message}`) + ) +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/connection/connection-delegate.js b/packages/neo4j-driver-deno/lib/bolt-connection/connection/connection-delegate.js new file mode 100644 index 000000000..6d195d1d9 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/connection/connection-delegate.js @@ -0,0 +1,101 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 Connection from './connection.js' + +export default class DelegateConnection extends Connection { + /** + * @param delegate {Connection} the delegated connection + * @param errorHandler {ConnectionErrorHandler} the error handler + */ + constructor (delegate, errorHandler) { + super(errorHandler) + + if (errorHandler) { + this._originalErrorHandler = delegate._errorHandler + delegate._errorHandler = this._errorHandler + } + + this._delegate = delegate + } + + get id () { + return this._delegate.id + } + + get databaseId () { + return this._delegate.databaseId + } + + set databaseId (value) { + this._delegate.databaseId = value + } + + get server () { + return this._delegate.server + } + + get address () { + return this._delegate.address + } + + get version () { + return this._delegate.version + } + + set version (value) { + this._delegate.version = value + } + + isOpen () { + return this._delegate.isOpen() + } + + protocol () { + return this._delegate.protocol() + } + + connect (userAgent, authToken) { + return this._delegate.connect(userAgent, authToken) + } + + write (message, observer, flush) { + return this._delegate.write(message, observer, flush) + } + + resetAndFlush () { + return this._delegate.resetAndFlush() + } + + hasOngoingObservableRequests () { + return this._delegate.hasOngoingObservableRequests() + } + + close () { + return this._delegate.close() + } + + _release () { + if (this._originalErrorHandler) { + this._delegate._errorHandler = this._originalErrorHandler + } + + return this._delegate._release() + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/connection/connection-error-handler.js b/packages/neo4j-driver-deno/lib/bolt-connection/connection/connection-error-handler.js new file mode 100644 index 000000000..8544c4c10 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/connection/connection-error-handler.js @@ -0,0 +1,109 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { error } from '../../core/index.ts' + +const { SERVICE_UNAVAILABLE, SESSION_EXPIRED } = error + +export default class ConnectionErrorHandler { + constructor ( + errorCode, + handleUnavailability, + handleWriteFailure, + handleAuthorizationExpired + ) { + this._errorCode = errorCode + this._handleUnavailability = handleUnavailability || noOpHandler + this._handleWriteFailure = handleWriteFailure || noOpHandler + this._handleAuthorizationExpired = handleAuthorizationExpired || noOpHandler + } + + static create ({ + errorCode, + handleUnavailability, + handleWriteFailure, + handleAuthorizationExpired + }) { + return new ConnectionErrorHandler( + errorCode, + handleUnavailability, + handleWriteFailure, + handleAuthorizationExpired + ) + } + + /** + * Error code to use for network errors. + * @return {string} the error code. + */ + errorCode () { + return this._errorCode + } + + /** + * Handle and transform the error. + * @param {Neo4jError} error the original error. + * @param {ServerAddress} address the address of the connection where the error happened. + * @return {Neo4jError} new error that should be propagated to the user. + */ + handleAndTransformError (error, address) { + if (isAutorizationExpiredError(error)) { + return this._handleAuthorizationExpired(error, address) + } + if (isAvailabilityError(error)) { + return this._handleUnavailability(error, address) + } + if (isFailureToWrite(error)) { + return this._handleWriteFailure(error, address) + } + return error + } +} + +function isAutorizationExpiredError (error) { + return error && ( + error.code === 'Neo.ClientError.Security.AuthorizationExpired' || + error.code === 'Neo.ClientError.Security.TokenExpired' + ) +} + +function isAvailabilityError (error) { + if (error) { + return ( + error.code === SESSION_EXPIRED || + error.code === SERVICE_UNAVAILABLE || + error.code === 'Neo.TransientError.General.DatabaseUnavailable' + ) + } + return false +} + +function isFailureToWrite (error) { + if (error) { + return ( + error.code === 'Neo.ClientError.Cluster.NotALeader' || + error.code === 'Neo.ClientError.General.ForbiddenOnReadOnlyDatabase' + ) + } + return false +} + +function noOpHandler (error) { + return error +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/connection/connection.js b/packages/neo4j-driver-deno/lib/bolt-connection/connection/connection.js new file mode 100644 index 000000000..dc996522c --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/connection/connection.js @@ -0,0 +1,132 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ +// eslint-disable-next-line no-unused-vars +import { ResultStreamObserver, BoltProtocol } from '../bolt/index.js' + +export default class Connection { + /** + * @param {ConnectionErrorHandler} errorHandler the error handler + */ + constructor (errorHandler) { + this._errorHandler = errorHandler + } + + get id () { + throw new Error('not implemented') + } + + get databaseId () { + throw new Error('not implemented') + } + + set databaseId (value) { + throw new Error('not implemented') + } + + /** + * @returns {boolean} whether this connection is in a working condition + */ + isOpen () { + throw new Error('not implemented') + } + + /** + * @returns {BoltProtocol} the underlying bolt protocol assigned to this connection + */ + protocol () { + throw new Error('not implemented') + } + + /** + * @returns {ServerAddress} the server address this connection is opened against + */ + get address () { + throw new Error('not implemented') + } + + /** + * @returns {ServerVersion} the version of the server this connection is connected to + */ + get version () { + throw new Error('not implemented') + } + + set version (value) { + throw new Error('not implemented') + } + + get server () { + throw new Error('not implemented') + } + + /** + * Connect to the target address, negotiate Bolt protocol and send initialization message. + * @param {string} userAgent the user agent for this driver. + * @param {Object} authToken the object containing auth information. + * @return {Promise} promise resolved with the current connection if connection is successful. Rejected promise otherwise. + */ + connect (userAgent, authToken) { + throw new Error('not implemented') + } + + /** + * Write a message to the network channel. + * @param {RequestMessage} message the message to write. + * @param {ResultStreamObserver} observer the response observer. + * @param {boolean} flush `true` if flush should happen after the message is written to the buffer. + */ + write (message, observer, flush) { + throw new Error('not implemented') + } + + /** + * Send a RESET-message to the database. Message is immediately flushed to the network. + * @return {Promise} promise resolved when SUCCESS-message response arrives, or failed when other response messages arrives. + */ + resetAndFlush () { + throw new Error('not implemented') + } + + hasOngoingObservableRequests () { + throw new Error('not implemented') + } + + /** + * Call close on the channel. + * @returns {Promise} - A promise that will be resolved when the connection is closed. + * + */ + close () { + throw new Error('not implemented') + } + + /** + * + * @param error + * @param address + * @returns {Neo4jError|*} + */ + handleAndTransformError (error, address) { + if (this._errorHandler) { + return this._errorHandler.handleAndTransformError(error, address) + } + + return error + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/connection/index.js b/packages/neo4j-driver-deno/lib/bolt-connection/connection/index.js new file mode 100644 index 000000000..3f82b83d5 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/connection/index.js @@ -0,0 +1,34 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 Connection from './connection.js' +import ChannelConnection, { + createChannelConnection +} from './connection-channel.js' +import DelegateConnection from './connection-delegate.js' +import ConnectionErrorHandler from './connection-error-handler.js' + +export default Connection +export { + Connection, + ChannelConnection, + DelegateConnection, + ConnectionErrorHandler, + createChannelConnection +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/index.js b/packages/neo4j-driver-deno/lib/bolt-connection/index.js new file mode 100644 index 000000000..76ecf194e --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/index.js @@ -0,0 +1,27 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ + +export * as loadBalancing from './load-balancing/index.js' +export * as bolt from './bolt/index.js' +export * as buf from './buf/index.js' +export * as channel from './channel/index.js' +export * as packstream from './packstream/index.js' +export * as pool from './pool/index.js' + +export * from './connection-provider/index.js' diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/lang/functional.js b/packages/neo4j-driver-deno/lib/bolt-connection/lang/functional.js new file mode 100644 index 000000000..24aa87184 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/lang/functional.js @@ -0,0 +1,30 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ + +/** + * Identity function. + * + * Identity functions are function which returns the input as output. + * + * @param {any} x + * @returns {any} the x + */ +export function identity (x) { + return x +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/lang/index.js b/packages/neo4j-driver-deno/lib/bolt-connection/lang/index.js new file mode 100644 index 000000000..2c7efd846 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/lang/index.js @@ -0,0 +1,20 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ + +export * as functional from './functional.js' diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/load-balancing/index.js b/packages/neo4j-driver-deno/lib/bolt-connection/load-balancing/index.js new file mode 100644 index 000000000..6b007e9b3 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/load-balancing/index.js @@ -0,0 +1,23 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 LoadBalancingStrategy from './load-balancing-strategy.js' +import LeastConnectedLoadBalancingStrategy from './least-connected-load-balancing-strategy.js' + +export default LeastConnectedLoadBalancingStrategy +export { LoadBalancingStrategy, LeastConnectedLoadBalancingStrategy } diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/load-balancing/least-connected-load-balancing-strategy.js b/packages/neo4j-driver-deno/lib/bolt-connection/load-balancing/least-connected-load-balancing-strategy.js new file mode 100644 index 000000000..6b182066b --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/load-balancing/least-connected-load-balancing-strategy.js @@ -0,0 +1,83 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 RoundRobinArrayIndex from './round-robin-array-index.js' +import LoadBalancingStrategy from './load-balancing-strategy.js' + +export default class LeastConnectedLoadBalancingStrategy extends LoadBalancingStrategy { + /** + * @constructor + * @param {Pool} connectionPool the connection pool of this driver. + */ + constructor (connectionPool) { + super() + this._readersIndex = new RoundRobinArrayIndex() + this._writersIndex = new RoundRobinArrayIndex() + this._connectionPool = connectionPool + } + + /** + * @inheritDoc + */ + selectReader (knownReaders) { + return this._select(knownReaders, this._readersIndex) + } + + /** + * @inheritDoc + */ + selectWriter (knownWriters) { + return this._select(knownWriters, this._writersIndex) + } + + _select (addresses, roundRobinIndex) { + const length = addresses.length + if (length === 0) { + return null + } + + // choose start index for iteration in round-robin fashion + const startIndex = roundRobinIndex.next(length) + let index = startIndex + + let leastConnectedAddress = null + let leastActiveConnections = Number.MAX_SAFE_INTEGER + + // iterate over the array to find least connected address + do { + const address = addresses[index] + const activeConnections = this._connectionPool.activeResourceCount( + address + ) + + if (activeConnections < leastActiveConnections) { + leastConnectedAddress = address + leastActiveConnections = activeConnections + } + + // loop over to the start of the array when end is reached + if (index === length - 1) { + index = 0 + } else { + index++ + } + } while (index !== startIndex) + + return leastConnectedAddress + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/load-balancing/load-balancing-strategy.js b/packages/neo4j-driver-deno/lib/bolt-connection/load-balancing/load-balancing-strategy.js new file mode 100644 index 000000000..6ad539c69 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/load-balancing/load-balancing-strategy.js @@ -0,0 +1,41 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ + +/** + * A facility to select most appropriate reader or writer among the given addresses for request processing. + */ +export default class LoadBalancingStrategy { + /** + * Select next most appropriate reader from the list of given readers. + * @param {string[]} knownReaders an array of currently known readers to select from. + * @return {string} most appropriate reader or `null` if given array is empty. + */ + selectReader (knownReaders) { + throw new Error('Abstract function') + } + + /** + * Select next most appropriate writer from the list of given writers. + * @param {string[]} knownWriters an array of currently known writers to select from. + * @return {string} most appropriate writer or `null` if given array is empty. + */ + selectWriter (knownWriters) { + throw new Error('Abstract function') + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/load-balancing/round-robin-array-index.js b/packages/neo4j-driver-deno/lib/bolt-connection/load-balancing/round-robin-array-index.js new file mode 100644 index 000000000..cb36bac6f --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/load-balancing/round-robin-array-index.js @@ -0,0 +1,47 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ + +export default class RoundRobinArrayIndex { + /** + * @constructor + * @param {number} [initialOffset=0] the initial offset for round robin. + */ + constructor (initialOffset) { + this._offset = initialOffset || 0 + } + + /** + * Get next index for an array with given length. + * @param {number} arrayLength the array length. + * @return {number} index in the array. + */ + next (arrayLength) { + if (arrayLength === 0) { + return -1 + } + + const nextOffset = this._offset + this._offset += 1 + if (this._offset === Number.MAX_SAFE_INTEGER) { + this._offset = 0 + } + + return nextOffset % arrayLength + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/packstream/index.js b/packages/neo4j-driver-deno/lib/bolt-connection/packstream/index.js new file mode 100644 index 000000000..1c66becf5 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/packstream/index.js @@ -0,0 +1,26 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 * as v1 from './packstream-v1.js' +import * as v2 from './packstream-v2.js' +import * as structure from './structure.js' + +export { v1, v2, structure } + +export default v2 diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/packstream/packstream-v1.js b/packages/neo4j-driver-deno/lib/bolt-connection/packstream/packstream-v1.js new file mode 100644 index 000000000..e648ccc85 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/packstream/packstream-v1.js @@ -0,0 +1,547 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { utf8 } from '../channel/index.js' +import { functional } from '../lang/index.js' +import { Structure } from './structure.js' +import { + newError, + error, + int, + isInt, + Integer +} from '../../core/index.ts' + +const { PROTOCOL_ERROR } = error + +const TINY_STRING = 0x80 +const TINY_LIST = 0x90 +const TINY_MAP = 0xa0 +const TINY_STRUCT = 0xb0 +const NULL = 0xc0 +const FLOAT_64 = 0xc1 +const FALSE = 0xc2 +const TRUE = 0xc3 +const INT_8 = 0xc8 +const INT_16 = 0xc9 +const INT_32 = 0xca +const INT_64 = 0xcb +const STRING_8 = 0xd0 +const STRING_16 = 0xd1 +const STRING_32 = 0xd2 +const LIST_8 = 0xd4 +const LIST_16 = 0xd5 +const LIST_32 = 0xd6 +const BYTES_8 = 0xcc +const BYTES_16 = 0xcd +const BYTES_32 = 0xce +const MAP_8 = 0xd8 +const MAP_16 = 0xd9 +const MAP_32 = 0xda +const STRUCT_8 = 0xdc +const STRUCT_16 = 0xdd + +/** + * Class to pack + * @access private + */ +class Packer { + /** + * @constructor + * @param {Chunker} channel the chunker backed by a network channel. + */ + constructor (channel) { + this._ch = channel + this._byteArraysSupported = true + } + + /** + * Creates a packable function out of the provided value + * @param x the value to pack + * @returns Function + */ + packable (x, dehydrateStruct = functional.identity) { + try { + x = dehydrateStruct(x) + } catch (ex) { + return () => { throw ex } + } + + if (x === null) { + return () => this._ch.writeUInt8(NULL) + } else if (x === true) { + return () => this._ch.writeUInt8(TRUE) + } else if (x === false) { + return () => this._ch.writeUInt8(FALSE) + } else if (typeof x === 'number') { + return () => this.packFloat(x) + } else if (typeof x === 'string') { + return () => this.packString(x) + } else if (typeof x === 'bigint') { + return () => this.packInteger(int(x)) + } else if (isInt(x)) { + return () => this.packInteger(x) + } else if (x instanceof Int8Array) { + return () => this.packBytes(x) + } else if (x instanceof Array) { + return () => { + this.packListHeader(x.length) + for (let i = 0; i < x.length; i++) { + this.packable(x[i] === undefined ? null : x[i], dehydrateStruct)() + } + } + } else if (isIterable(x)) { + return this.packableIterable(x, dehydrateStruct) + } else if (x instanceof Structure) { + const packableFields = [] + for (let i = 0; i < x.fields.length; i++) { + packableFields[i] = this.packable(x.fields[i], dehydrateStruct) + } + return () => this.packStruct(x.signature, packableFields) + } else if (typeof x === 'object') { + return () => { + const keys = Object.keys(x) + + let count = 0 + for (let i = 0; i < keys.length; i++) { + if (x[keys[i]] !== undefined) { + count++ + } + } + this.packMapHeader(count) + for (let i = 0; i < keys.length; i++) { + const key = keys[i] + if (x[key] !== undefined) { + this.packString(key) + this.packable(x[key], dehydrateStruct)() + } + } + } + } else { + return this._nonPackableValue(`Unable to pack the given value: ${x}`) + } + } + + packableIterable (iterable, dehydrateStruct) { + try { + const array = Array.from(iterable) + return this.packable(array, dehydrateStruct) + } catch (e) { + // handle errors from iterable to array conversion + throw newError(`Cannot pack given iterable, ${e.message}: ${iterable}`) + } + } + + /** + * Packs a struct + * @param signature the signature of the struct + * @param packableFields the fields of the struct, make sure you call `packable on all fields` + */ + packStruct (signature, packableFields) { + packableFields = packableFields || [] + this.packStructHeader(packableFields.length, signature) + for (let i = 0; i < packableFields.length; i++) { + packableFields[i]() + } + } + + packInteger (x) { + const high = x.high + const low = x.low + + if (x.greaterThanOrEqual(-0x10) && x.lessThan(0x80)) { + this._ch.writeInt8(low) + } else if (x.greaterThanOrEqual(-0x80) && x.lessThan(-0x10)) { + this._ch.writeUInt8(INT_8) + this._ch.writeInt8(low) + } else if (x.greaterThanOrEqual(-0x8000) && x.lessThan(0x8000)) { + this._ch.writeUInt8(INT_16) + this._ch.writeInt16(low) + } else if (x.greaterThanOrEqual(-0x80000000) && x.lessThan(0x80000000)) { + this._ch.writeUInt8(INT_32) + this._ch.writeInt32(low) + } else { + this._ch.writeUInt8(INT_64) + this._ch.writeInt32(high) + this._ch.writeInt32(low) + } + } + + packFloat (x) { + this._ch.writeUInt8(FLOAT_64) + this._ch.writeFloat64(x) + } + + packString (x) { + const bytes = utf8.encode(x) + const size = bytes.length + if (size < 0x10) { + this._ch.writeUInt8(TINY_STRING | size) + this._ch.writeBytes(bytes) + } else if (size < 0x100) { + this._ch.writeUInt8(STRING_8) + this._ch.writeUInt8(size) + this._ch.writeBytes(bytes) + } else if (size < 0x10000) { + this._ch.writeUInt8(STRING_16) + this._ch.writeUInt8((size / 256) >> 0) + this._ch.writeUInt8(size % 256) + this._ch.writeBytes(bytes) + } else if (size < 0x100000000) { + this._ch.writeUInt8(STRING_32) + this._ch.writeUInt8(((size / 16777216) >> 0) % 256) + this._ch.writeUInt8(((size / 65536) >> 0) % 256) + this._ch.writeUInt8(((size / 256) >> 0) % 256) + this._ch.writeUInt8(size % 256) + this._ch.writeBytes(bytes) + } else { + throw newError('UTF-8 strings of size ' + size + ' are not supported') + } + } + + packListHeader (size) { + if (size < 0x10) { + this._ch.writeUInt8(TINY_LIST | size) + } else if (size < 0x100) { + this._ch.writeUInt8(LIST_8) + this._ch.writeUInt8(size) + } else if (size < 0x10000) { + this._ch.writeUInt8(LIST_16) + this._ch.writeUInt8(((size / 256) >> 0) % 256) + this._ch.writeUInt8(size % 256) + } else if (size < 0x100000000) { + this._ch.writeUInt8(LIST_32) + this._ch.writeUInt8(((size / 16777216) >> 0) % 256) + this._ch.writeUInt8(((size / 65536) >> 0) % 256) + this._ch.writeUInt8(((size / 256) >> 0) % 256) + this._ch.writeUInt8(size % 256) + } else { + throw newError('Lists of size ' + size + ' are not supported') + } + } + + packBytes (array) { + if (this._byteArraysSupported) { + this.packBytesHeader(array.length) + for (let i = 0; i < array.length; i++) { + this._ch.writeInt8(array[i]) + } + } else { + throw newError( + 'Byte arrays are not supported by the database this driver is connected to' + ) + } + } + + packBytesHeader (size) { + if (size < 0x100) { + this._ch.writeUInt8(BYTES_8) + this._ch.writeUInt8(size) + } else if (size < 0x10000) { + this._ch.writeUInt8(BYTES_16) + this._ch.writeUInt8(((size / 256) >> 0) % 256) + this._ch.writeUInt8(size % 256) + } else if (size < 0x100000000) { + this._ch.writeUInt8(BYTES_32) + this._ch.writeUInt8(((size / 16777216) >> 0) % 256) + this._ch.writeUInt8(((size / 65536) >> 0) % 256) + this._ch.writeUInt8(((size / 256) >> 0) % 256) + this._ch.writeUInt8(size % 256) + } else { + throw newError('Byte arrays of size ' + size + ' are not supported') + } + } + + packMapHeader (size) { + if (size < 0x10) { + this._ch.writeUInt8(TINY_MAP | size) + } else if (size < 0x100) { + this._ch.writeUInt8(MAP_8) + this._ch.writeUInt8(size) + } else if (size < 0x10000) { + this._ch.writeUInt8(MAP_16) + this._ch.writeUInt8((size / 256) >> 0) + this._ch.writeUInt8(size % 256) + } else if (size < 0x100000000) { + this._ch.writeUInt8(MAP_32) + this._ch.writeUInt8(((size / 16777216) >> 0) % 256) + this._ch.writeUInt8(((size / 65536) >> 0) % 256) + this._ch.writeUInt8(((size / 256) >> 0) % 256) + this._ch.writeUInt8(size % 256) + } else { + throw newError('Maps of size ' + size + ' are not supported') + } + } + + packStructHeader (size, signature) { + if (size < 0x10) { + this._ch.writeUInt8(TINY_STRUCT | size) + this._ch.writeUInt8(signature) + } else if (size < 0x100) { + this._ch.writeUInt8(STRUCT_8) + this._ch.writeUInt8(size) + this._ch.writeUInt8(signature) + } else if (size < 0x10000) { + this._ch.writeUInt8(STRUCT_16) + this._ch.writeUInt8((size / 256) >> 0) + this._ch.writeUInt8(size % 256) + } else { + throw newError('Structures of size ' + size + ' are not supported') + } + } + + disableByteArrays () { + this._byteArraysSupported = false + } + + _nonPackableValue (message) { + return () => { + throw newError(message, PROTOCOL_ERROR) + } + } +} + +/** + * Class to unpack + * @access private + */ +class Unpacker { + /** + * @constructor + * @param {boolean} disableLosslessIntegers if this unpacker should convert all received integers to native JS numbers. + * @param {boolean} useBigInt if this unpacker should convert all received integers to Bigint + */ + constructor (disableLosslessIntegers = false, useBigInt = false) { + this._disableLosslessIntegers = disableLosslessIntegers + this._useBigInt = useBigInt + } + + unpack (buffer, hydrateStructure = functional.identity) { + const marker = buffer.readUInt8() + const markerHigh = marker & 0xf0 + const markerLow = marker & 0x0f + + if (marker === NULL) { + return null + } + + const boolean = this._unpackBoolean(marker) + if (boolean !== null) { + return boolean + } + + const numberOrInteger = this._unpackNumberOrInteger(marker, buffer) + if (numberOrInteger !== null) { + if (isInt(numberOrInteger)) { + if (this._useBigInt) { + return numberOrInteger.toBigInt() + } else if (this._disableLosslessIntegers) { + return numberOrInteger.toNumberOrInfinity() + } + } + return numberOrInteger + } + + const string = this._unpackString(marker, markerHigh, markerLow, buffer) + if (string !== null) { + return string + } + + const list = this._unpackList(marker, markerHigh, markerLow, buffer, hydrateStructure) + if (list !== null) { + return list + } + + const byteArray = this._unpackByteArray(marker, buffer) + if (byteArray !== null) { + return byteArray + } + + const map = this._unpackMap(marker, markerHigh, markerLow, buffer, hydrateStructure) + if (map !== null) { + return map + } + + const struct = this._unpackStruct(marker, markerHigh, markerLow, buffer, hydrateStructure) + if (struct !== null) { + return struct + } + + throw newError('Unknown packed value with marker ' + marker.toString(16)) + } + + unpackInteger (buffer) { + const marker = buffer.readUInt8() + const result = this._unpackInteger(marker, buffer) + if (result == null) { + throw newError( + 'Unable to unpack integer value with marker ' + marker.toString(16) + ) + } + return result + } + + _unpackBoolean (marker) { + if (marker === TRUE) { + return true + } else if (marker === FALSE) { + return false + } else { + return null + } + } + + _unpackNumberOrInteger (marker, buffer) { + if (marker === FLOAT_64) { + return buffer.readFloat64() + } else { + return this._unpackInteger(marker, buffer) + } + } + + _unpackInteger (marker, buffer) { + if (marker >= 0 && marker < 128) { + return int(marker) + } else if (marker >= 240 && marker < 256) { + return int(marker - 256) + } else if (marker === INT_8) { + return int(buffer.readInt8()) + } else if (marker === INT_16) { + return int(buffer.readInt16()) + } else if (marker === INT_32) { + const b = buffer.readInt32() + return int(b) + } else if (marker === INT_64) { + const high = buffer.readInt32() + const low = buffer.readInt32() + return new Integer(low, high) + } else { + return null + } + } + + _unpackString (marker, markerHigh, markerLow, buffer) { + if (markerHigh === TINY_STRING) { + return utf8.decode(buffer, markerLow) + } else if (marker === STRING_8) { + return utf8.decode(buffer, buffer.readUInt8()) + } else if (marker === STRING_16) { + return utf8.decode(buffer, buffer.readUInt16()) + } else if (marker === STRING_32) { + return utf8.decode(buffer, buffer.readUInt32()) + } else { + return null + } + } + + _unpackList (marker, markerHigh, markerLow, buffer, hydrateStructure) { + if (markerHigh === TINY_LIST) { + return this._unpackListWithSize(markerLow, buffer, hydrateStructure) + } else if (marker === LIST_8) { + return this._unpackListWithSize(buffer.readUInt8(), buffer, hydrateStructure) + } else if (marker === LIST_16) { + return this._unpackListWithSize(buffer.readUInt16(), buffer, hydrateStructure) + } else if (marker === LIST_32) { + return this._unpackListWithSize(buffer.readUInt32(), buffer, hydrateStructure) + } else { + return null + } + } + + _unpackListWithSize (size, buffer, hydrateStructure) { + const value = [] + for (let i = 0; i < size; i++) { + value.push(this.unpack(buffer, hydrateStructure)) + } + return value + } + + _unpackByteArray (marker, buffer) { + if (marker === BYTES_8) { + return this._unpackByteArrayWithSize(buffer.readUInt8(), buffer) + } else if (marker === BYTES_16) { + return this._unpackByteArrayWithSize(buffer.readUInt16(), buffer) + } else if (marker === BYTES_32) { + return this._unpackByteArrayWithSize(buffer.readUInt32(), buffer) + } else { + return null + } + } + + _unpackByteArrayWithSize (size, buffer) { + const value = new Int8Array(size) + for (let i = 0; i < size; i++) { + value[i] = buffer.readInt8() + } + return value + } + + _unpackMap (marker, markerHigh, markerLow, buffer, hydrateStructure) { + if (markerHigh === TINY_MAP) { + return this._unpackMapWithSize(markerLow, buffer, hydrateStructure) + } else if (marker === MAP_8) { + return this._unpackMapWithSize(buffer.readUInt8(), buffer, hydrateStructure) + } else if (marker === MAP_16) { + return this._unpackMapWithSize(buffer.readUInt16(), buffer, hydrateStructure) + } else if (marker === MAP_32) { + return this._unpackMapWithSize(buffer.readUInt32(), buffer, hydrateStructure) + } else { + return null + } + } + + _unpackMapWithSize (size, buffer, hydrateStructure) { + const value = {} + for (let i = 0; i < size; i++) { + const key = this.unpack(buffer, hydrateStructure) + value[key] = this.unpack(buffer, hydrateStructure) + } + return value + } + + _unpackStruct (marker, markerHigh, markerLow, buffer, hydrateStructure) { + if (markerHigh === TINY_STRUCT) { + return this._unpackStructWithSize(markerLow, buffer, hydrateStructure) + } else if (marker === STRUCT_8) { + return this._unpackStructWithSize(buffer.readUInt8(), buffer, hydrateStructure) + } else if (marker === STRUCT_16) { + return this._unpackStructWithSize(buffer.readUInt16(), buffer, hydrateStructure) + } else { + return null + } + } + + _unpackStructWithSize (structSize, buffer, hydrateStructure) { + const signature = buffer.readUInt8() + const structure = new Structure(signature, []) + for (let i = 0; i < structSize; i++) { + structure.fields.push(this.unpack(buffer, hydrateStructure)) + } + + return hydrateStructure(structure) + } +} + +function isIterable (obj) { + if (obj == null) { + return false + } + return typeof obj[Symbol.iterator] === 'function' +} + +export { Packer, Unpacker } diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/packstream/packstream-v2.js b/packages/neo4j-driver-deno/lib/bolt-connection/packstream/packstream-v2.js new file mode 100644 index 000000000..3d83affeb --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/packstream/packstream-v2.js @@ -0,0 +1,37 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 * as v1 from './packstream-v1.js' + +export class Packer extends v1.Packer { + disableByteArrays () { + throw new Error('Bolt V2 should always support byte arrays') + } +} + +export class Unpacker extends v1.Unpacker { + /** + * @constructor + * @param {boolean} disableLosslessIntegers if this unpacker should convert all received integers to native JS numbers. + * @param {boolean} useBigInt if this unpacker should convert all received integers to Bigint + */ + constructor (disableLosslessIntegers = false, useBigInt = false) { + super(disableLosslessIntegers, useBigInt) + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/packstream/structure.js b/packages/neo4j-driver-deno/lib/bolt-connection/packstream/structure.js new file mode 100644 index 000000000..23e67a7be --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/packstream/structure.js @@ -0,0 +1,63 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { newError, error } from '../../core/index.ts' + +const { + PROTOCOL_ERROR +} = error + +/** + * A Structure have a signature and fields. + */ +export class Structure { + /** + * Create new instance + */ + constructor (signature, fields) { + this.signature = signature + this.fields = fields + } + + get size () { + return this.fields.length + } + + toString () { + let fieldStr = '' + for (let i = 0; i < this.fields.length; i++) { + if (i > 0) { + fieldStr += ', ' + } + fieldStr += this.fields[i] + } + return 'Structure(' + this.signature + ', [' + fieldStr + '])' + } +} + +export function verifyStructSize (structName, expectedSize, actualSize) { + if (expectedSize !== actualSize) { + throw newError( + `Wrong struct size for ${structName}, expected ${expectedSize} but was ${actualSize}`, + PROTOCOL_ERROR + ) + } +} + +export default Structure diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/pool/index.js b/packages/neo4j-driver-deno/lib/bolt-connection/pool/index.js new file mode 100644 index 000000000..df21d8ab0 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/pool/index.js @@ -0,0 +1,27 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 PoolConfig, { + DEFAULT_ACQUISITION_TIMEOUT, + DEFAULT_MAX_SIZE +} from './pool-config.js' +import Pool from './pool.js' + +export default Pool +export { Pool, PoolConfig, DEFAULT_ACQUISITION_TIMEOUT, DEFAULT_MAX_SIZE } diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/pool/pool-config.js b/packages/neo4j-driver-deno/lib/bolt-connection/pool/pool-config.js new file mode 100644 index 000000000..94596ac22 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/pool/pool-config.js @@ -0,0 +1,60 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ + +const DEFAULT_MAX_SIZE = 100 +const DEFAULT_ACQUISITION_TIMEOUT = 60 * 1000 // 60 seconds + +export default class PoolConfig { + constructor (maxSize, acquisitionTimeout) { + this.maxSize = valueOrDefault(maxSize, DEFAULT_MAX_SIZE) + this.acquisitionTimeout = valueOrDefault( + acquisitionTimeout, + DEFAULT_ACQUISITION_TIMEOUT + ) + } + + static defaultConfig () { + return new PoolConfig(DEFAULT_MAX_SIZE, DEFAULT_ACQUISITION_TIMEOUT) + } + + static fromDriverConfig (config) { + const maxSizeConfigured = isConfigured(config.maxConnectionPoolSize) + const maxSize = maxSizeConfigured + ? config.maxConnectionPoolSize + : DEFAULT_MAX_SIZE + const acquisitionTimeoutConfigured = isConfigured( + config.connectionAcquisitionTimeout + ) + const acquisitionTimeout = acquisitionTimeoutConfigured + ? config.connectionAcquisitionTimeout + : DEFAULT_ACQUISITION_TIMEOUT + + return new PoolConfig(maxSize, acquisitionTimeout) + } +} + +function valueOrDefault (value, defaultValue) { + return value === 0 || value ? value : defaultValue +} + +function isConfigured (value) { + return value === 0 || value +} + +export { DEFAULT_MAX_SIZE, DEFAULT_ACQUISITION_TIMEOUT } diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/pool/pool.js b/packages/neo4j-driver-deno/lib/bolt-connection/pool/pool.js new file mode 100644 index 000000000..148c5f1b9 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/pool/pool.js @@ -0,0 +1,450 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 PoolConfig from './pool-config.js' +import { newError, internal } from '../../core/index.ts' + +const { + logger: { Logger } +} = internal + +class Pool { + /** + * @param {function(address: ServerAddress, function(address: ServerAddress, resource: object): Promise): Promise} create + * an allocation function that creates a promise with a new resource. It's given an address for which to + * allocate the connection and a function that will return the resource to the pool if invoked, which is + * meant to be called on .dispose or .close or whatever mechanism the resource uses to finalize. + * @param {function(resource: object): Promise} destroy + * called with the resource when it is evicted from this pool + * @param {function(resource: object): boolean} validate + * called at various times (like when an instance is acquired and when it is returned. + * If this returns false, the resource will be evicted + * @param {function(resource: object, observer: { onError }): void} installIdleObserver + * called when the resource is released back to pool + * @param {function(resource: object): void} removeIdleObserver + * called when the resource is acquired from the pool + * @param {PoolConfig} config configuration for the new driver. + * @param {Logger} log the driver logger. + */ + constructor ({ + create = (address, release) => Promise.resolve(), + destroy = conn => Promise.resolve(), + validate = conn => true, + installIdleObserver = (conn, observer) => {}, + removeIdleObserver = conn => {}, + config = PoolConfig.defaultConfig(), + log = Logger.noOp() + } = {}) { + this._create = create + this._destroy = destroy + this._validate = validate + this._installIdleObserver = installIdleObserver + this._removeIdleObserver = removeIdleObserver + this._maxSize = config.maxSize + this._acquisitionTimeout = config.acquisitionTimeout + this._pools = {} + this._pendingCreates = {} + this._acquireRequests = {} + this._activeResourceCounts = {} + this._release = this._release.bind(this) + this._log = log + this._closed = false + } + + /** + * Acquire and idle resource fom the pool or create a new one. + * @param {ServerAddress} address the address for which we're acquiring. + * @return {Promise} resource that is ready to use. + */ + acquire (address) { + const key = address.asKey() + + // We're out of resources and will try to acquire later on when an existing resource is released. + const allRequests = this._acquireRequests + const requests = allRequests[key] + if (!requests) { + allRequests[key] = [] + } + return new Promise((resolve, reject) => { + let request = null + + const timeoutId = setTimeout(() => { + // acquisition timeout fired + + // remove request from the queue of pending requests, if it's still there + // request might've been taken out by the release operation + const pendingRequests = allRequests[key] + if (pendingRequests) { + allRequests[key] = pendingRequests.filter(item => item !== request) + } + + if (request.isCompleted()) { + // request already resolved/rejected by the release operation; nothing to do + } else { + // request is still pending and needs to be failed + const activeCount = this.activeResourceCount(address) + const idleCount = this.has(address) ? this._pools[key].length : 0 + request.reject( + newError( + `Connection acquisition timed out in ${this._acquisitionTimeout} ms. Pool status: Active conn count = ${activeCount}, Idle conn count = ${idleCount}.` + ) + ) + } + }, this._acquisitionTimeout) + + request = new PendingRequest(key, resolve, reject, timeoutId, this._log) + allRequests[key].push(request) + this._processPendingAcquireRequests(address) + }) + } + + /** + * Destroy all idle resources for the given address. + * @param {ServerAddress} address the address of the server to purge its pool. + * @returns {Promise} A promise that is resolved when the resources are purged + */ + purge (address) { + return this._purgeKey(address.asKey()) + } + + /** + * Destroy all idle resources in this pool. + * @returns {Promise} A promise that is resolved when the resources are purged + */ + async close () { + this._closed = true + /** + * The lack of Promise consuming was making the driver do not close properly in the scenario + * captured at result.test.js:it('should handle missing onCompleted'). The test was timing out + * because while wainting for the driver close. + * + * Consuming the Promise.all or by calling then or by awaiting in the result inside this method solved + * the issue somehow. + * + * PS: the return of this method was already awaited at PooledConnectionProvider.close, but the await bellow + * seems to be need also. + */ + return await Promise.all( + Object.keys(this._pools).map(key => this._purgeKey(key)) + ) + } + + /** + * Keep the idle resources for the provided addresses and purge the rest. + * @returns {Promise} A promise that is resolved when the other resources are purged + */ + keepAll (addresses) { + const keysToKeep = addresses.map(a => a.asKey()) + const keysPresent = Object.keys(this._pools) + const keysToPurge = keysPresent.filter(k => keysToKeep.indexOf(k) === -1) + + return Promise.all(keysToPurge.map(key => this._purgeKey(key))) + } + + /** + * Check if this pool contains resources for the given address. + * @param {ServerAddress} address the address of the server to check. + * @return {boolean} `true` when pool contains entries for the given key, false otherwise. + */ + has (address) { + return address.asKey() in this._pools + } + + /** + * Get count of active (checked out of the pool) resources for the given key. + * @param {ServerAddress} address the address of the server to check. + * @return {number} count of resources acquired by clients. + */ + activeResourceCount (address) { + return this._activeResourceCounts[address.asKey()] || 0 + } + + _getOrInitializePoolFor (key) { + let pool = this._pools[key] + if (!pool) { + pool = new SingleAddressPool() + this._pools[key] = pool + this._pendingCreates[key] = 0 + } + return pool + } + + async _acquire (address) { + if (this._closed) { + throw newError('Pool is closed, it is no more able to serve requests.') + } + + const key = address.asKey() + const pool = this._getOrInitializePoolFor(key) + while (pool.length) { + const resource = pool.pop() + + if (this._validate(resource)) { + if (this._removeIdleObserver) { + this._removeIdleObserver(resource) + } + + // idle resource is valid and can be acquired + resourceAcquired(key, this._activeResourceCounts) + if (this._log.isDebugEnabled()) { + this._log.debug(`${resource} acquired from the pool ${key}`) + } + return { resource, pool } + } else { + await this._destroy(resource) + } + } + + // Ensure requested max pool size + if (this._maxSize > 0) { + // Include pending creates when checking pool size since these probably will add + // to the number when fulfilled. + const numConnections = + this.activeResourceCount(address) + this._pendingCreates[key] + if (numConnections >= this._maxSize) { + // Will put this request in queue instead since the pool is full + return { resource: null, pool } + } + } + + // there exist no idle valid resources, create a new one for acquisition + // Keep track of how many pending creates there are to avoid making too many connections. + this._pendingCreates[key] = this._pendingCreates[key] + 1 + let resource + try { + // Invoke callback that creates actual connection + resource = await this._create(address, (address, resource) => this._release(address, resource, pool)) + + resourceAcquired(key, this._activeResourceCounts) + if (this._log.isDebugEnabled()) { + this._log.debug(`${resource} created for the pool ${key}`) + } + } finally { + this._pendingCreates[key] = this._pendingCreates[key] - 1 + } + return { resource, pool } + } + + async _release (address, resource, pool) { + const key = address.asKey() + + if (pool.isActive()) { + // there exist idle connections for the given key + if (!this._validate(resource)) { + if (this._log.isDebugEnabled()) { + this._log.debug( + `${resource} destroyed and can't be released to the pool ${key} because it is not functional` + ) + } + await this._destroy(resource) + } else { + if (this._installIdleObserver) { + this._installIdleObserver(resource, { + onError: error => { + this._log.debug( + `Idle connection ${resource} destroyed because of error: ${error}` + ) + const pool = this._pools[key] + if (pool) { + this._pools[key] = pool.filter(r => r !== resource) + } + // let's not care about background clean-ups due to errors but just trigger the destroy + // process for the resource, we especially catch any errors and ignore them to avoid + // unhandled promise rejection warnings + this._destroy(resource).catch(() => {}) + } + }) + } + pool.push(resource) + if (this._log.isDebugEnabled()) { + this._log.debug(`${resource} released to the pool ${key}`) + } + } + } else { + // key has been purged, don't put it back, just destroy the resource + if (this._log.isDebugEnabled()) { + this._log.debug( + `${resource} destroyed and can't be released to the pool ${key} because pool has been purged` + ) + } + await this._destroy(resource) + } + resourceReleased(key, this._activeResourceCounts) + + this._processPendingAcquireRequests(address) + } + + async _purgeKey (key) { + const pool = this._pools[key] + const destructionList = [] + if (pool) { + while (pool.length) { + const resource = pool.pop() + if (this._removeIdleObserver) { + this._removeIdleObserver(resource) + } + destructionList.push(this._destroy(resource)) + } + pool.close() + delete this._pools[key] + await Promise.all(destructionList) + } + } + + _processPendingAcquireRequests (address) { + const key = address.asKey() + const requests = this._acquireRequests[key] + if (requests) { + const pendingRequest = requests.shift() // pop a pending acquire request + + if (pendingRequest) { + this._acquire(address) + .catch(error => { + // failed to acquire/create a new connection to resolve the pending acquire request + // propagate the error by failing the pending request + pendingRequest.reject(error) + return { resource: null } + }) + .then(({ resource, pool }) => { + if (resource) { + // managed to acquire a valid resource from the pool + + if (pendingRequest.isCompleted()) { + // request has been completed, most likely failed by a timeout + // return the acquired resource back to the pool + this._release(address, resource, pool) + } else { + // request is still pending and can be resolved with the newly acquired resource + pendingRequest.resolve(resource) // resolve the pending request with the acquired resource + } + } else { + // failed to acquire a valid resource from the pool + // return the pending request back to the pool + if (!pendingRequest.isCompleted()) { + if (!this._acquireRequests[key]) { + this._acquireRequests[key] = [] + } + this._acquireRequests[key].unshift(pendingRequest) + } + } + }) + } else { + delete this._acquireRequests[key] + } + } + } +} + +/** + * Increment active (checked out of the pool) resource counter. + * @param {string} key the resource group identifier (server address for connections). + * @param {Object.} activeResourceCounts the object holding active counts per key. + */ +function resourceAcquired (key, activeResourceCounts) { + const currentCount = activeResourceCounts[key] || 0 + activeResourceCounts[key] = currentCount + 1 +} + +/** + * Decrement active (checked out of the pool) resource counter. + * @param {string} key the resource group identifier (server address for connections). + * @param {Object.} activeResourceCounts the object holding active counts per key. + */ +function resourceReleased (key, activeResourceCounts) { + const currentCount = activeResourceCounts[key] || 0 + const nextCount = currentCount - 1 + + if (nextCount > 0) { + activeResourceCounts[key] = nextCount + } else { + delete activeResourceCounts[key] + } +} + +class PendingRequest { + constructor (key, resolve, reject, timeoutId, log) { + this._key = key + this._resolve = resolve + this._reject = reject + this._timeoutId = timeoutId + this._log = log + this._completed = false + } + + isCompleted () { + return this._completed + } + + resolve (resource) { + if (this._completed) { + return + } + this._completed = true + + clearTimeout(this._timeoutId) + if (this._log.isDebugEnabled()) { + this._log.debug(`${resource} acquired from the pool ${this._key}`) + } + this._resolve(resource) + } + + reject (error) { + if (this._completed) { + return + } + this._completed = true + + clearTimeout(this._timeoutId) + this._reject(error) + } +} + +class SingleAddressPool { + constructor () { + this._active = true + this._elements = [] + } + + isActive () { + return this._active + } + + close () { + this._active = false + } + + filter (predicate) { + this._elements = this._elements.filter(predicate) + return this + } + + get length () { + return this._elements.length + } + + pop () { + return this._elements.pop() + } + + push (element) { + return this._elements.push(element) + } +} + +export default Pool diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/rediscovery/index.js b/packages/neo4j-driver-deno/lib/bolt-connection/rediscovery/index.js new file mode 100644 index 000000000..43b9dd59e --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/rediscovery/index.js @@ -0,0 +1,24 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 Rediscovery from './rediscovery.js' +import RoutingTable from './routing-table.js' + +export default Rediscovery +export { Rediscovery, RoutingTable } diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/rediscovery/rediscovery.js b/packages/neo4j-driver-deno/lib/bolt-connection/rediscovery/rediscovery.js new file mode 100644 index 000000000..664e3f2e8 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/rediscovery/rediscovery.js @@ -0,0 +1,78 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 RoutingTable from './routing-table.js' +// eslint-disable-next-line no-unused-vars +import { Session } from '../../core/index.ts' + +export default class Rediscovery { + /** + * @constructor + * @param {object} routingContext + */ + constructor (routingContext) { + this._routingContext = routingContext + } + + /** + * Try to fetch new routing table from the given router. + * @param {Session} session the session to use. + * @param {string} database the database for which to lookup routing table. + * @param {ServerAddress} routerAddress the URL of the router. + * @param {string} impersonatedUser The impersonated user + * @return {Promise} promise resolved with new routing table or null when connection error happened. + */ + lookupRoutingTableOnRouter (session, database, routerAddress, impersonatedUser) { + return session._acquireConnection(connection => { + return this._requestRawRoutingTable( + connection, + session, + database, + routerAddress, + impersonatedUser + ).then(rawRoutingTable => { + if (rawRoutingTable.isNull) { + return null + } + return RoutingTable.fromRawRoutingTable( + database, + routerAddress, + rawRoutingTable + ) + }) + }) + } + + _requestRawRoutingTable (connection, session, database, routerAddress, impersonatedUser) { + return new Promise((resolve, reject) => { + connection.protocol().requestRoutingInformation({ + routingContext: this._routingContext, + databaseName: database, + impersonatedUser, + sessionContext: { + bookmarks: session._lastBookmarks, + mode: session._mode, + database: session._database, + afterComplete: session._onComplete + }, + onCompleted: resolve, + onError: reject + }) + }) + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/rediscovery/routing-table.js b/packages/neo4j-driver-deno/lib/bolt-connection/rediscovery/routing-table.js new file mode 100644 index 000000000..f5d50844c --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/rediscovery/routing-table.js @@ -0,0 +1,266 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { + newError, + error, + Integer, + int, + internal, + json +} from '../../core/index.ts' + +const { + constants: { ACCESS_MODE_WRITE: WRITE, ACCESS_MODE_READ: READ }, + serverAddress: { ServerAddress } +} = internal +const { PROTOCOL_ERROR } = error + +const MIN_ROUTERS = 1 + +/** + * The routing table object used to determine the role of the servers in the driver. + */ +export default class RoutingTable { + constructor ({ + database, + routers, + readers, + writers, + expirationTime, + ttl + } = {}) { + this.database = database || null + this.databaseName = database || 'default database' + this.routers = routers || [] + this.readers = readers || [] + this.writers = writers || [] + this.expirationTime = expirationTime || int(0) + this.ttl = ttl + } + + /** + * Create a valid routing table from a raw object + * + * @param {string} database the database name. It is used for logging purposes + * @param {ServerAddress} routerAddress The router address, it is used for loggin purposes + * @param {RawRoutingTable} rawRoutingTable Method used to get the raw routing table to be processed + * @param {RoutingTable} The valid Routing Table + */ + static fromRawRoutingTable (database, routerAddress, rawRoutingTable) { + return createValidRoutingTable(database, routerAddress, rawRoutingTable) + } + + forget (address) { + // Don't remove it from the set of routers, since that might mean we lose our ability to re-discover, + // just remove it from the set of readers and writers, so that we don't use it for actual work without + // performing discovery first. + + this.readers = removeFromArray(this.readers, address) + this.writers = removeFromArray(this.writers, address) + } + + forgetRouter (address) { + this.routers = removeFromArray(this.routers, address) + } + + forgetWriter (address) { + this.writers = removeFromArray(this.writers, address) + } + + /** + * Check if this routing table is fresh to perform the required operation. + * @param {string} accessMode the type of operation. Allowed values are {@link READ} and {@link WRITE}. + * @return {boolean} `true` when this table contains servers to serve the required operation, `false` otherwise. + */ + isStaleFor (accessMode) { + return ( + this.expirationTime.lessThan(Date.now()) || + this.routers.length < MIN_ROUTERS || + (accessMode === READ && this.readers.length === 0) || + (accessMode === WRITE && this.writers.length === 0) + ) + } + + /** + * Check if this routing table is expired for specified amount of duration + * + * @param {Integer} duration amount of duration in milliseconds to check for expiration + * @returns {boolean} + */ + isExpiredFor (duration) { + return this.expirationTime.add(duration).lessThan(Date.now()) + } + + allServers () { + return [...this.routers, ...this.readers, ...this.writers] + } + + toString () { + return ( + 'RoutingTable[' + + `database=${this.databaseName}, ` + + `expirationTime=${this.expirationTime}, ` + + `currentTime=${Date.now()}, ` + + `routers=[${this.routers}], ` + + `readers=[${this.readers}], ` + + `writers=[${this.writers}]]` + ) + } +} + +/** + * Remove all occurrences of the element in the array. + * @param {Array} array the array to filter. + * @param {Object} element the element to remove. + * @return {Array} new filtered array. + */ +function removeFromArray (array, element) { + return array.filter(item => item.asKey() !== element.asKey()) +} + +/** + * Create a valid routing table from a raw object + * + * @param {string} db the database name. It is used for logging purposes + * @param {ServerAddress} routerAddress The router address, it is used for loggin purposes + * @param {RawRoutingTable} rawRoutingTable Method used to get the raw routing table to be processed + * @param {RoutingTable} The valid Routing Table + */ +export function createValidRoutingTable ( + database, + routerAddress, + rawRoutingTable +) { + const ttl = rawRoutingTable.ttl + const expirationTime = calculateExpirationTime(rawRoutingTable, routerAddress) + const { routers, readers, writers } = parseServers( + rawRoutingTable, + routerAddress + ) + + assertNonEmpty(routers, 'routers', routerAddress) + assertNonEmpty(readers, 'readers', routerAddress) + + return new RoutingTable({ + database: database || rawRoutingTable.db, + routers, + readers, + writers, + expirationTime, + ttl + }) +} + +/** + * Parse server from the RawRoutingTable. + * + * @param {RawRoutingTable} rawRoutingTable the raw routing table + * @param {string} routerAddress the router address + * @returns {Object} The object with the list of routers, readers and writers + */ +function parseServers (rawRoutingTable, routerAddress) { + try { + let routers = [] + let readers = [] + let writers = [] + + rawRoutingTable.servers.forEach(server => { + const role = server.role + const addresses = server.addresses + + if (role === 'ROUTE') { + routers = parseArray(addresses).map(address => + ServerAddress.fromUrl(address) + ) + } else if (role === 'WRITE') { + writers = parseArray(addresses).map(address => + ServerAddress.fromUrl(address) + ) + } else if (role === 'READ') { + readers = parseArray(addresses).map(address => + ServerAddress.fromUrl(address) + ) + } + }) + + return { + routers: routers, + readers: readers, + writers: writers + } + } catch (error) { + throw newError( + `Unable to parse servers entry from router ${routerAddress} from addresses:\n${json.stringify( + rawRoutingTable.servers + )}\nError message: ${error.message}`, + PROTOCOL_ERROR + ) + } +} + +/** + * Call the expiration time using the ttls from the raw routing table and return it + * + * @param {RawRoutingTable} rawRoutingTable the routing table + * @param {string} routerAddress the router address + * @returns {number} the ttl + */ +function calculateExpirationTime (rawRoutingTable, routerAddress) { + try { + const now = int(Date.now()) + const expires = int(rawRoutingTable.ttl) + .multiply(1000) + .add(now) + // if the server uses a really big expire time like Long.MAX_VALUE this may have overflowed + if (expires.lessThan(now)) { + return Integer.MAX_VALUE + } + return expires + } catch (error) { + throw newError( + `Unable to parse TTL entry from router ${routerAddress} from raw routing table:\n${json.stringify( + rawRoutingTable + )}\nError message: ${error.message}`, + PROTOCOL_ERROR + ) + } +} + +/** + * Assert if serverAddressesArray is not empty, throws and PROTOCOL_ERROR otherwise + * + * @param {string[]} serverAddressesArray array of addresses + * @param {string} serversName the server name + * @param {string} routerAddress the router address + */ +function assertNonEmpty (serverAddressesArray, serversName, routerAddress) { + if (serverAddressesArray.length === 0) { + throw newError( + 'Received no ' + serversName + ' from router ' + routerAddress, + PROTOCOL_ERROR + ) + } +} + +function parseArray (addresses) { + if (!Array.isArray(addresses)) { + throw new TypeError('Array expected but got: ' + addresses) + } + return Array.from(addresses) +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/types/index.d.ts b/packages/neo4j-driver-deno/lib/bolt-connection/types/index.d.ts new file mode 100644 index 000000000..a310791fb --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/types/index.d.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { + ConnectionProvider +} from '../../core/index.ts' + +declare class DirectConnectionProvider extends ConnectionProvider { + constructor (config: any) +} + +declare class RoutingConnectionProvider extends ConnectionProvider { + constructor (config: any) +} + +export { + DirectConnectionProvider, + RoutingConnectionProvider +} diff --git a/packages/neo4j-driver-deno/lib/core/auth.ts b/packages/neo4j-driver-deno/lib/core/auth.ts new file mode 100644 index 000000000..502166a0e --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/auth.ts @@ -0,0 +1,89 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ + +/** + * @property {function(username: string, password: string, realm: ?string)} basic the function to create a + * basic authentication token. + * @property {function(base64EncodedTicket: string)} kerberos the function to create a Kerberos authentication token. + * Accepts a single string argument - base64 encoded Kerberos ticket. + * @property {function(base64EncodedTicket: string)} bearer the function to create a Bearer authentication token. + * Accepts a single string argument - base64 encoded Bearer ticket. + * @property {function(principal: string, credentials: string, realm: string, scheme: string, parameters: ?object)} custom + * the function to create a custom authentication token. + */ +const auth = { + basic: (username: string, password: string, realm?: string) => { + if (realm != null) { + return { + scheme: 'basic', + principal: username, + credentials: password, + realm: realm + } + } else { + return { scheme: 'basic', principal: username, credentials: password } + } + }, + kerberos: (base64EncodedTicket: string) => { + return { + scheme: 'kerberos', + principal: '', // This empty string is required for backwards compatibility. + credentials: base64EncodedTicket + } + }, + bearer: (base64EncodedToken: string) => { + return { + scheme: 'bearer', + credentials: base64EncodedToken + } + }, + custom: ( + principal: string, + credentials: string, + realm: string, + scheme: string, + parameters?: object + ) => { + const output: any = { + scheme: scheme, + principal: principal + } + if (isNotEmpty(credentials)) { + output.credentials = credentials + } + if (isNotEmpty(realm)) { + output.realm = realm + } + if (isNotEmpty(parameters)) { + output.parameters = parameters + } + return output + } +} + +function isNotEmpty (value: T | null | undefined): boolean { + return !( + value === null || + value === undefined || + value === '' || + (Object.getPrototypeOf(value) === Object.prototype && Object.keys(value).length === 0) + ) +} + +export default auth diff --git a/packages/neo4j-driver-deno/lib/core/bookmark-manager.ts b/packages/neo4j-driver-deno/lib/core/bookmark-manager.ts new file mode 100644 index 000000000..3d2a36168 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/bookmark-manager.ts @@ -0,0 +1,187 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ + +/** + * Interface for the piece of software responsible for keeping track of current active bookmarks accross the driver. + * @interface + * @since 5.0 + * @experimental + */ +export default class BookmarkManager { + /** + * @constructor + * @private + */ + private constructor () { + throw new Error('Not implemented') + } + + /** + * Method called when the bookmarks get updated when a transaction finished. + * + * This method will be called when auto-commit queries finish and when explicit transactions + * get commited. + * + * @param {string} database The database which the bookmarks belongs to + * @param {Iterable} previousBookmarks The bookmarks used when starting the transaction + * @param {Iterable} newBookmarks The new bookmarks received at the end of the transaction. + * @returns {void} + */ + async updateBookmarks (database: string, previousBookmarks: Iterable, newBookmarks: Iterable): Promise { + throw new Error('Not implemented') + } + + /** + * Method called by the driver to get the bookmarks for one specific database + * + * @param {string} database The database which the bookmarks belong to + * @returns {Iterable} The set of bookmarks + */ + async getBookmarks (database: string): Promise> { + throw new Error('Not implemented') + } + + /** + * Method called by the driver for getting all the bookmarks. + * + * This method should return all bookmarks for all databases present in the BookmarkManager. + * + * @returns {Iterable} The set of bookmarks + */ + async getAllBookmarks (): Promise> { + throw new Error('Not implemented') + } + + /** + * Forget the databases and its bookmarks + * + * This method is not called by the driver. Forgetting unused databases is the user's responsibility. + * + * @param {Iterable} databases The databases which the bookmarks will be removed for. + */ + async forget (databases: Iterable): Promise { + throw new Error('Not implemented') + } +} + +export interface BookmarkManagerConfig { + initialBookmarks?: Map> + bookmarksSupplier?: (database?: string) => Promise> + bookmarksConsumer?: (database: string, bookmarks: Iterable) => Promise +} + +/** + * @typedef {Object} BookmarkManagerConfig + * + * @since 5.0 + * @experimental + * @property {Map>} [initialBookmarks@experimental] Defines the initial set of bookmarks. The key is the database name and the values are the bookmarks. + * @property {function([database]: string):Promise>} [bookmarksSupplier] Called for supplying extra bookmarks to the BookmarkManager + * 1. supplying bookmarks from the given database when the default BookmarkManager's `.getBookmarks(database)` gets called. + * 2. supplying all the bookmarks when the default BookmarkManager's `.getAllBookmarks()` gets called + * @property {function(database: string, bookmarks: Iterable): Promise} [bookmarksConsumer] Called when the set of bookmarks for database get updated + */ +/** + * Provides an configured {@link BookmarkManager} instance. + * + * @since 5.0 + * @experimental + * @param {BookmarkManagerConfig} [config={}] + * @returns {BookmarkManager} + */ +export function bookmarkManager (config: BookmarkManagerConfig = {}): BookmarkManager { + const initialBookmarks = new Map>() + + config.initialBookmarks?.forEach((v, k) => initialBookmarks.set(k, new Set(v))) + + return new Neo4jBookmarkManager( + initialBookmarks, + config.bookmarksSupplier, + config.bookmarksConsumer + ) +} + +class Neo4jBookmarkManager implements BookmarkManager { + constructor ( + private readonly _bookmarksPerDb: Map>, + private readonly _bookmarksSupplier?: (database?: string) => Promise>, + private readonly _bookmarksConsumer?: (database: string, bookmark: Iterable) => Promise + ) { + + } + + async updateBookmarks (database: string, previousBookmarks: Iterable, newBookmarks: Iterable): Promise { + const bookmarks = this._getOrInitializeBookmarks(database) + for (const bm of previousBookmarks) { + bookmarks.delete(bm) + } + for (const bm of newBookmarks) { + bookmarks.add(bm) + } + if (typeof this._bookmarksConsumer === 'function') { + await this._bookmarksConsumer(database, [...bookmarks]) + } + } + + private _getOrInitializeBookmarks (database: string): Set { + let maybeBookmarks = this._bookmarksPerDb.get(database) + if (maybeBookmarks === undefined) { + maybeBookmarks = new Set() + this._bookmarksPerDb.set(database, maybeBookmarks) + } + return maybeBookmarks + } + + async getBookmarks (database: string): Promise> { + const bookmarks = new Set(this._bookmarksPerDb.get(database)) + + if (typeof this._bookmarksSupplier === 'function') { + const suppliedBookmarks = await this._bookmarksSupplier(database) ?? [] + for (const bm of suppliedBookmarks) { + bookmarks.add(bm) + } + } + + return [...bookmarks] + } + + async getAllBookmarks (): Promise> { + const bookmarks = new Set() + + for (const [, dbBookmarks] of this._bookmarksPerDb) { + for (const bm of dbBookmarks) { + bookmarks.add(bm) + } + } + if (typeof this._bookmarksSupplier === 'function') { + const suppliedBookmarks = await this._bookmarksSupplier() ?? [] + for (const bm of suppliedBookmarks) { + bookmarks.add(bm) + } + } + + return bookmarks + } + + async forget (databases: Iterable): Promise { + for (const database of databases) { + this._bookmarksPerDb.delete(database) + } + } +} diff --git a/packages/neo4j-driver-deno/lib/core/connection-provider.ts b/packages/neo4j-driver-deno/lib/core/connection-provider.ts new file mode 100644 index 000000000..e8bd12133 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/connection-provider.ts @@ -0,0 +1,123 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ +/* eslint-disable @typescript-eslint/promise-function-async */ + +import Connection from './connection.ts' +import { bookmarks } from './internal/index.ts' +import { ServerInfo } from './result-summary.ts' + +/** + * Inteface define a common way to acquire a connection + * + * @private + */ +class ConnectionProvider { + /** + * This method acquires a connection against the specified database. + * + * Access mode and Bookmarks only applies to routing driver. Access mode only + * differentiates the target server for the connection, where WRITE selects a + * WRITER server, whereas READ selects a READ server. Bookmarks, when specified, + * is only passed to the routing discovery procedure, for the system database to + * synchronize on creation of databases and is never used in direct drivers. + * + * @param {object} param - object parameter + * @property {string} param.accessMode - the access mode for the to-be-acquired connection + * @property {string} param.database - the target database for the to-be-acquired connection + * @property {Bookmarks} param.bookmarks - the bookmarks to send to routing discovery + * @property {string} param.impersonatedUser - the impersonated user + * @property {function (databaseName:string?)} param.onDatabaseNameResolved - Callback called when the database name get resolved + */ + acquireConnection (param?: { + accessMode?: string + database?: string + bookmarks: bookmarks.Bookmarks + impersonatedUser?: string + onDatabaseNameResolved?: (databaseName?: string) => void + }): Promise { + throw Error('Not implemented') + } + + /** + * This method checks whether the backend database supports multi database functionality + * by checking protocol handshake result. + * + * @returns {Promise} + */ + supportsMultiDb (): Promise { + throw Error('Not implemented') + } + + /** + * This method checks whether the backend database supports transaction config functionality + * by checking protocol handshake result. + * + * @returns {Promise} + */ + supportsTransactionConfig (): Promise { + throw Error('Not implemented') + } + + /** + * This method checks whether the backend database supports transaction config functionality + * by checking protocol handshake result. + * + * @returns {Promise} + */ + supportsUserImpersonation (): Promise { + throw Error('Not implemented') + } + + /** + * This method verifies the connectivity of the database by trying to acquire a connection + * for each server available in the cluster. + * + * @param {object} param - object parameter + * @property {string} param.database - the target database for the to-be-acquired connection + * @property {string} param.accessMode - the access mode for the to-be-acquired connection + * + * @returns {Promise} promise resolved with server info or rejected with error. + */ + verifyConnectivityAndGetServerInfo (param?: { database?: string, accessMode?: string }): Promise { + throw Error('Not implemented') + } + + /** + * Returns the protocol version negotiated via handshake. + * + * Note that this function call _always_ causes a round-trip to the server. + * + * @returns {Promise} the protocol version negotiated via handshake. + * @throws {Error} When protocol negotiation fails + */ + getNegotiatedProtocolVersion (): Promise { + throw Error('Not Implemented') + } + + /** + * Closes this connection provider along with its internals (connections, pools, etc.) + * + * @returns {Promise} + */ + close (): Promise { + throw Error('Not implemented') + } +} + +export default ConnectionProvider diff --git a/packages/neo4j-driver-deno/lib/core/connection.ts b/packages/neo4j-driver-deno/lib/core/connection.ts new file mode 100644 index 000000000..30c896932 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/connection.ts @@ -0,0 +1,122 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ +/* eslint-disable @typescript-eslint/promise-function-async */ + +import { ServerAddress } from './internal/server-address.ts' + +/** + * Interface which defines the raw connection with the database + * @private + */ +class Connection { + get id (): string { + return '' + } + + get databaseId (): string { + return '' + } + + get server (): any { + return {} + } + + /** + * @property {ServerAddress} the server address this connection is opened against + */ + get address (): ServerAddress | undefined { + return undefined + } + + /** + * @property {ServerVersion} the version of the server this connection is connected to + */ + get version (): any { + return undefined + } + + /** + * @returns {boolean} whether this connection is in a working condition + */ + isOpen (): boolean { + return false + } + + /** + * @todo be removed and internalize the methods + * @returns {any} the underlying bolt protocol assigned to this connection + */ + protocol (): any { + throw Error('Not implemented') + } + + /** + * Connect to the target address, negotiate Bolt protocol and send initialization message. + * @param {string} userAgent the user agent for this driver. + * @param {Object} authToken the object containing auth information. + * @return {Promise} promise resolved with the current connection if connection is successful. Rejected promise otherwise. + */ + connect (userAgent: string, authToken: any): Promise { + throw Error('Not implemented') + } + + /** + * Write a message to the network channel. + * @param {RequestMessage} message the message to write. + * @param {ResultStreamObserver} observer the response observer. + * @param {boolean} flush `true` if flush should happen after the message is written to the buffer. + */ + write (message: any, observer: any, flush: boolean): void { + throw Error('Not implemented') + } + + /** + * Send a RESET-message to the database. Message is immediately flushed to the network. + * @return {Promise} promise resolved when SUCCESS-message response arrives, or failed when other response messages arrives. + */ + resetAndFlush (): Promise { + throw Error('Not implemented') + } + + /** + * Checks if there is an ongoing request being handled + * @return {boolean} `true` if there is an ongoing request being handled + */ + hasOngoingObservableRequests (): boolean { + throw Error('Not implemented') + } + + /** + * Call close on the channel. + * @returns {Promise} - A promise that will be resolved when the connection is closed. + * + */ + close (): Promise { + throw Error('Not implemented') + } + + /** + * Called to release the connection + */ + _release (): Promise { + return Promise.resolve() + } +} + +export default Connection diff --git a/packages/neo4j-driver-deno/lib/core/driver.ts b/packages/neo4j-driver-deno/lib/core/driver.ts new file mode 100644 index 000000000..cde7a6865 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/driver.ts @@ -0,0 +1,594 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ +/* eslint-disable @typescript-eslint/promise-function-async */ +import ConnectionProvider from './connection-provider.ts' +import { Bookmarks } from './internal/bookmarks.ts' +import ConfiguredCustomResolver from './internal/resolver/configured-custom-resolver.ts' + +import { + ACCESS_MODE_READ, + ACCESS_MODE_WRITE, + FETCH_ALL, + DEFAULT_CONNECTION_TIMEOUT_MILLIS, + DEFAULT_POOL_ACQUISITION_TIMEOUT, + DEFAULT_POOL_MAX_SIZE +} from './internal/constants.ts' +import { Logger } from './internal/logger.ts' +import Session from './session.ts' +import { ServerInfo } from './result-summary.ts' +import { ENCRYPTION_ON } from './internal/util.ts' +import { + EncryptionLevel, + LoggingConfig, + TrustStrategy, + SessionMode +} from './types.ts' +import { ServerAddress } from './internal/server-address.ts' +import BookmarkManager from './bookmark-manager.ts' + +const DEFAULT_MAX_CONNECTION_LIFETIME: number = 60 * 60 * 1000 // 1 hour + +/** + * The default record fetch size. This is used in Bolt V4 protocol to pull query execution result in batches. + * @type {number} + */ +const DEFAULT_FETCH_SIZE: number = 1000 + +/** + * Constant that represents read session access mode. + * Should be used like this: `driver.session({ defaultAccessMode: neo4j.session.READ })`. + * @type {string} + */ +const READ: SessionMode = ACCESS_MODE_READ + +/** + * Constant that represents write session access mode. + * Should be used like this: `driver.session({ defaultAccessMode: neo4j.session.WRITE })`. + * @type {string} + */ +const WRITE: SessionMode = ACCESS_MODE_WRITE + +let idGenerator = 0 + +interface MetaInfo { + routing: boolean + typename: string + address: string | ServerAddress +} + +type CreateConnectionProvider = ( + id: number, + config: Object, + log: Logger, + hostNameResolver: ConfiguredCustomResolver +) => ConnectionProvider + +type CreateSession = (args: { + mode: SessionMode + connectionProvider: ConnectionProvider + bookmarks?: Bookmarks + database: string + config: any + reactive: boolean + fetchSize: number + impersonatedUser?: string + bookmarkManager?: BookmarkManager +}) => Session + +interface DriverConfig { + encrypted?: EncryptionLevel | boolean + trust?: TrustStrategy + fetchSize?: number + logging?: LoggingConfig +} + +/** + * The session configuration + * + * @interface + */ +class SessionConfig { + defaultAccessMode?: SessionMode + bookmarks?: string | string[] + database?: string + impersonatedUser?: string + fetchSize?: number + bookmarkManager?: BookmarkManager + + /** + * @constructor + * @private + */ + constructor () { + /** + * The access mode of this session, allowed values are {@link READ} and {@link WRITE}. + * **Default**: {@link WRITE} + * @type {string} + */ + this.defaultAccessMode = WRITE + /** + * The initial reference or references to some previous + * transactions. Value is optional and absence indicates that that the bookmarks do not exist or are unknown. + * @type {string|string[]|undefined} + */ + this.bookmarks = [] + + /** + * The database this session will operate on. + * + * @type {string|undefined} + */ + this.database = '' + + /** + * The username which the user wants to impersonate for the duration of the session. + * + * @type {string|undefined} + */ + this.impersonatedUser = undefined + + /** + * The record fetch size of each batch of this session. + * + * Use {@link FETCH_ALL} to always pull all records in one batch. This will override the config value set on driver config. + * + * @type {number|undefined} + */ + this.fetchSize = undefined + /** + * Configure a BookmarkManager for the session to use + * + * A BookmarkManager is a piece of software responsible for keeping casual consistency between different sessions by sharing bookmarks + * between the them. + * Enabling it is done by supplying an BookmarkManager implementation instance to this param. + * A default implementation could be acquired by calling the factory function {@link bookmarkManager}. + * + * **Warning**: Share the same BookmarkManager instance accross all session can have a negative impact + * on performance since all the queries will wait for the latest changes being propagated across the cluster. + * For keeping consistency between a group of queries, use {@link Session} for grouping them. + * For keeping consistency between a group of sessions, use {@link BookmarkManager} instance for groupping them. + * + * @example + * const bookmarkManager = neo4j.bookmarkManager() + * const linkedSession1 = driver.session({ database:'neo4j', bookmarkManager }) + * const linkedSession2 = driver.session({ database:'neo4j', bookmarkManager }) + * const unlinkedSession = driver.session({ database:'neo4j' }) + * + * // Creating Driver User + * const createUserQueryResult = await linkedSession1.run('CREATE (p:Person {name: $name})', { name: 'Driver User'}) + * + * // Reading Driver User will *NOT* wait of the changes being propagated to the server before RUN the query + * // So the 'Driver User' person might not exist in the Result + * const unlinkedReadResult = await unlinkedSession.run('CREATE (p:Person {name: $name}) RETURN p', { name: 'Driver User'}) + * + * // Reading Driver User will wait of the changes being propagated to the server before RUN the query + * // So the 'Driver User' person should exist in the Result, unless deleted. + * const linkedSesssion2 = await linkedSession2.run('CREATE (p:Person {name: $name}) RETURN p', { name: 'Driver User'}) + * + * await linkedSession1.close() + * await linkedSession2.close() + * await unlinkedSession.close() + * + * @experimental + * @type {BookmarkManager|undefined} + * @since 5.0 + */ + this.bookmarkManager = undefined + } +} + +/** + * A driver maintains one or more {@link Session}s with a remote + * Neo4j instance. Through the {@link Session}s you can send queries + * and retrieve results from the database. + * + * Drivers are reasonably expensive to create - you should strive to keep one + * driver instance around per Neo4j Instance you connect to. + * + * @access public + */ +class Driver { + private readonly _id: number + private readonly _meta: MetaInfo + private readonly _config: DriverConfig + private readonly _log: Logger + private readonly _createConnectionProvider: CreateConnectionProvider + private _connectionProvider: ConnectionProvider | null + private readonly _createSession: CreateSession + + /** + * You should not be calling this directly, instead use {@link driver}. + * @constructor + * @protected + * @param {Object} meta Metainformation about the driver + * @param {Object} config + * @param {function(id: number, config:Object, log:Logger, hostNameResolver: ConfiguredCustomResolver): ConnectionProvider } createConnectonProvider Creates the connection provider + * @param {function(args): Session } createSession Creates the a session + */ + constructor ( + meta: MetaInfo, + config: DriverConfig = {}, + createConnectonProvider: CreateConnectionProvider, + createSession: CreateSession = args => new Session(args) + ) { + sanitizeConfig(config) + + const log = Logger.create(config) + + validateConfig(config, log) + + this._id = idGenerator++ + this._meta = meta + this._config = config + this._log = log + this._createConnectionProvider = createConnectonProvider + this._createSession = createSession + + /** + * Reference to the connection provider. Initialized lazily by {@link _getOrCreateConnectionProvider}. + * @type {ConnectionProvider} + * @protected + */ + this._connectionProvider = null + + this._afterConstruction() + } + + /** + * Verifies connectivity of this driver by trying to open a connection with the provided driver options. + * + * @deprecated This return of this method will change in 6.0.0 to not async return the {@link ServerInfo} and + * async return {@link void} instead. If you need to use the server info, use {@link getServerInfo} instead. + * + * @public + * @param {Object} param - The object parameter + * @param {string} param.database - The target database to verify connectivity for. + * @returns {Promise} promise resolved with server info or rejected with error. + */ + verifyConnectivity ({ database = '' }: { database?: string } = {}): Promise { + const connectionProvider = this._getOrCreateConnectionProvider() + return connectionProvider.verifyConnectivityAndGetServerInfo({ database, accessMode: READ }) + } + + /** + * Get ServerInfo for the giver database. + * + * @param {Object} param - The object parameter + * @param {string} param.database - The target database to verify connectivity for. + * @returns {Promise} promise resolved with void or rejected with error. + */ + getServerInfo ({ database = '' }: { database?: string } = {}): Promise { + const connectionProvider = this._getOrCreateConnectionProvider() + return connectionProvider.verifyConnectivityAndGetServerInfo({ database, accessMode: READ }) + } + + /** + * Returns whether the server supports multi database capabilities based on the protocol + * version negotiated via handshake. + * + * Note that this function call _always_ causes a round-trip to the server. + * + * @returns {Promise} promise resolved with a boolean or rejected with error. + */ + supportsMultiDb (): Promise { + const connectionProvider = this._getOrCreateConnectionProvider() + return connectionProvider.supportsMultiDb() + } + + /** + * Returns whether the server supports transaction config capabilities based on the protocol + * version negotiated via handshake. + * + * Note that this function call _always_ causes a round-trip to the server. + * + * @returns {Promise} promise resolved with a boolean or rejected with error. + */ + supportsTransactionConfig (): Promise { + const connectionProvider = this._getOrCreateConnectionProvider() + return connectionProvider.supportsTransactionConfig() + } + + /** + * Returns whether the server supports user impersonation capabilities based on the protocol + * version negotiated via handshake. + * + * Note that this function call _always_ causes a round-trip to the server. + * + * @returns {Promise} promise resolved with a boolean or rejected with error. + */ + supportsUserImpersonation (): Promise { + const connectionProvider = this._getOrCreateConnectionProvider() + return connectionProvider.supportsUserImpersonation() + } + + /** + * Returns the protocol version negotiated via handshake. + * + * Note that this function call _always_ causes a round-trip to the server. + * + * @returns {Promise} the protocol version negotiated via handshake. + * @throws {Error} When protocol negotiation fails + */ + getNegotiatedProtocolVersion (): Promise { + const connectionProvider = this._getOrCreateConnectionProvider() + return connectionProvider.getNegotiatedProtocolVersion() + } + + /** + * Returns boolean to indicate if driver has been configured with encryption enabled. + * + * @returns {boolean} + */ + isEncrypted (): boolean { + return this._isEncrypted() + } + + /** + * @protected + * @returns {boolean} + */ + _supportsRouting (): boolean { + return this._meta.routing + } + + /** + * Returns boolean to indicate if driver has been configured with encryption enabled. + * + * @protected + * @returns {boolean} + */ + _isEncrypted (): boolean { + return this._config.encrypted === ENCRYPTION_ON || this._config.encrypted === true + } + + /** + * Returns the configured trust strategy that the driver has been configured with. + * + * @protected + * @returns {TrustStrategy} + */ + _getTrust (): TrustStrategy | undefined { + return this._config.trust + } + + /** + * Acquire a session to communicate with the database. The session will + * borrow connections from the underlying connection pool as required and + * should be considered lightweight and disposable. + * + * This comes with some responsibility - make sure you always call + * {@link close} when you are done using a session, and likewise, + * make sure you don't close your session before you are done using it. Once + * it is closed, the underlying connection will be released to the connection + * pool and made available for others to use. + * + * @public + * @param {SessionConfig} param - The session configuration + * @return {Session} new session. + */ + session ({ + defaultAccessMode = WRITE, + bookmarks: bookmarkOrBookmarks, + database = '', + impersonatedUser, + fetchSize, + bookmarkManager + }: SessionConfig = {}): Session { + return this._newSession({ + defaultAccessMode, + bookmarkOrBookmarks, + database, + reactive: false, + impersonatedUser, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + fetchSize: validateFetchSizeValue(fetchSize, this._config.fetchSize!), + bookmarkManager + }) + } + + /** + * Close all open sessions and other associated resources. You should + * make sure to use this when you are done with this driver instance. + * @public + * @return {Promise} promise resolved when the driver is closed. + */ + close (): Promise { + this._log.info(`Driver ${this._id} closing`) + if (this._connectionProvider != null) { + return this._connectionProvider.close() + } + return Promise.resolve() + } + + /** + * @protected + */ + _afterConstruction (): void { + this._log.info( + `${this._meta.typename} driver ${this._id} created for server address ${this._meta.address.toString()}` + ) + } + + /** + * @private + */ + _newSession ({ + defaultAccessMode, + bookmarkOrBookmarks, + database, + reactive, + impersonatedUser, + fetchSize, + bookmarkManager + }: { + defaultAccessMode: SessionMode + bookmarkOrBookmarks?: string | string[] + database: string + reactive: boolean + impersonatedUser?: string + fetchSize: number + bookmarkManager?: BookmarkManager + }): Session { + const sessionMode = Session._validateSessionMode(defaultAccessMode) + const connectionProvider = this._getOrCreateConnectionProvider() + const bookmarks = bookmarkOrBookmarks != null + ? new Bookmarks(bookmarkOrBookmarks) + : Bookmarks.empty() + + return this._createSession({ + mode: sessionMode, + database: database ?? '', + connectionProvider, + bookmarks, + config: this._config, + reactive, + impersonatedUser, + fetchSize, + bookmarkManager + }) + } + + /** + * @private + */ + _getOrCreateConnectionProvider (): ConnectionProvider { + if (this._connectionProvider == null) { + this._connectionProvider = this._createConnectionProvider( + this._id, + this._config, + this._log, + createHostNameResolver(this._config) + ) + } + + return this._connectionProvider + } +} + +/** + * @private + * @returns {Object} the given config. + */ +function validateConfig (config: any, log: Logger): any { + const resolver = config.resolver + if (resolver !== null && resolver !== undefined && typeof resolver !== 'function') { + throw new TypeError( + `Configured resolver should be a function. Got: ${typeof resolver}` + ) + } + + if (config.connectionAcquisitionTimeout < config.connectionTimeout) { + log.warn( + 'Configuration for "connectionAcquisitionTimeout" should be greater than ' + + 'or equal to "connectionTimeout". Otherwise, the connection acquisition ' + + 'timeout will take precedence for over the connection timeout in scenarios ' + + 'where a new connection is created while it is acquired' + ) + } + return config +} + +/** + * @private + */ +function sanitizeConfig (config: any): void { + config.maxConnectionLifetime = sanitizeIntValue( + config.maxConnectionLifetime, + DEFAULT_MAX_CONNECTION_LIFETIME + ) + config.maxConnectionPoolSize = sanitizeIntValue( + config.maxConnectionPoolSize, + DEFAULT_POOL_MAX_SIZE + ) + config.connectionAcquisitionTimeout = sanitizeIntValue( + config.connectionAcquisitionTimeout, + DEFAULT_POOL_ACQUISITION_TIMEOUT + ) + config.fetchSize = validateFetchSizeValue( + config.fetchSize, + DEFAULT_FETCH_SIZE + ) + config.connectionTimeout = extractConnectionTimeout(config) +} + +/** + * @private + */ +function sanitizeIntValue (rawValue: any, defaultWhenAbsent: number): number { + const sanitizedValue = parseInt(rawValue, 10) + if (sanitizedValue > 0 || sanitizedValue === 0) { + return sanitizedValue + } else if (sanitizedValue < 0) { + return Number.MAX_SAFE_INTEGER + } else { + return defaultWhenAbsent + } +} + +/** + * @private + */ +function validateFetchSizeValue ( + rawValue: any, + defaultWhenAbsent: number +): number { + const fetchSize = parseInt(rawValue, 10) + if (fetchSize > 0 || fetchSize === FETCH_ALL) { + return fetchSize + } else if (fetchSize === 0 || fetchSize < 0) { + throw new Error( + `The fetch size can only be a positive value or ${FETCH_ALL} for ALL. However fetchSize = ${fetchSize}` + ) + } else { + return defaultWhenAbsent + } +} + +/** + * @private + */ +function extractConnectionTimeout (config: any): number | null { + const configuredTimeout = parseInt(config.connectionTimeout, 10) + if (configuredTimeout === 0) { + // timeout explicitly configured to 0 + return null + } else if (!isNaN(configuredTimeout) && configuredTimeout < 0) { + // timeout explicitly configured to a negative value + return null + } else if (isNaN(configuredTimeout)) { + // timeout not configured, use default value + return DEFAULT_CONNECTION_TIMEOUT_MILLIS + } else { + // timeout configured, use the provided value + return configuredTimeout + } +} + +/** + * @private + * @returns {ConfiguredCustomResolver} new custom resolver that wraps the passed-in resolver function. + * If resolved function is not specified, it defaults to an identity resolver. + */ +function createHostNameResolver (config: any): ConfiguredCustomResolver { + return new ConfiguredCustomResolver(config.resolver) +} + +export { Driver, READ, WRITE } +export type { SessionConfig } +export default Driver diff --git a/packages/neo4j-driver-deno/lib/core/error.ts b/packages/neo4j-driver-deno/lib/core/error.ts new file mode 100644 index 000000000..a2454be8f --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/error.ts @@ -0,0 +1,160 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ + +// A common place for constructing error objects, to keep them +// uniform across the driver surface. + +/** + * Error code representing complete loss of service. Used by {@link Neo4jError#code}. + * @type {string} + */ +const SERVICE_UNAVAILABLE: string = 'ServiceUnavailable' + +/** + * Error code representing transient loss of service. Used by {@link Neo4jError#code}. + * @type {string} + */ +const SESSION_EXPIRED: string = 'SessionExpired' + +/** + * Error code representing serialization/deserialization issue in the Bolt protocol. Used by {@link Neo4jError#code}. + * @type {string} + */ +const PROTOCOL_ERROR: string = 'ProtocolError' + +/** + * Error code representing an no classified error. Used by {@link Neo4jError#code}. + * @type {string} + */ +const NOT_AVAILABLE: string = 'N/A' + +/** + * Possible error codes in the {@link Neo4jError} + */ +type Neo4jErrorCode = + | typeof SERVICE_UNAVAILABLE + | typeof SESSION_EXPIRED + | typeof PROTOCOL_ERROR + | typeof NOT_AVAILABLE + +/// TODO: Remove definitions of this.constructor and this.__proto__ +/** + * Class for all errors thrown/returned by the driver. + */ +class Neo4jError extends Error { + /** + * Optional error code. Will be populated when error originates in the database. + */ + code: Neo4jErrorCode + 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. + */ + constructor (message: string, code: Neo4jErrorCode, cause?: Error) { + // eslint-disable-next-line + // @ts-ignore: not available in ES6 yet + super(message, cause != null ? { cause } : undefined) + 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 + */ + this.retriable = _isRetriableCode(code) + } + + /** + * Verifies if the given error is retriable. + * + * @param {object|undefined|null} error the error object + * @returns {boolean} true if the error is retriable + */ + static isRetriable (error?: any | null): boolean { + return error !== null && + error !== undefined && + error instanceof Neo4jError && + error.retriable + } +} + +/** + * Create a new error from a message and error code + * @param message the error message + * @param code the error code + * @return {Neo4jError} an {@link Neo4jError} + * @private + */ +function newError (message: string, code?: Neo4jErrorCode, cause?: Error): Neo4jError { + return new Neo4jError(message, code ?? NOT_AVAILABLE, cause) +} + +/** + * Verifies if the given error is retriable. + * + * @public + * @param {object|undefined|null} error the error object + * @returns {boolean} true if the error is retriable + */ +const isRetriableError = Neo4jError.isRetriable + +/** + * @private + * @param {string} code the error code + * @returns {boolean} true if the error is a retriable error + */ +function _isRetriableCode (code?: Neo4jErrorCode): boolean { + return code === SERVICE_UNAVAILABLE || + code === SESSION_EXPIRED || + _isAuthorizationExpired(code) || + _isTransientError(code) +} + +/** + * @private + * @param {string} code the error to check + * @return {boolean} true if the error is a transient error + */ +function _isTransientError (code?: Neo4jErrorCode): boolean { + return code?.includes('TransientError') === true +} + +/** + * @private + * @param {string} code the error to check + * @returns {boolean} true if the error is a service unavailable error + */ +function _isAuthorizationExpired (code?: Neo4jErrorCode): boolean { + return code === 'Neo.ClientError.Security.AuthorizationExpired' +} + +export { + newError, + isRetriableError, + Neo4jError, + SERVICE_UNAVAILABLE, + SESSION_EXPIRED, + PROTOCOL_ERROR +} diff --git a/packages/neo4j-driver-deno/lib/core/graph-types.ts b/packages/neo4j-driver-deno/lib/core/graph-types.ts new file mode 100644 index 000000000..8ec873917 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/graph-types.ts @@ -0,0 +1,474 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 Integer from './integer.ts' +import { stringify } from './json.ts' + +type StandardDate = Date +/** + * @typedef {number | Integer | bigint} NumberOrInteger + */ +type NumberOrInteger = number | Integer | bigint +interface Properties { [key: string]: any } + +const IDENTIFIER_PROPERTY_ATTRIBUTES = { + value: true, + enumerable: false, + configurable: false, + writable: false +} + +const NODE_IDENTIFIER_PROPERTY: string = '__isNode__' +const RELATIONSHIP_IDENTIFIER_PROPERTY: string = '__isRelationship__' +const UNBOUND_RELATIONSHIP_IDENTIFIER_PROPERTY: string = + '__isUnboundRelationship__' +const PATH_IDENTIFIER_PROPERTY: string = '__isPath__' +const PATH_SEGMENT_IDENTIFIER_PROPERTY: string = '__isPathSegment__' + +function hasIdentifierProperty (obj: any, property: string): boolean { + return obj != null && obj[property] === true +} + +/** + * Class for Node Type. + */ +class Node { + identity: T + labels: string[] + properties: P + elementId: string + /** + * @constructor + * @protected + * @param {NumberOrInteger} identity - Unique identity + * @param {Array} labels - Array for all labels + * @param {Properties} properties - Map with node properties + * @param {string} elementId - Node element identifier + */ + constructor (identity: T, labels: string[], properties: P, elementId?: string) { + /** + * Identity of the node. + * @type {NumberOrInteger} + * @deprecated use {@link Node#elementId} instead + */ + this.identity = identity + /** + * Labels of the node. + * @type {string[]} + */ + this.labels = labels + /** + * Properties of the node. + * @type {Properties} + */ + this.properties = properties + /** + * The Node element identifier. + * @type {string} + */ + this.elementId = _valueOrGetDefault(elementId, () => identity.toString()) + } + + /** + * @ignore + */ + toString (): string { + let s = '(' + this.elementId + for (let i = 0; i < this.labels.length; i++) { + s += ':' + this.labels[i] + } + const keys = Object.keys(this.properties) + if (keys.length > 0) { + s += ' {' + for (let i = 0; i < keys.length; i++) { + if (i > 0) s += ',' + s += keys[i] + ':' + stringify(this.properties[keys[i]]) + } + s += '}' + } + s += ')' + return s + } +} + +Object.defineProperty( + Node.prototype, + NODE_IDENTIFIER_PROPERTY, + IDENTIFIER_PROPERTY_ATTRIBUTES +) + +/** + * Test if given object is an instance of {@link Node} class. + * @param {Object} obj the object to test. + * @return {boolean} `true` if given object is a {@link Node}, `false` otherwise. + */ +function isNode (obj: object): obj is Node { + return hasIdentifierProperty(obj, NODE_IDENTIFIER_PROPERTY) +} + +/** + * Class for Relationship Type. + */ +class Relationship { + identity: T + start: T + end: T + type: string + properties: P + elementId: string + startNodeElementId: string + endNodeElementId: string + + /** + * @constructor + * @protected + * @param {NumberOrInteger} identity - Unique identity + * @param {NumberOrInteger} start - Identity of start Node + * @param {NumberOrInteger} end - Identity of end Node + * @param {string} type - Relationship type + * @param {Properties} properties - Map with relationship properties + * @param {string} elementId - Relationship element identifier + * @param {string} startNodeElementId - Start Node element identifier + * @param {string} endNodeElementId - End Node element identifier + */ + constructor ( + identity: T, start: T, end: T, type: string, properties: P, + elementId?: string, startNodeElementId?: string, endNodeElementId?: string + ) { + /** + * Identity of the relationship. + * @type {NumberOrInteger} + * @deprecated use {@link Relationship#elementId} instead + */ + this.identity = identity + /** + * Identity of the start node. + * @type {NumberOrInteger} + * @deprecated use {@link Relationship#startNodeElementId} instead + */ + this.start = start + /** + * Identity of the end node. + * @type {NumberOrInteger} + * @deprecated use {@link Relationship#endNodeElementId} instead + */ + this.end = end + /** + * Type of the relationship. + * @type {string} + */ + this.type = type + /** + * Properties of the relationship. + * @type {Properties} + */ + this.properties = properties + + /** + * The Relationship element identifier. + * @type {string} + */ + this.elementId = _valueOrGetDefault(elementId, () => identity.toString()) + + /** + * The Start Node element identifier. + * @type {string} + */ + this.startNodeElementId = _valueOrGetDefault(startNodeElementId, () => start.toString()) + + /** + * The End Node element identifier. + * @type {string} + */ + this.endNodeElementId = _valueOrGetDefault(endNodeElementId, () => end.toString()) + } + + /** + * @ignore + */ + toString (): string { + let s = '(' + this.startNodeElementId + ')-[:' + this.type + const keys = Object.keys(this.properties) + if (keys.length > 0) { + s += ' {' + for (let i = 0; i < keys.length; i++) { + if (i > 0) s += ',' + s += keys[i] + ':' + stringify(this.properties[keys[i]]) + } + s += '}' + } + s += ']->(' + this.endNodeElementId + ')' + return s + } +} + +Object.defineProperty( + Relationship.prototype, + RELATIONSHIP_IDENTIFIER_PROPERTY, + IDENTIFIER_PROPERTY_ATTRIBUTES +) + +/** + * Test if given object is an instance of {@link Relationship} class. + * @param {Object} obj the object to test. + * @return {boolean} `true` if given object is a {@link Relationship}, `false` otherwise. + */ +function isRelationship (obj: object): obj is Relationship { + return hasIdentifierProperty(obj, RELATIONSHIP_IDENTIFIER_PROPERTY) +} + +/** + * Class for UnboundRelationship Type. + * @access private + */ +class UnboundRelationship { + identity: T + type: string + properties: P + elementId: string + + /** + * @constructor + * @protected + * @param {NumberOrInteger} identity - Unique identity + * @param {string} type - Relationship type + * @param {Properties} properties - Map with relationship properties + * @param {string} elementId - Relationship element identifier + */ + constructor (identity: T, type: string, properties: any, elementId?: string) { + /** + * Identity of the relationship. + * @type {NumberOrInteger} + * @deprecated use {@link UnboundRelationship#elementId} instead + */ + this.identity = identity + /** + * Type of the relationship. + * @type {string} + */ + this.type = type + /** + * Properties of the relationship. + * @type {Properties} + */ + this.properties = properties + + /** + * The Relationship element identifier. + * @type {string} + */ + this.elementId = _valueOrGetDefault(elementId, () => identity.toString()) + } + + /** + * Bind relationship + * + * @protected + * @deprecated use {@link UnboundRelationship#bindTo} instead + * @param {Integer} start - Identity of start node + * @param {Integer} end - Identity of end node + * @return {Relationship} - Created relationship + */ + bind (start: T, end: T): Relationship { + return new Relationship( + this.identity, + start, + end, + this.type, + this.properties, + this.elementId + ) + } + + /** + * Bind relationship + * + * @protected + * @param {Node} start - Start Node + * @param {Node} end - End Node + * @return {Relationship} - Created relationship + */ + bindTo (start: Node, end: Node): Relationship { + return new Relationship( + this.identity, + start.identity, + end.identity, + this.type, + this.properties, + this.elementId, + start.elementId, + end.elementId + ) + } + + /** + * @ignore + */ + toString (): string { + let s = '-[:' + this.type + const keys = Object.keys(this.properties) + if (keys.length > 0) { + s += ' {' + for (let i = 0; i < keys.length; i++) { + if (i > 0) s += ',' + s += keys[i] + ':' + stringify(this.properties[keys[i]]) + } + s += '}' + } + s += ']->' + return s + } +} + +Object.defineProperty( + UnboundRelationship.prototype, + UNBOUND_RELATIONSHIP_IDENTIFIER_PROPERTY, + IDENTIFIER_PROPERTY_ATTRIBUTES +) + +/** + * Test if given object is an instance of {@link UnboundRelationship} class. + * @param {Object} obj the object to test. + * @return {boolean} `true` if given object is a {@link UnboundRelationship}, `false` otherwise. + */ +function isUnboundRelationship (obj: object): obj is UnboundRelationship { + return hasIdentifierProperty(obj, UNBOUND_RELATIONSHIP_IDENTIFIER_PROPERTY) +} + +/** + * Class for PathSegment Type. + */ +class PathSegment { + start: Node + relationship: Relationship + end: Node + /** + * @constructor + * @protected + * @param {Node} start - start node + * @param {Relationship} rel - relationship that connects start and end node + * @param {Node} end - end node + */ + constructor (start: Node, rel: Relationship, end: Node) { + /** + * Start node. + * @type {Node} + */ + this.start = start + /** + * Relationship. + * @type {Relationship} + */ + this.relationship = rel + /** + * End node. + * @type {Node} + */ + this.end = end + } +} + +Object.defineProperty( + PathSegment.prototype, + PATH_SEGMENT_IDENTIFIER_PROPERTY, + IDENTIFIER_PROPERTY_ATTRIBUTES +) + +/** + * Test if given object is an instance of {@link PathSegment} class. + * @param {Object} obj the object to test. + * @return {boolean} `true` if given object is a {@link PathSegment}, `false` otherwise. + */ +function isPathSegment (obj: object): obj is PathSegment { + return hasIdentifierProperty(obj, PATH_SEGMENT_IDENTIFIER_PROPERTY) +} + +/** + * Class for Path Type. + */ +class Path { + start: Node + end: Node + segments: Array> + length: number + /** + * @constructor + * @protected + * @param {Node} start - start node + * @param {Node} end - end node + * @param {Array} segments - Array of Segments + */ + constructor (start: Node, end: Node, segments: Array>) { + /** + * Start node. + * @type {Node} + */ + this.start = start + /** + * End node. + * @type {Node} + */ + this.end = end + /** + * Segments. + * @type {Array} + */ + this.segments = segments + /** + * Length of the segments. + * @type {Number} + */ + this.length = segments.length + } +} + +Object.defineProperty( + Path.prototype, + PATH_IDENTIFIER_PROPERTY, + IDENTIFIER_PROPERTY_ATTRIBUTES +) + +/** + * Test if given object is an instance of {@link Path} class. + * @param {Object} obj the object to test. + * @return {boolean} `true` if given object is a {@link Path}, `false` otherwise. + */ +function isPath (obj: object): obj is Path { + return hasIdentifierProperty(obj, PATH_IDENTIFIER_PROPERTY) +} + +function _valueOrGetDefault (value: T|undefined|null, getDefault: () => T): T { + return value === undefined || value === null ? getDefault() : value +} + +export { + Node, + isNode, + Relationship, + isRelationship, + UnboundRelationship, + isUnboundRelationship, + Path, + isPath, + PathSegment, + isPathSegment +} +export type { + StandardDate, + NumberOrInteger +} diff --git a/packages/neo4j-driver-deno/lib/core/index.ts b/packages/neo4j-driver-deno/lib/core/index.ts new file mode 100644 index 000000000..1cd30eeea --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/index.ts @@ -0,0 +1,227 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { + newError, + Neo4jError, + isRetriableError, + PROTOCOL_ERROR, + SERVICE_UNAVAILABLE, + SESSION_EXPIRED +} from './error.ts' +import Integer, { int, isInt, inSafeRange, toNumber, toString } from './integer.ts' +import { + Date, + DateTime, + Duration, + isDate, + isDateTime, + isDuration, + isLocalDateTime, + isLocalTime, + isTime, + LocalDateTime, + LocalTime, + Time +} from './temporal-types.ts' +import { + StandardDate, + NumberOrInteger, + Node, + isNode, + Relationship, + isRelationship, + UnboundRelationship, + isUnboundRelationship, + Path, + isPath, + PathSegment, + isPathSegment +} from './graph-types.ts' +import Record from './record.ts' +import { isPoint, Point } from './spatial-types.ts' +import ResultSummary, { + queryType, + ServerInfo, + Notification, + NotificationPosition, + Plan, + ProfiledPlan, + QueryStatistics, + Stats +} from './result-summary.ts' +import Result, { QueryResult, ResultObserver } from './result.ts' +import ConnectionProvider from './connection-provider.ts' +import Connection from './connection.ts' +import Transaction from './transaction.ts' +import ManagedTransaction from './transaction-managed.ts' +import TransactionPromise from './transaction-promise.ts' +import Session, { TransactionConfig } from './session.ts' +import Driver, * as driver from './driver.ts' +import auth from './auth.ts' +import BookmarkManager, { BookmarkManagerConfig, bookmarkManager } from './bookmark-manager.ts' +import { SessionConfig } from './driver.ts' +import * as types from './types.ts' +import * as json from './json.ts' +import * as internal from './internal/index.ts' + +/** + * Object containing string constants representing predefined {@link Neo4jError} codes. + */ +const error = { + SERVICE_UNAVAILABLE, + SESSION_EXPIRED, + PROTOCOL_ERROR +} + +/** + * @private + */ +const forExport = { + newError, + Neo4jError, + isRetriableError, + error, + Integer, + int, + isInt, + inSafeRange, + toNumber, + toString, + internal, + isPoint, + Point, + Date, + DateTime, + Duration, + isDate, + isDateTime, + isDuration, + isLocalDateTime, + isLocalTime, + isTime, + LocalDateTime, + LocalTime, + Time, + Node, + isNode, + Relationship, + isRelationship, + UnboundRelationship, + isUnboundRelationship, + Path, + isPath, + PathSegment, + isPathSegment, + Record, + ResultSummary, + queryType, + ServerInfo, + Notification, + Plan, + ProfiledPlan, + QueryStatistics, + Stats, + Result, + Transaction, + ManagedTransaction, + TransactionPromise, + Session, + Driver, + Connection, + types, + driver, + json, + auth, + bookmarkManager +} + +export { + newError, + Neo4jError, + isRetriableError, + error, + Integer, + int, + isInt, + inSafeRange, + toNumber, + toString, + internal, + isPoint, + Point, + Date, + DateTime, + Duration, + isDate, + isDateTime, + isDuration, + isLocalDateTime, + isLocalTime, + isTime, + LocalDateTime, + LocalTime, + Time, + Node, + isNode, + Relationship, + isRelationship, + UnboundRelationship, + isUnboundRelationship, + Path, + isPath, + PathSegment, + isPathSegment, + Record, + ResultSummary, + queryType, + ServerInfo, + Notification, + Plan, + ProfiledPlan, + QueryStatistics, + Stats, + Result, + ConnectionProvider, + Connection, + Transaction, + ManagedTransaction, + TransactionPromise, + Session, + Driver, + types, + driver, + json, + auth, + bookmarkManager +} + +export type { + StandardDate, + NumberOrInteger, + NotificationPosition, + QueryResult, + ResultObserver, + TransactionConfig, + BookmarkManager, + BookmarkManagerConfig, + SessionConfig +} + +export default forExport diff --git a/packages/neo4j-driver-deno/lib/core/integer.ts b/packages/neo4j-driver-deno/lib/core/integer.ts new file mode 100644 index 000000000..3f70960ed --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/integer.ts @@ -0,0 +1,1093 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ + +// 64-bit Integer library, originally from Long.js by dcodeIO +// https://github.com/dcodeIO/Long.js +// License Apache 2 + +import { newError } from './error.ts' + +/** + * A cache of the Integer representations of small integer values. + * @type {!Object} + * @inner + * @private + */ +// eslint-disable-next-line no-use-before-define +const INT_CACHE: Map = new Map() + +/** + * Constructs a 64 bit two's-complement integer, given its low and high 32 bit values as *signed* integers. + * See exported functions for more convenient ways of operating integers. + * Use `int()` function to create new integers, `isInt()` to check if given object is integer, + * `inSafeRange()` to check if it is safe to convert given value to native number, + * `toNumber()` and `toString()` to convert given integer to number or string respectively. + * @access public + * @exports Integer + * @class A Integer class for representing a 64 bit two's-complement integer value. + * @param {number} low The low (signed) 32 bits of the long + * @param {number} high The high (signed) 32 bits of the long + * + * @constructor + */ +class Integer { + low: number + high: number + + constructor (low?: number, high?: number) { + /** + * The low 32 bits as a signed value. + * @type {number} + * @expose + */ + this.low = low ?? 0 + + /** + * The high 32 bits as a signed value. + * @type {number} + * @expose + */ + this.high = high ?? 0 + } + + // The internal representation of an Integer is the two given signed, 32-bit values. + // We use 32-bit pieces because these are the size of integers on which + // JavaScript performs bit-operations. For operations like addition and + // multiplication, we split each number into 16 bit pieces, which can easily be + // multiplied within JavaScript's floating-point representation without overflow + // or change in sign. + // + // In the algorithms below, we frequently reduce the negative case to the + // positive case by negating the input(s) and then post-processing the result. + // Note that we must ALWAYS check specially whether those values are MIN_VALUE + // (-2^63) because -MIN_VALUE == MIN_VALUE (since 2^63 cannot be represented as + // a positive number, it overflows back into a negative). Not handling this + // case would often result in infinite recursion. + // + // Common constant values ZERO, ONE, NEG_ONE, etc. are defined below the from* + // methods on which they depend. + + inSafeRange (): boolean { + return ( + this.greaterThanOrEqual(Integer.MIN_SAFE_VALUE) && + this.lessThanOrEqual(Integer.MAX_SAFE_VALUE) + ) + } + + /** + * Converts the Integer to an exact javascript Number, assuming it is a 32 bit integer. + * @returns {number} + * @expose + */ + toInt (): number { + return this.low + } + + /** + * Converts the Integer to a the nearest floating-point representation of this value (double, 53 bit mantissa). + * @returns {number} + * @expose + */ + toNumber (): number { + return this.high * TWO_PWR_32_DBL + (this.low >>> 0) + } + + /** + * Converts the Integer to a BigInt representation of this value + * @returns {bigint} + * @expose + */ + toBigInt (): bigint { + if (this.isZero()) { + return BigInt(0) + } else if (this.isPositive()) { + return ( + BigInt(this.high >>> 0) * BigInt(TWO_PWR_32_DBL) + + BigInt(this.low >>> 0) + ) + } else { + const negate = this.negate() + return ( + BigInt(-1) * + (BigInt(negate.high >>> 0) * BigInt(TWO_PWR_32_DBL) + + BigInt(negate.low >>> 0)) + ) + } + } + + /** + * Converts the Integer to native number or -Infinity/+Infinity when it does not fit. + * @return {number} + * @package + */ + toNumberOrInfinity (): number { + if (this.lessThan(Integer.MIN_SAFE_VALUE)) { + return Number.NEGATIVE_INFINITY + } else if (this.greaterThan(Integer.MAX_SAFE_VALUE)) { + return Number.POSITIVE_INFINITY + } else { + return this.toNumber() + } + } + + /** + * Converts the Integer to a string written in the specified radix. + * @param {number=} radix Radix (2-36), defaults to 10 + * @returns {string} + * @override + * @throws {RangeError} If `radix` is out of range + * @expose + */ + toString (radix?: number): string { + radix = radix ?? 10 + if (radix < 2 || radix > 36) { + throw RangeError('radix out of range: ' + radix.toString()) + } + if (this.isZero()) { + return '0' + } + let rem: Integer + if (this.isNegative()) { + if (this.equals(Integer.MIN_VALUE)) { + // We need to change the Integer value before it can be negated, so we remove + // the bottom-most digit in this base and then recurse to do the rest. + const radixInteger = Integer.fromNumber(radix) + const div = this.div(radixInteger) + rem = div.multiply(radixInteger).subtract(this) + return div.toString(radix) + rem.toInt().toString(radix) + } else { + return '-' + this.negate().toString(radix) + } + } + + // Do several (6) digits each time through the loop, so as to + // minimize the calls to the very expensive emulated div. + const radixToPower = Integer.fromNumber(Math.pow(radix, 6)) + rem = this + let result = '' + while (true) { + const remDiv = rem.div(radixToPower) + const intval = rem.subtract(remDiv.multiply(radixToPower)).toInt() >>> 0 + let digits = intval.toString(radix) + rem = remDiv + if (rem.isZero()) { + return digits + result + } else { + while (digits.length < 6) { + digits = '0' + digits + } + result = '' + digits + result + } + } + } + + /** + * Gets the high 32 bits as a signed integer. + * @returns {number} Signed high bits + * @expose + */ + getHighBits (): number { + return this.high + } + + /** + * Gets the low 32 bits as a signed integer. + * @returns {number} Signed low bits + * @expose + */ + getLowBits (): number { + return this.low + } + + /** + * Gets the number of bits needed to represent the absolute value of this Integer. + * @returns {number} + * @expose + */ + getNumBitsAbs (): number { + if (this.isNegative()) { + return this.equals(Integer.MIN_VALUE) ? 64 : this.negate().getNumBitsAbs() + } + const val = this.high !== 0 ? this.high : this.low + let bit = 0 + for (bit = 31; bit > 0; bit--) { + if ((val & (1 << bit)) !== 0) { + break + } + } + return this.high !== 0 ? bit + 33 : bit + 1 + } + + /** + * Tests if this Integer's value equals zero. + * @returns {boolean} + * @expose + */ + isZero (): boolean { + return this.high === 0 && this.low === 0 + } + + /** + * Tests if this Integer's value is negative. + * @returns {boolean} + * @expose + */ + isNegative (): boolean { + return this.high < 0 + } + + /** + * Tests if this Integer's value is positive. + * @returns {boolean} + * @expose + */ + isPositive (): boolean { + return this.high >= 0 + } + + /** + * Tests if this Integer's value is odd. + * @returns {boolean} + * @expose + */ + isOdd (): boolean { + return (this.low & 1) === 1 + } + + /** + * Tests if this Integer's value is even. + * @returns {boolean} + * @expose + */ + isEven (): boolean { + return (this.low & 1) === 0 + } + + /** + * Tests if this Integer's value equals the specified's. + * @param {!Integer|number|string} other Other value + * @returns {boolean} + * @expose + */ + equals (other: Integerable): boolean { + const theOther = Integer.fromValue(other) + return this.high === theOther.high && this.low === theOther.low + } + + /** + * Tests if this Integer's value differs from the specified's. + * @param {!Integer|number|string} other Other value + * @returns {boolean} + * @expose + */ + notEquals (other: Integerable): boolean { + return !this.equals(/* validates */ other) + } + + /** + * Tests if this Integer's value is less than the specified's. + * @param {!Integer|number|string} other Other value + * @returns {boolean} + * @expose + */ + lessThan (other: Integerable): boolean { + return this.compare(/* validates */ other) < 0 + } + + /** + * Tests if this Integer's value is less than or equal the specified's. + * @param {!Integer|number|string} other Other value + * @returns {boolean} + * @expose + */ + lessThanOrEqual (other: Integerable): boolean { + return this.compare(/* validates */ other) <= 0 + } + + /** + * Tests if this Integer's value is greater than the specified's. + * @param {!Integer|number|string} other Other value + * @returns {boolean} + * @expose + */ + greaterThan (other: Integerable): boolean { + return this.compare(/* validates */ other) > 0 + } + + /** + * Tests if this Integer's value is greater than or equal the specified's. + * @param {!Integer|number|string} other Other value + * @returns {boolean} + * @expose + */ + greaterThanOrEqual (other: Integerable): boolean { + return this.compare(/* validates */ other) >= 0 + } + + /** + * Compares this Integer's value with the specified's. + * @param {!Integer|number|string} other Other value + * @returns {number} 0 if they are the same, 1 if the this is greater and -1 + * if the given one is greater + * @expose + */ + compare (other: Integerable): number { + const theOther = Integer.fromValue(other) + + if (this.equals(theOther)) { + return 0 + } + const thisNeg = this.isNegative() + const otherNeg = theOther.isNegative() + if (thisNeg && !otherNeg) { + return -1 + } + if (!thisNeg && otherNeg) { + return 1 + } + // At this point the sign bits are the same + return this.subtract(theOther).isNegative() ? -1 : 1 + } + + /** + * Negates this Integer's value. + * @returns {!Integer} Negated Integer + * @expose + */ + negate (): Integer { + if (this.equals(Integer.MIN_VALUE)) { + return Integer.MIN_VALUE + } + return this.not().add(Integer.ONE) + } + + /** + * Returns the sum of this and the specified Integer. + * @param {!Integer|number|string} addend Addend + * @returns {!Integer} Sum + * @expose + */ + add (addend: Integerable): Integer { + const theAddend = Integer.fromValue(addend) + + // Divide each number into 4 chunks of 16 bits, and then sum the chunks. + + const a48 = this.high >>> 16 + const a32 = this.high & 0xffff + const a16 = this.low >>> 16 + const a00 = this.low & 0xffff + + const b48 = theAddend.high >>> 16 + const b32 = theAddend.high & 0xffff + const b16 = theAddend.low >>> 16 + const b00 = theAddend.low & 0xffff + + let c48 = 0 + let c32 = 0 + let c16 = 0 + let c00 = 0 + c00 += a00 + b00 + c16 += c00 >>> 16 + c00 &= 0xffff + c16 += a16 + b16 + c32 += c16 >>> 16 + c16 &= 0xffff + c32 += a32 + b32 + c48 += c32 >>> 16 + c32 &= 0xffff + c48 += a48 + b48 + c48 &= 0xffff + return Integer.fromBits((c16 << 16) | c00, (c48 << 16) | c32) + } + + /** + * Returns the difference of this and the specified Integer. + * @param {!Integer|number|string} subtrahend Subtrahend + * @returns {!Integer} Difference + * @expose + */ + subtract (subtrahend: Integerable): Integer { + const theSubtrahend = Integer.fromValue(subtrahend) + return this.add(theSubtrahend.negate()) + } + + /** + * Returns the product of this and the specified Integer. + * @param {!Integer|number|string} multiplier Multiplier + * @returns {!Integer} Product + * @expose + */ + multiply (multiplier: Integerable): Integer { + if (this.isZero()) { + return Integer.ZERO + } + + const theMultiplier = Integer.fromValue(multiplier) + + if (theMultiplier.isZero()) { + return Integer.ZERO + } + if (this.equals(Integer.MIN_VALUE)) { + return theMultiplier.isOdd() ? Integer.MIN_VALUE : Integer.ZERO + } + if (theMultiplier.equals(Integer.MIN_VALUE)) { + return this.isOdd() ? Integer.MIN_VALUE : Integer.ZERO + } + + if (this.isNegative()) { + if (theMultiplier.isNegative()) { + return this.negate().multiply(theMultiplier.negate()) + } else { + return this.negate() + .multiply(theMultiplier) + .negate() + } + } else if (theMultiplier.isNegative()) { + return this.multiply(theMultiplier.negate()).negate() + } + + // If both longs are small, use float multiplication + if (this.lessThan(TWO_PWR_24) && theMultiplier.lessThan(TWO_PWR_24)) { + return Integer.fromNumber(this.toNumber() * theMultiplier.toNumber()) + } + + // Divide each long into 4 chunks of 16 bits, and then add up 4x4 products. + // We can skip products that would overflow. + + const a48 = this.high >>> 16 + const a32 = this.high & 0xffff + const a16 = this.low >>> 16 + const a00 = this.low & 0xffff + + const b48 = theMultiplier.high >>> 16 + const b32 = theMultiplier.high & 0xffff + const b16 = theMultiplier.low >>> 16 + const b00 = theMultiplier.low & 0xffff + + let c48 = 0 + let c32 = 0 + let c16 = 0 + let c00 = 0 + c00 += a00 * b00 + c16 += c00 >>> 16 + c00 &= 0xffff + c16 += a16 * b00 + c32 += c16 >>> 16 + c16 &= 0xffff + c16 += a00 * b16 + c32 += c16 >>> 16 + c16 &= 0xffff + c32 += a32 * b00 + c48 += c32 >>> 16 + c32 &= 0xffff + c32 += a16 * b16 + c48 += c32 >>> 16 + c32 &= 0xffff + c32 += a00 * b32 + c48 += c32 >>> 16 + c32 &= 0xffff + c48 += a48 * b00 + a32 * b16 + a16 * b32 + a00 * b48 + c48 &= 0xffff + return Integer.fromBits((c16 << 16) | c00, (c48 << 16) | c32) + } + + /** + * Returns this Integer divided by the specified. + * @param {!Integer|number|string} divisor Divisor + * @returns {!Integer} Quotient + * @expose + */ + div (divisor: Integerable): Integer { + const theDivisor = Integer.fromValue(divisor) + + if (theDivisor.isZero()) { + throw newError('division by zero') + } + if (this.isZero()) { + return Integer.ZERO + } + let approx, rem, res + if (this.equals(Integer.MIN_VALUE)) { + if ( + theDivisor.equals(Integer.ONE) || + theDivisor.equals(Integer.NEG_ONE) + ) { + return Integer.MIN_VALUE + } + if (theDivisor.equals(Integer.MIN_VALUE)) { + return Integer.ONE + } else { + // At this point, we have |other| >= 2, so |this/other| < |MIN_VALUE|. + const halfThis = this.shiftRight(1) + approx = halfThis.div(theDivisor).shiftLeft(1) + if (approx.equals(Integer.ZERO)) { + return theDivisor.isNegative() ? Integer.ONE : Integer.NEG_ONE + } else { + rem = this.subtract(theDivisor.multiply(approx)) + res = approx.add(rem.div(theDivisor)) + return res + } + } + } else if (theDivisor.equals(Integer.MIN_VALUE)) { + return Integer.ZERO + } + if (this.isNegative()) { + if (theDivisor.isNegative()) { + return this.negate().div(theDivisor.negate()) + } + return this.negate() + .div(theDivisor) + .negate() + } else if (theDivisor.isNegative()) { + return this.div(theDivisor.negate()).negate() + } + + // Repeat the following until the remainder is less than other: find a + // floating-point that approximates remainder / other *from below*, add this + // into the result, and subtract it from the remainder. It is critical that + // the approximate value is less than or equal to the real value so that the + // remainder never becomes negative. + res = Integer.ZERO + rem = this + while (rem.greaterThanOrEqual(theDivisor)) { + // Approximate the result of division. This may be a little greater or + // smaller than the actual value. + approx = Math.max(1, Math.floor(rem.toNumber() / theDivisor.toNumber())) + + // We will tweak the approximate result by changing it in the 48-th digit or + // the smallest non-fractional digit, whichever is larger. + const log2 = Math.ceil(Math.log(approx) / Math.LN2) + const delta = log2 <= 48 ? 1 : Math.pow(2, log2 - 48) + + // Decrease the approximation until it is smaller than the remainder. Note + // that if it is too large, the product overflows and is negative. + let approxRes = Integer.fromNumber(approx) + let approxRem = approxRes.multiply(theDivisor) + while (approxRem.isNegative() || approxRem.greaterThan(rem)) { + approx -= delta + approxRes = Integer.fromNumber(approx) + approxRem = approxRes.multiply(theDivisor) + } + + // We know the answer can't be zero... and actually, zero would cause + // infinite recursion since we would make no progress. + if (approxRes.isZero()) { + approxRes = Integer.ONE + } + + res = res.add(approxRes) + rem = rem.subtract(approxRem) + } + return res + } + + /** + * Returns this Integer modulo the specified. + * @param {!Integer|number|string} divisor Divisor + * @returns {!Integer} Remainder + * @expose + */ + modulo (divisor: Integerable): Integer { + const theDivisor = Integer.fromValue(divisor) + return this.subtract(this.div(theDivisor).multiply(theDivisor)) + } + + /** + * Returns the bitwise NOT of this Integer. + * @returns {!Integer} + * @expose + */ + not (): Integer { + return Integer.fromBits(~this.low, ~this.high) + } + + /** + * Returns the bitwise AND of this Integer and the specified. + * @param {!Integer|number|string} other Other Integer + * @returns {!Integer} + * @expose + */ + and (other: Integerable): Integer { + const theOther = Integer.fromValue(other) + return Integer.fromBits(this.low & theOther.low, this.high & theOther.high) + } + + /** + * Returns the bitwise OR of this Integer and the specified. + * @param {!Integer|number|string} other Other Integer + * @returns {!Integer} + * @expose + */ + or (other: Integerable): Integer { + const theOther = Integer.fromValue(other) + return Integer.fromBits(this.low | theOther.low, this.high | theOther.high) + } + + /** + * Returns the bitwise XOR of this Integer and the given one. + * @param {!Integer|number|string} other Other Integer + * @returns {!Integer} + * @expose + */ + xor (other: Integerable): Integer { + const theOther = Integer.fromValue(other) + return Integer.fromBits(this.low ^ theOther.low, this.high ^ theOther.high) + } + + /** + * Returns this Integer with bits shifted to the left by the given amount. + * @param {number|!Integer} numBits Number of bits + * @returns {!Integer} Shifted Integer + * @expose + */ + shiftLeft (numBits: number | Integer): Integer { + let bitsCount = Integer.toNumber(numBits) + if ((bitsCount &= 63) === 0) { + return Integer.ZERO + } else if (bitsCount < 32) { + return Integer.fromBits( + this.low << bitsCount, + (this.high << bitsCount) | (this.low >>> (32 - bitsCount)) + ) + } else { + return Integer.fromBits(0, this.low << (bitsCount - 32)) + } + } + + /** + * Returns this Integer with bits arithmetically shifted to the right by the given amount. + * @param {number|!Integer} numBits Number of bits + * @returns {!Integer} Shifted Integer + * @expose + */ + shiftRight (numBits: number | Integer): Integer { + let bitsCount: number = Integer.toNumber(numBits) + + if ((bitsCount &= 63) === 0) { + return Integer.ZERO + } else if (numBits < 32) { + return Integer.fromBits( + (this.low >>> bitsCount) | (this.high << (32 - bitsCount)), + this.high >> bitsCount + ) + } else { + return Integer.fromBits( + this.high >> (bitsCount - 32), + this.high >= 0 ? 0 : -1 + ) + } + } + + /** + * Signed zero. + * @type {!Integer} + * @expose + */ + static ZERO: Integer = Integer.fromInt(0) + + /** + * Signed one. + * @type {!Integer} + * @expose + */ + static ONE: Integer = Integer.fromInt(1) + + /** + * Signed negative one. + * @type {!Integer} + * @expose + */ + static NEG_ONE: Integer = Integer.fromInt(-1) + + /** + * Maximum signed value. + * @type {!Integer} + * @expose + */ + static MAX_VALUE: Integer = Integer.fromBits(0xffffffff | 0, 0x7fffffff | 0) + + /** + * Minimum signed value. + * @type {!Integer} + * @expose + */ + static MIN_VALUE: Integer = Integer.fromBits(0, 0x80000000 | 0) + + /** + * Minimum safe value. + * @type {!Integer} + * @expose + */ + static MIN_SAFE_VALUE: Integer = Integer.fromBits( + 0x1 | 0, + 0xffffffffffe00000 | 0 + ) + + /** + * Maximum safe value. + * @type {!Integer} + * @expose + */ + static MAX_SAFE_VALUE: Integer = Integer.fromBits( + 0xffffffff | 0, + 0x1fffff | 0 + ) + + /** + * An indicator used to reliably determine if an object is a Integer or not. + * @type {boolean} + * @const + * @expose + * @private + */ + static __isInteger__: boolean = true + + /** + * Tests if the specified object is a Integer. + * @access private + * @param {*} obj Object + * @returns {boolean} + * @expose + */ + static isInteger (obj: any): obj is Integer { + return obj?.__isInteger__ === true + } + + /** + * Returns a Integer representing the given 32 bit integer value. + * @access private + * @param {number} value The 32 bit integer in question + * @returns {!Integer} The corresponding Integer value + * @expose + */ + static fromInt (value: number): Integer { + let cachedObj + value = value | 0 + if (value >= -128 && value < 128) { + cachedObj = INT_CACHE.get(value) + if (cachedObj != null) { + return cachedObj + } + } + const obj = new Integer(value, value < 0 ? -1 : 0) + if (value >= -128 && value < 128) { + INT_CACHE.set(value, obj) + } + return obj + } + + /** + * Returns a Integer representing the 64 bit integer that comes by concatenating the given low and high bits. Each is + * assumed to use 32 bits. + * @access private + * @param {number} lowBits The low 32 bits + * @param {number} highBits The high 32 bits + * @returns {!Integer} The corresponding Integer value + * @expose + */ + static fromBits (lowBits: number, highBits: number): Integer { + return new Integer(lowBits, highBits) + } + + /** + * Returns a Integer representing the given value, provided that it is a finite number. Otherwise, zero is returned. + * @access private + * @param {number} value The number in question + * @returns {!Integer} The corresponding Integer value + * @expose + */ + static fromNumber (value: number): Integer { + if (isNaN(value) || !isFinite(value)) { + return Integer.ZERO + } + if (value <= -TWO_PWR_63_DBL) { + return Integer.MIN_VALUE + } + if (value + 1 >= TWO_PWR_63_DBL) { + return Integer.MAX_VALUE + } + if (value < 0) { + return Integer.fromNumber(-value).negate() + } + return new Integer(value % TWO_PWR_32_DBL | 0, (value / TWO_PWR_32_DBL) | 0) + } + + /** + * Returns a Integer representation of the given string, written using the specified radix. + * @access private + * @param {string} str The textual representation of the Integer + * @param {number=} radix The radix in which the text is written (2-36), defaults to 10 + * @param {Object} [opts={}] Configuration options + * @param {boolean} [opts.strictStringValidation=false] Enable strict validation generated Integer. + * @returns {!Integer} The corresponding Integer value + * @expose + */ + static fromString (str: string, radix?: number, { strictStringValidation }: { strictStringValidation?: boolean} = {}): Integer { + if (str.length === 0) { + throw newError('number format error: empty string') + } + if ( + str === 'NaN' || + str === 'Infinity' || + str === '+Infinity' || + str === '-Infinity' + ) { + return Integer.ZERO + } + radix = radix ?? 10 + if (radix < 2 || radix > 36) { + throw newError('radix out of range: ' + radix.toString()) + } + + let p: number + if ((p = str.indexOf('-')) > 0) { + throw newError('number format error: interior "-" character: ' + str) + } else if (p === 0) { + return Integer.fromString(str.substring(1), radix).negate() + } + + // Do several (8) digits each time through the loop, so as to + // minimize the calls to the very expensive emulated div. + const radixToPower = Integer.fromNumber(Math.pow(radix, 8)) + + let result = Integer.ZERO + for (let i = 0; i < str.length; i += 8) { + const size = Math.min(8, str.length - i) + const valueString = str.substring(i, i + size) + const value = parseInt(valueString, radix) + + if (strictStringValidation === true && !_isValidNumberFromString(valueString, value, radix)) { + throw newError(`number format error: "${valueString}" is NaN in radix ${radix}: ${str}`) + } + + if (size < 8) { + const power = Integer.fromNumber(Math.pow(radix, size)) + result = result.multiply(power).add(Integer.fromNumber(value)) + } else { + result = result.multiply(radixToPower) + result = result.add(Integer.fromNumber(value)) + } + } + return result + } + + /** + * Converts the specified value to a Integer. + * @access private + * @param {!Integer|number|string|bigint|!{low: number, high: number}} val Value + * @param {Object} [opts={}] Configuration options + * @param {boolean} [opts.strictStringValidation=false] Enable strict validation generated Integer. + * @returns {!Integer} + * @expose + */ + static fromValue (val: Integerable, opts: { strictStringValidation?: boolean} = {}): Integer { + if (val /* is compatible */ instanceof Integer) { + return val + } + if (typeof val === 'number') { + return Integer.fromNumber(val) + } + if (typeof val === 'string') { + return Integer.fromString(val, undefined, opts) + } + if (typeof val === 'bigint') { + return Integer.fromString(val.toString()) + } + // Throws for non-objects, converts non-instanceof Integer: + return new Integer(val.low, val.high) + } + + /** + * Converts the specified value to a number. + * @access private + * @param {!Integer|number|string|!{low: number, high: number}} val Value + * @returns {number} + * @expose + */ + static toNumber (val: Integerable): number { + switch (typeof val) { + case 'number': + return val + case 'bigint': + return Number(val) + default: + return Integer.fromValue(val).toNumber() + } + } + + /** + * Converts the specified value to a string. + * @access private + * @param {!Integer|number|string|!{low: number, high: number}} val Value + * @param {number} radix optional radix for string conversion, defaults to 10 + * @returns {string} + * @expose + */ + static toString (val: Integerable, radix?: number): string { + return Integer.fromValue(val).toString(radix) + } + + /** + * Checks if the given value is in the safe range in order to be converted to a native number + * @access private + * @param {!Integer|number|string|!{low: number, high: number}} val Value + * @param {number} radix optional radix for string conversion, defaults to 10 + * @returns {boolean} + * @expose + */ + static inSafeRange (val: Integerable): boolean { + return Integer.fromValue(val).inSafeRange() + } +} + +/** + * @private + * @param num + * @param radix + * @param minSize + * @returns {string} + */ +function _convertNumberToString (num: number, radix: number, minSize: number): string { + const theNumberString = num.toString(radix) + const paddingLength = Math.max(minSize - theNumberString.length, 0) + const padding = '0'.repeat(paddingLength) + return `${padding}${theNumberString}` +} + +/** + * + * @private + * @param theString + * @param theNumber + * @param radix + * @return {boolean} True if valid + */ +function _isValidNumberFromString (theString: string, theNumber: number, radix: number): boolean { + return !Number.isNaN(theString) && + !Number.isNaN(theNumber) && + _convertNumberToString(theNumber, radix, theString.length) === theString.toLowerCase() +} + +type Integerable = + | number + | string + | Integer + | { low: number, high: number } + | bigint + +Object.defineProperty(Integer.prototype, '__isInteger__', { + value: true, + enumerable: false, + configurable: false +}) + +/** + * @type {number} + * @const + * @inner + * @private + */ +const TWO_PWR_16_DBL = 1 << 16 + +/** + * @type {number} + * @const + * @inner + * @private + */ +const TWO_PWR_24_DBL = 1 << 24 + +/** + * @type {number} + * @const + * @inner + * @private + */ +const TWO_PWR_32_DBL = TWO_PWR_16_DBL * TWO_PWR_16_DBL + +/** + * @type {number} + * @const + * @inner + * @private + */ +const TWO_PWR_64_DBL = TWO_PWR_32_DBL * TWO_PWR_32_DBL + +/** + * @type {number} + * @const + * @inner + * @private + */ +const TWO_PWR_63_DBL = TWO_PWR_64_DBL / 2 + +/** + * @type {!Integer} + * @const + * @inner + * @private + */ +const TWO_PWR_24 = Integer.fromInt(TWO_PWR_24_DBL) + +/** + * Cast value to Integer type. + * @access public + * @param {Mixed} value - The value to use. + * @param {Object} [opts={}] Configuration options + * @param {boolean} [opts.strictStringValidation=false] Enable strict validation generated Integer. + * @return {Integer} - An object of type Integer. + */ +const int = Integer.fromValue + +/** + * Check if a variable is of Integer type. + * @access public + * @param {Mixed} value - The variable to check. + * @return {Boolean} - Is it of the Integer type? + */ +const isInt = Integer.isInteger + +/** + * Check if a variable can be safely converted to a number + * @access public + * @param {Mixed} value - The variable to check + * @return {Boolean} - true if it is safe to call toNumber on variable otherwise false + */ +const inSafeRange = Integer.inSafeRange + +/** + * Converts a variable to a number + * @access public + * @param {Mixed} value - The variable to convert + * @return {number} - the variable as a number + */ +const toNumber = Integer.toNumber + +/** + * Converts the integer to a string representation + * @access public + * @param {Mixed} value - The variable to convert + * @param {number} radix - radix to use in string conversion, defaults to 10 + * @return {string} - returns a string representation of the integer + */ +const toString = Integer.toString + +export { int, isInt, inSafeRange, toNumber, toString } + +export default Integer diff --git a/packages/neo4j-driver-deno/lib/core/internal/bookmarks.ts b/packages/neo4j-driver-deno/lib/core/internal/bookmarks.ts new file mode 100644 index 000000000..3afa070d8 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/internal/bookmarks.ts @@ -0,0 +1,135 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 * as util from './util.ts' + +const BOOKMARKS_KEY = 'bookmarks' + +export class Bookmarks { + private readonly _values: string[] + + /** + * @constructor + * @param {string|string[]} values single bookmark as string or multiple bookmarks as a string array. + */ + constructor (values?: string | string[] | null) { + this._values = asStringArray(values) + } + + static empty (): Bookmarks { + return EMPTY_BOOKMARK + } + + /** + * Check if the given Bookmarks holder is meaningful and can be send to the database. + * @return {boolean} returns `true` bookmarks has a value, `false` otherwise. + */ + isEmpty (): boolean { + return this._values.length === 0 + } + + /** + * Get all bookmarks values as an array. + * @return {string[]} all values. + */ + values (): string[] { + return this._values + } + + [Symbol.iterator] (): IterableIterator { + return this._values[Symbol.iterator]() + } + + /** + * Get these bookmarks as an object for begin transaction call. + * @return {Object} the value of this bookmarks holder as object. + */ + asBeginTransactionParameters (): { [BOOKMARKS_KEY]?: string[] } { + if (this.isEmpty()) { + return {} + } + + // Driver sends {bookmarks: "max", bookmarks: ["one", "two", "max"]} instead of simple + // {bookmarks: ["one", "two", "max"]} for backwards compatibility reasons. Old servers can only accept single + // bookmarks that is why driver has to parse and compare given list of bookmarks. This functionality will + // eventually be removed. + return { + [BOOKMARKS_KEY]: this._values + } + } +} + +const EMPTY_BOOKMARK = new Bookmarks(null) + +/** + * Converts given value to an array. + * @param {string|string[]|Array} [value=undefined] argument to convert. + * @return {string[]} value converted to an array. + */ +function asStringArray ( + value?: string | string[] | null +): string[] { + if (value == null || value === '') { + return [] + } + + if (util.isString(value)) { + return [value] as string[] + } + + if (Array.isArray(value)) { + const result = new Set() + const flattenedValue = flattenArray(value) + for (let i = 0; i < flattenedValue.length; i++) { + const element = flattenedValue[i] + // if it is undefined or null, ignore it + if (element !== undefined && element !== null) { + if (!util.isString(element)) { + throw new TypeError( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Bookmark value should be a string, given: '${element}'` + ) + } + result.add(element) + } + } + return [...result] + } + + throw new TypeError( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Bookmarks should either be a string or a string array, given: '${value}'` + ) +} + +/** + * Recursively flattens an array so that the result becomes a single array + * of values, which does not include any sub-arrays + * + * @param {Array} value + */ +function flattenArray (values: any[]): string[] { + return values.reduce( + (dest, value) => + Array.isArray(value) + ? dest.concat(flattenArray(value)) + : dest.concat(value), + [] + ) +} diff --git a/packages/neo4j-driver-deno/lib/core/internal/connection-holder.ts b/packages/neo4j-driver-deno/lib/core/internal/connection-holder.ts new file mode 100644 index 000000000..d67044100 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/internal/connection-holder.ts @@ -0,0 +1,331 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ +/* eslint-disable @typescript-eslint/promise-function-async */ + +import { newError } from '../error.ts' +import { assertString } from './util.ts' +import Connection from '../connection.ts' +import { ACCESS_MODE_WRITE } from './constants.ts' +import { Bookmarks } from './bookmarks.ts' +import ConnectionProvider from '../connection-provider.ts' + +/** + * @private + */ +interface ConnectionHolderInterface { + /** + * Returns the assigned access mode. + * @returns {string} access mode + */ + mode: () => string | undefined + + /** + * Returns the target database name + * @returns {string} the database name + */ + database: () => string | undefined + + /** + * Returns the bookmarks + */ + bookmarks: () => Bookmarks + + /** + * Make this holder initialize new connection if none exists already. + * @return {boolean} + */ + initializeConnection: () => boolean + + /** + * Get the current connection promise. + * @return {Promise} promise resolved with the current connection. + */ + getConnection: () => Promise + + /** + * Notify this holder that single party does not require current connection any more. + * @return {Promise} promise resolved with the current connection, never a rejected promise. + */ + releaseConnection: () => Promise + + /** + * Closes this holder and releases current connection (if any) despite any existing users. + * @return {Promise} promise resolved when current connection is released to the pool. + */ + close: () => Promise +} + +/** + * Utility to lazily initialize connections and return them back to the pool when unused. + * @private + */ +class ConnectionHolder implements ConnectionHolderInterface { + private readonly _mode: string + private _database?: string + private readonly _bookmarks: Bookmarks + private readonly _connectionProvider?: ConnectionProvider + private _referenceCount: number + private _connectionPromise: Promise + private readonly _impersonatedUser?: string + private readonly _getConnectionAcquistionBookmarks: () => Promise + private readonly _onDatabaseNameResolved?: (databaseName?: string) => void + + /** + * @constructor + * @param {object} params + * @property {string} params.mode - the access mode for new connection holder. + * @property {string} params.database - the target database name. + * @property {Bookmarks} params.bookmarks - initial bookmarks + * @property {ConnectionProvider} params.connectionProvider - the connection provider to acquire connections from. + * @property {string?} params.impersonatedUser - the user which will be impersonated + * @property {function(databaseName:string)} params.onDatabaseNameResolved - callback called when the database name is resolved + * @property {function():Promise} params.getConnectionAcquistionBookmarks - called for getting Bookmarks for acquiring connections + */ + constructor ({ + mode = ACCESS_MODE_WRITE, + database = '', + bookmarks, + connectionProvider, + impersonatedUser, + onDatabaseNameResolved, + getConnectionAcquistionBookmarks + }: { + mode?: string + database?: string + bookmarks?: Bookmarks + connectionProvider?: ConnectionProvider + impersonatedUser?: string + onDatabaseNameResolved?: (databaseName?: string) => void + getConnectionAcquistionBookmarks?: () => Promise + } = {}) { + this._mode = mode + this._database = database != null ? assertString(database, 'database') : '' + this._bookmarks = bookmarks ?? Bookmarks.empty() + this._connectionProvider = connectionProvider + this._impersonatedUser = impersonatedUser + this._referenceCount = 0 + this._connectionPromise = Promise.resolve(null) + this._onDatabaseNameResolved = onDatabaseNameResolved + this._getConnectionAcquistionBookmarks = getConnectionAcquistionBookmarks ?? (() => Promise.resolve(Bookmarks.empty())) + } + + mode (): string | undefined { + return this._mode + } + + database (): string | undefined { + return this._database + } + + setDatabase (database?: string): void { + this._database = database + } + + bookmarks (): Bookmarks { + return this._bookmarks + } + + connectionProvider (): ConnectionProvider | undefined { + return this._connectionProvider + } + + referenceCount (): number { + return this._referenceCount + } + + initializeConnection (): boolean { + if (this._referenceCount === 0 && (this._connectionProvider != null)) { + this._connectionPromise = this._createConnectionPromise(this._connectionProvider) + } else { + this._referenceCount++ + return false + } + this._referenceCount++ + return true + } + + private async _createConnectionPromise (connectionProvider: ConnectionProvider): Promise { + return await connectionProvider.acquireConnection({ + accessMode: this._mode, + database: this._database, + bookmarks: await this._getBookmarks(), + impersonatedUser: this._impersonatedUser, + onDatabaseNameResolved: this._onDatabaseNameResolved + }) + } + + private async _getBookmarks (): Promise { + return await this._getConnectionAcquistionBookmarks() + } + + getConnection (): Promise { + return this._connectionPromise + } + + releaseConnection (): Promise { + if (this._referenceCount === 0) { + return this._connectionPromise + } + + this._referenceCount-- + + if (this._referenceCount === 0) { + return this._releaseConnection() + } + return this._connectionPromise + } + + close (hasTx?: boolean): Promise { + if (this._referenceCount === 0) { + return this._connectionPromise + } + this._referenceCount = 0 + return this._releaseConnection(hasTx) + } + + /** + * Return the current pooled connection instance to the connection pool. + * We don't pool Session instances, to avoid users using the Session after they've called close. + * The `Session` object is just a thin wrapper around Connection anyway, so it makes little difference. + * @return {Promise} - promise resolved then connection is returned to the pool. + * @private + */ + private _releaseConnection (hasTx?: boolean): Promise { + this._connectionPromise = this._connectionPromise + .then((connection?: Connection|null) => { + if (connection != null) { + if (connection.isOpen() && (connection.hasOngoingObservableRequests() || hasTx === true)) { + return connection + .resetAndFlush() + .catch(ignoreError) + .then(() => connection._release().then(() => null)) + } + return connection._release().then(() => null) + } else { + return Promise.resolve(null) + } + }) + .catch(ignoreError) + + return this._connectionPromise + } +} + +/** + * Provides a interaction with a ConnectionHolder without change it state by + * releasing or initilizing + */ +export default class ReadOnlyConnectionHolder extends ConnectionHolder { + private readonly _connectionHolder: ConnectionHolder + + /** + * Contructor + * @param {ConnectionHolder} connectionHolder the connection holder which will treat the requests + */ + constructor (connectionHolder: ConnectionHolder) { + super({ + mode: connectionHolder.mode(), + database: connectionHolder.database(), + bookmarks: connectionHolder.bookmarks(), + // @ts-expect-error + getConnectionAcquistionBookmarks: connectionHolder._getConnectionAcquistionBookmarks, + connectionProvider: connectionHolder.connectionProvider() + }) + this._connectionHolder = connectionHolder + } + + /** + * Return the true if the connection is suppose to be initilized with the command. + * + * @return {boolean} + */ + initializeConnection (): boolean { + if (this._connectionHolder.referenceCount() === 0) { + return false + } + return true + } + + /** + * Get the current connection promise. + * @return {Promise} promise resolved with the current connection. + */ + getConnection (): Promise { + return this._connectionHolder.getConnection() + } + + /** + * Get the current connection promise, doesn't performs the release + * @return {Promise} promise with the resolved current connection + */ + releaseConnection (): Promise { + return this._connectionHolder.getConnection().catch(() => Promise.resolve(null)) + } + + /** + * Get the current connection promise, doesn't performs the connection close + * @return {Promise} promise with the resolved current connection + */ + close (): Promise { + return this._connectionHolder.getConnection().catch(() => Promise.resolve(null)) + } +} + +class EmptyConnectionHolder extends ConnectionHolder { + mode (): undefined { + return undefined + } + + database (): undefined { + return undefined + } + + initializeConnection (): boolean { + // nothing to initialize + return true + } + + async getConnection (): Promise { + return await Promise.reject( + newError('This connection holder does not serve connections') + ) + } + + async releaseConnection (): Promise { + return await Promise.resolve(null) + } + + async close (): Promise { + return await Promise.resolve(null) + } +} + +/** + * Connection holder that does not manage any connections. + * @type {ConnectionHolder} + * @private + */ +const EMPTY_CONNECTION_HOLDER: EmptyConnectionHolder = new EmptyConnectionHolder() + +// eslint-disable-next-line node/handle-callback-err +function ignoreError (error: Error): null { + return null +} + +export { ConnectionHolder, ReadOnlyConnectionHolder, EMPTY_CONNECTION_HOLDER } diff --git a/packages/neo4j-driver-deno/lib/core/internal/constants.ts b/packages/neo4j-driver-deno/lib/core/internal/constants.ts new file mode 100644 index 000000000..39e790f2f --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/internal/constants.ts @@ -0,0 +1,54 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ + +const FETCH_ALL = -1 +const DEFAULT_POOL_ACQUISITION_TIMEOUT = 60 * 1000 // 60 seconds +const DEFAULT_POOL_MAX_SIZE = 100 +const DEFAULT_CONNECTION_TIMEOUT_MILLIS = 30000 // 30 seconds by default + +const ACCESS_MODE_READ: 'READ' = 'READ' +const ACCESS_MODE_WRITE: 'WRITE' = 'WRITE' + +const BOLT_PROTOCOL_V1: number = 1 +const BOLT_PROTOCOL_V2: number = 2 +const BOLT_PROTOCOL_V3: number = 3 +const BOLT_PROTOCOL_V4_0: number = 4.0 +const BOLT_PROTOCOL_V4_1: number = 4.1 +const BOLT_PROTOCOL_V4_2: number = 4.2 +const BOLT_PROTOCOL_V4_3: number = 4.3 +const BOLT_PROTOCOL_V4_4: number = 4.4 +const BOLT_PROTOCOL_V5_0: number = 5.0 + +export { + FETCH_ALL, + ACCESS_MODE_READ, + ACCESS_MODE_WRITE, + DEFAULT_CONNECTION_TIMEOUT_MILLIS, + DEFAULT_POOL_ACQUISITION_TIMEOUT, + DEFAULT_POOL_MAX_SIZE, + BOLT_PROTOCOL_V1, + BOLT_PROTOCOL_V2, + BOLT_PROTOCOL_V3, + BOLT_PROTOCOL_V4_0, + BOLT_PROTOCOL_V4_1, + BOLT_PROTOCOL_V4_2, + BOLT_PROTOCOL_V4_3, + BOLT_PROTOCOL_V4_4, + BOLT_PROTOCOL_V5_0 +} diff --git a/packages/neo4j-driver-deno/lib/core/internal/index.ts b/packages/neo4j-driver-deno/lib/core/internal/index.ts new file mode 100644 index 000000000..a618369ad --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/internal/index.ts @@ -0,0 +1,48 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 * as util from './util.ts' +import * as temporalUtil from './temporal-util.ts' +import * as observer from './observers.ts' +import * as bookmarks from './bookmarks.ts' +import * as constants from './constants.ts' +import * as connectionHolder from './connection-holder.ts' +import * as txConfig from './tx-config.ts' +import * as transactionExecutor from './transaction-executor.ts' +import * as logger from './logger.ts' +import * as urlUtil from './url-util.ts' +import * as serverAddress from './server-address.ts' +import * as resolver from './resolver/index.ts' +import * as objectUtil from './object-util.ts' + +export { + util, + temporalUtil, + observer, + bookmarks, + constants, + connectionHolder, + txConfig, + transactionExecutor, + logger, + urlUtil, + serverAddress, + resolver, + objectUtil +} diff --git a/packages/neo4j-driver-deno/lib/core/internal/logger.ts b/packages/neo4j-driver-deno/lib/core/internal/logger.ts new file mode 100644 index 000000000..fee29714b --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/internal/logger.ts @@ -0,0 +1,224 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { newError } from '../error.ts' +import { LogLevel, LoggerFunction, LoggingConfig } from '../types.ts' + +const ERROR: 'error' = 'error' +const WARN: 'warn' = 'warn' +const INFO: 'info' = 'info' +const DEBUG: 'debug' = 'debug' + +const DEFAULT_LEVEL = INFO + +const levels = { + [ERROR]: 0, + [WARN]: 1, + [INFO]: 2, + [DEBUG]: 3 +} + +/** + * Logger used by the driver to notify about various internal events. Single logger should be used per driver. + */ +export class Logger { + private readonly _level: LogLevel + private readonly _loggerFunction: LoggerFunction + /** + * @constructor + * @param {string} level the enabled logging level. + * @param {function(level: string, message: string)} loggerFunction the function to write the log level and message. + */ + constructor (level: LogLevel, loggerFunction: LoggerFunction) { + this._level = level + this._loggerFunction = loggerFunction + } + + /** + * Create a new logger based on the given driver configuration. + * @param {Object} driverConfig the driver configuration as supplied by the user. + * @return {Logger} a new logger instance or a no-op logger when not configured. + */ + static create (driverConfig: { logging?: LoggingConfig }): Logger { + if (driverConfig?.logging != null) { + const loggingConfig = driverConfig.logging + const level = extractConfiguredLevel(loggingConfig) + const loggerFunction = extractConfiguredLogger(loggingConfig) + return new Logger(level, loggerFunction) + } + return this.noOp() + } + + /** + * Create a no-op logger implementation. + * @return {Logger} the no-op logger implementation. + */ + static noOp (): Logger { + return noOpLogger + } + + /** + * Check if error logging is enabled, i.e. it is not a no-op implementation. + * @return {boolean} `true` when enabled, `false` otherwise. + */ + isErrorEnabled (): boolean { + return isLevelEnabled(this._level, ERROR) + } + + /** + * Log an error message. + * @param {string} message the message to log. + */ + error (message: string): void { + if (this.isErrorEnabled()) { + this._loggerFunction(ERROR, message) + } + } + + /** + * Check if warn logging is enabled, i.e. it is not a no-op implementation. + * @return {boolean} `true` when enabled, `false` otherwise. + */ + isWarnEnabled (): boolean { + return isLevelEnabled(this._level, WARN) + } + + /** + * Log an warning message. + * @param {string} message the message to log. + */ + warn (message: string): void { + if (this.isWarnEnabled()) { + this._loggerFunction(WARN, message) + } + } + + /** + * Check if info logging is enabled, i.e. it is not a no-op implementation. + * @return {boolean} `true` when enabled, `false` otherwise. + */ + isInfoEnabled (): boolean { + return isLevelEnabled(this._level, INFO) + } + + /** + * Log an info message. + * @param {string} message the message to log. + */ + info (message: string): void { + if (this.isInfoEnabled()) { + this._loggerFunction(INFO, message) + } + } + + /** + * Check if debug logging is enabled, i.e. it is not a no-op implementation. + * @return {boolean} `true` when enabled, `false` otherwise. + */ + isDebugEnabled (): boolean { + return isLevelEnabled(this._level, DEBUG) + } + + /** + * Log a debug message. + * @param {string} message the message to log. + */ + debug (message: string): void { + if (this.isDebugEnabled()) { + this._loggerFunction(DEBUG, message) + } + } +} + +class NoOpLogger extends Logger { + constructor () { + super(INFO, (level: LogLevel, message: string) => {}) + } + + isErrorEnabled (): boolean { + return false + } + + error (message: string): void {} + + isWarnEnabled (): boolean { + return false + } + + warn (message: string): void {} + + isInfoEnabled (): boolean { + return false + } + + info (message: string): void {} + + isDebugEnabled (): boolean { + return false + } + + debug (message: string): void {} +} + +const noOpLogger = new NoOpLogger() + +/** + * Check if the given logging level is enabled. + * @param {string} configuredLevel the configured level. + * @param {string} targetLevel the level to check. + * @return {boolean} value of `true` when enabled, `false` otherwise. + */ +function isLevelEnabled (configuredLevel: LogLevel, targetLevel: LogLevel): boolean { + return levels[configuredLevel] >= levels[targetLevel] +} + +/** + * Extract the configured logging level from the driver's logging configuration. + * @param {Object} loggingConfig the logging configuration. + * @return {string} the configured log level or default when none configured. + */ +function extractConfiguredLevel (loggingConfig: LoggingConfig): LogLevel { + if (loggingConfig?.level != null) { + const configuredLevel = loggingConfig.level + const value = levels[configuredLevel] + if (value == null && value !== 0) { + throw newError( + `Illegal logging level: ${configuredLevel}. Supported levels are: ${Object.keys( + levels + ).toString()}` + ) + } + return configuredLevel + } + return DEFAULT_LEVEL +} + +/** + * Extract the configured logger function from the driver's logging configuration. + * @param {Object} loggingConfig the logging configuration. + * @return {function(level: string, message: string)} the configured logging function. + */ +function extractConfiguredLogger (loggingConfig: LoggingConfig): LoggerFunction { + if (loggingConfig?.logger != null) { + const configuredLogger = loggingConfig.logger + if (configuredLogger != null && typeof configuredLogger === 'function') { + return configuredLogger + } + } + throw newError(`Illegal logger function: ${loggingConfig?.logger?.toString() ?? 'undefined'}`) +} diff --git a/packages/neo4j-driver-deno/lib/core/internal/object-util.ts b/packages/neo4j-driver-deno/lib/core/internal/object-util.ts new file mode 100644 index 000000000..77016038c --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/internal/object-util.ts @@ -0,0 +1,87 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ +// eslint-disable-next-line @typescript-eslint/naming-convention +const __isBrokenObject__ = '__isBrokenObject__' +// eslint-disable-next-line @typescript-eslint/naming-convention +const __reason__ = '__reason__' + +/** + * Creates a object which all method call will throw the given error + * + * @param {Error} error The error + * @param {any} object The object. Default: {} + * @returns {any} A broken object + */ +function createBrokenObject (error: Error, object: any = {}): T { + const fail: () => T = () => { + throw error + } + + return new Proxy(object, { + get: (_: T, p: string | Symbol): any => { + if (p === __isBrokenObject__) { + return true + } else if (p === __reason__) { + return error + } else if (p === 'toJSON') { + return undefined + } + fail() + }, + set: fail, + apply: fail, + construct: fail, + defineProperty: fail, + deleteProperty: fail, + getOwnPropertyDescriptor: fail, + getPrototypeOf: fail, + has: fail, + isExtensible: fail, + ownKeys: fail, + preventExtensions: fail, + setPrototypeOf: fail + }) +} + +/** + * Verifies if it is a Broken Object + * @param {any} object The object + * @returns {boolean} If it was created with createBrokenObject + */ +function isBrokenObject (object: any): boolean { + return object !== null && typeof object === 'object' && object[__isBrokenObject__] === true +} + +/** + * Returns if the reason the object is broken. + * + * This method should only be called with instances create with {@link createBrokenObject} + * + * @param {any} object The object + * @returns {Error} The reason the object is broken + */ +function getBrokenObjectReason (object: any): Error { + return object[__reason__] +} + +export { + createBrokenObject, + isBrokenObject, + getBrokenObjectReason +} diff --git a/packages/neo4j-driver-deno/lib/core/internal/observers.ts b/packages/neo4j-driver-deno/lib/core/internal/observers.ts new file mode 100644 index 000000000..1b6aea7a5 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/internal/observers.ts @@ -0,0 +1,210 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 Record from '../record.ts' +import ResultSummary from '../result-summary.ts' + +interface StreamObserver { + /** + * Will be called on every record that comes in and transform a raw record + * to a Object. If user-provided observer is present, pass transformed record + * to it's onNext method, otherwise, push to record que. + * @param {Array} rawRecord - An array with the raw record + */ + onNext?: (rawRecord: any[]) => void + /** + * Will be called on errors. + * If user-provided observer is present, pass the error + * to it's onError method, otherwise set instance variable _error. + * @param {Object} error - An error object + */ + onError: (error: Error) => void + onCompleted?: (meta: any) => void +} + +/** + * Interface to observe updates on the Result which is being produced. + * + */ +interface ResultObserver { + /** + * Receive the keys present on the record whenever this information is available + * + * @param {string[]} keys The keys present on the {@link Record} + */ + onKeys?: (keys: string[]) => void + + /** + * Receive the each record present on the {@link @Result} + * @param {Record} record The {@link Record} produced + */ + onNext?: (record: Record) => void + + /** + * Called when the result is fully received + * @param {ResultSummary| any} summary The result summary + */ + onCompleted?: (summary: ResultSummary | any) => void + + /** + * Called when some error occurs during the result proccess or query execution + * @param {Error} error The error ocurred + */ + onError?: (error: Error) => void +} + +/** + * Raw observer for the stream + */ +export interface ResultStreamObserver extends StreamObserver { + /** + * Cancel pending record stream + */ + cancel: () => void + + /** + * Pause the record consuming + * + * This function will supend the record consuming. It will not cancel the stream and the already + * requested records will be sent to the subscriber. + */ + pause: () => void + + /** + * Resume the record consuming + * + * This function will resume the record consuming fetching more records from the server. + */ + resume: () => void + + /** + * Stream observer defaults to handling responses for two messages: RUN + PULL_ALL or RUN + DISCARD_ALL. + * Response for RUN initializes query keys. Response for PULL_ALL / DISCARD_ALL exposes the result stream. + * + * However, some operations can be represented as a single message which receives full metadata in a single response. + * For example, operations to begin, commit and rollback an explicit transaction use two messages in Bolt V1 but a single message in Bolt V3. + * Messages are `RUN "BEGIN" {}` + `PULL_ALL` in Bolt V1 and `BEGIN` in Bolt V3. + * + * This function prepares the observer to only handle a single response message. + */ + prepareToHandleSingleResponse: () => void + + /** + * Mark this observer as if it has completed with no metadata. + */ + markCompleted: () => void + + /** + * Subscribe to events with provided observer. + * @param {Object} observer - Observer object + * @param {function(keys: String[])} observer.onKeys - Handle stream header, field keys. + * @param {function(record: Object)} observer.onNext - Handle records, one by one. + * @param {function(metadata: Object)} observer.onCompleted - Handle stream tail, the metadata. + * @param {function(error: Object)} observer.onError - Handle errors, should always be provided. + */ + subscribe: (observer: ResultObserver) => void +} + +export class CompletedObserver implements ResultStreamObserver { + subscribe (observer: ResultObserver): void { + apply(observer, observer.onKeys, []) + apply(observer, observer.onCompleted, {}) + } + + cancel (): void { + // do nothing + } + + pause (): void { + // do nothing + } + + resume (): void { + // do nothing + } + + prepareToHandleSingleResponse (): void { + // do nothing + } + + markCompleted (): void { + // do nothing + } + + // eslint-disable-next-line node/handle-callback-err + onError (error: Error): void { + // nothing to do, already finished + throw Error('CompletedObserver not supposed to call onError') + } +} + +export class FailedObserver implements ResultStreamObserver { + private readonly _error: Error + private readonly _beforeError?: (error: Error) => void + private readonly _observers: ResultObserver[] + + constructor ({ + error, + onError + }: { + error: Error + onError?: (error: Error) => void | Promise + }) { + this._error = error + this._beforeError = onError + this._observers = [] + this.onError(error) + } + + subscribe (observer: ResultObserver): void { + apply(observer, observer.onError, this._error) + this._observers.push(observer) + } + + onError (error: Error): void { + apply(this, this._beforeError, error) + this._observers.forEach(o => apply(o, o.onError, error)) + } + + cancel (): void { + // do nothing + } + + pause (): void { + // do nothing + } + + resume (): void { + // do nothing + } + + markCompleted (): void { + // do nothing + } + + prepareToHandleSingleResponse (): void { + // do nothing + } +} + +function apply (thisArg: any, func?: (param: T) => void, param?: T): void { + if (func != null) { + func.bind(thisArg)(param as any) + } +} diff --git a/packages/neo4j-driver-deno/lib/core/internal/resolver/base-host-name-resolver.ts b/packages/neo4j-driver-deno/lib/core/internal/resolver/base-host-name-resolver.ts new file mode 100644 index 000000000..4dcdf5ea9 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/internal/resolver/base-host-name-resolver.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ +/* eslint-disable @typescript-eslint/promise-function-async */ + +import { ServerAddress } from '../server-address.ts' + +export default class BaseHostNameResolver { + resolve (): Promise { + throw new Error('Abstract function') + } + + /** + * @protected + */ + _resolveToItself (address: ServerAddress): Promise { + return Promise.resolve([address]) + } +} diff --git a/packages/neo4j-driver-deno/lib/core/internal/resolver/configured-custom-resolver.ts b/packages/neo4j-driver-deno/lib/core/internal/resolver/configured-custom-resolver.ts new file mode 100644 index 000000000..878096143 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/internal/resolver/configured-custom-resolver.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ +/* eslint-disable @typescript-eslint/promise-function-async */ +import { ServerAddress } from '../server-address.ts' + +function resolveToSelf (address: ServerAddress): Promise { + return Promise.resolve([address]) +} + +export default class ConfiguredCustomResolver { + private readonly _resolverFunction: (address: string) => string + + constructor (resolverFunction: (address: string) => string) { + this._resolverFunction = resolverFunction ?? resolveToSelf + } + + resolve (seedRouter: ServerAddress): Promise { + return new Promise(resolve => + resolve(this._resolverFunction(seedRouter.asHostPort())) + ).then(resolved => { + if (!Array.isArray(resolved)) { + throw new TypeError( + 'Configured resolver function should either return an array of addresses or a Promise resolved with an array of addresses.' + + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Each address is ':'. Got: ${resolved}` + ) + } + return resolved.map(r => ServerAddress.fromUrl(r)) + }) + } +} diff --git a/packages/neo4j-driver-deno/lib/core/internal/resolver/index.ts b/packages/neo4j-driver-deno/lib/core/internal/resolver/index.ts new file mode 100644 index 000000000..e60c5ea5b --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/internal/resolver/index.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 BaseHostNameResolver from './base-host-name-resolver.ts' +import ConfiguredCustomResolver from './configured-custom-resolver.ts' + +export { BaseHostNameResolver, ConfiguredCustomResolver } diff --git a/packages/neo4j-driver-deno/lib/core/internal/server-address.ts b/packages/neo4j-driver-deno/lib/core/internal/server-address.ts new file mode 100644 index 000000000..89efa84d9 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/internal/server-address.ts @@ -0,0 +1,79 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { assertNumber, assertString } from './util.ts' +import * as urlUtil from './url-util.ts' + +export class ServerAddress { + private readonly _host: string + private readonly _resolved: string | null + private readonly _port: number + private readonly _hostPort: string + private readonly _stringValue: string + + constructor ( + host: string, + resolved: string | null | undefined, + port: number, + hostPort: string + ) { + this._host = assertString(host, 'host') + this._resolved = resolved != null ? assertString(resolved, 'resolved') : null + this._port = assertNumber(port, 'port') + this._hostPort = hostPort + this._stringValue = resolved != null ? `${hostPort}(${resolved})` : `${hostPort}` + } + + host (): string { + return this._host + } + + resolvedHost (): string { + return this._resolved != null ? this._resolved : this._host + } + + port (): number { + return this._port + } + + resolveWith (resolved: string): ServerAddress { + return new ServerAddress(this._host, resolved, this._port, this._hostPort) + } + + asHostPort (): string { + return this._hostPort + } + + asKey (): string { + return this._hostPort + } + + toString (): string { + return this._stringValue + } + + static fromUrl (url: string): ServerAddress { + const urlParsed = urlUtil.parseDatabaseUrl(url) + return new ServerAddress( + urlParsed.host, + null, + urlParsed.port, + urlParsed.hostAndPort + ) + } +} diff --git a/packages/neo4j-driver-deno/lib/core/internal/temporal-util.ts b/packages/neo4j-driver-deno/lib/core/internal/temporal-util.ts new file mode 100644 index 000000000..8ea58b204 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/internal/temporal-util.ts @@ -0,0 +1,645 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 Integer, { int, isInt } from '../integer.ts' +import { newError } from '../error.ts' +import { assertNumberOrInteger } from './util.ts' +import { NumberOrInteger } from '../graph-types.ts' + +/* + Code in this util should be compatible with code in the database that uses JSR-310 java.time APIs. + + It is based on a library called ThreeTen (https://github.com/ThreeTen/threetenbp) which was derived + from JSR-310 reference implementation previously hosted on GitHub. Code uses `Integer` type everywhere + to correctly handle large integer values that are greater than `Number.MAX_SAFE_INTEGER`. + + Please consult either ThreeTen or js-joda (https://github.com/js-joda/js-joda) when working with the + conversion functions. + */ +class ValueRange { + _minNumber: number + _maxNumber: number + _minInteger: Integer + _maxInteger: Integer + + constructor (min: number, max: number) { + this._minNumber = min + this._maxNumber = max + this._minInteger = int(min) + this._maxInteger = int(max) + } + + contains (value: number | Integer | bigint): boolean { + if (isInt(value) && value instanceof Integer) { + return ( + value.greaterThanOrEqual(this._minInteger) && + value.lessThanOrEqual(this._maxInteger) + ) + } else if (typeof value === 'bigint') { + const intValue = int(value) + return ( + intValue.greaterThanOrEqual(this._minInteger) && + intValue.lessThanOrEqual(this._maxInteger) + ) + } else { + return value >= this._minNumber && value <= this._maxNumber + } + } + + toString (): string { + return `[${this._minNumber}, ${this._maxNumber}]` + } +} + +export const YEAR_RANGE = new ValueRange(-999999999, 999999999) +export const MONTH_OF_YEAR_RANGE = new ValueRange(1, 12) +export const DAY_OF_MONTH_RANGE = new ValueRange(1, 31) +export const HOUR_OF_DAY_RANGE = new ValueRange(0, 23) +export const MINUTE_OF_HOUR_RANGE = new ValueRange(0, 59) +export const SECOND_OF_MINUTE_RANGE = new ValueRange(0, 59) +export const NANOSECOND_OF_SECOND_RANGE = new ValueRange(0, 999999999) + +export const MINUTES_PER_HOUR = 60 +export const SECONDS_PER_MINUTE = 60 +export const SECONDS_PER_HOUR = SECONDS_PER_MINUTE * MINUTES_PER_HOUR +export const NANOS_PER_SECOND = 1000000000 +export const NANOS_PER_MILLISECOND = 1000000 +export const NANOS_PER_MINUTE = NANOS_PER_SECOND * SECONDS_PER_MINUTE +export const NANOS_PER_HOUR = NANOS_PER_MINUTE * MINUTES_PER_HOUR +export const DAYS_0000_TO_1970 = 719528 +export const DAYS_PER_400_YEAR_CYCLE = 146097 +export const SECONDS_PER_DAY = 86400 + +export function normalizeSecondsForDuration ( + seconds: number | Integer | bigint, + nanoseconds: number | Integer | bigint +): Integer { + return int(seconds).add(floorDiv(nanoseconds, NANOS_PER_SECOND)) +} + +export function normalizeNanosecondsForDuration ( + nanoseconds: number | Integer | bigint +): Integer { + return floorMod(nanoseconds, NANOS_PER_SECOND) +} + +/** + * Converts given local time into a single integer representing this same time in nanoseconds of the day. + * @param {Integer|number|string} hour the hour of the local time to convert. + * @param {Integer|number|string} minute the minute of the local time to convert. + * @param {Integer|number|string} second the second of the local time to convert. + * @param {Integer|number|string} nanosecond the nanosecond of the local time to convert. + * @return {Integer} nanoseconds representing the given local time. + */ +export function localTimeToNanoOfDay ( + hour: NumberOrInteger | string, + minute: NumberOrInteger | string, + second: NumberOrInteger | string, + nanosecond: NumberOrInteger | string +): Integer { + hour = int(hour) + minute = int(minute) + second = int(second) + nanosecond = int(nanosecond) + + let totalNanos = hour.multiply(NANOS_PER_HOUR) + totalNanos = totalNanos.add(minute.multiply(NANOS_PER_MINUTE)) + totalNanos = totalNanos.add(second.multiply(NANOS_PER_SECOND)) + return totalNanos.add(nanosecond) +} + +/** + * Converts given local date time into a single integer representing this same time in epoch seconds UTC. + * @param {Integer|number|string} year the year of the local date-time to convert. + * @param {Integer|number|string} month the month of the local date-time to convert. + * @param {Integer|number|string} day the day of the local date-time to convert. + * @param {Integer|number|string} hour the hour of the local date-time to convert. + * @param {Integer|number|string} minute the minute of the local date-time to convert. + * @param {Integer|number|string} second the second of the local date-time to convert. + * @param {Integer|number|string} nanosecond the nanosecond of the local date-time to convert. + * @return {Integer} epoch second in UTC representing the given local date time. + */ +export function localDateTimeToEpochSecond ( + year: NumberOrInteger | string, + month: NumberOrInteger | string, + day: NumberOrInteger | string, + hour: NumberOrInteger | string, + minute: NumberOrInteger | string, + second: NumberOrInteger | string, + nanosecond: NumberOrInteger | string +): Integer { + const epochDay = dateToEpochDay(year, month, day) + const localTimeSeconds = localTimeToSecondOfDay(hour, minute, second) + return epochDay.multiply(SECONDS_PER_DAY).add(localTimeSeconds) +} + +/** + * Converts given local date into a single integer representing it's epoch day. + * @param {Integer|number|string} year the year of the local date to convert. + * @param {Integer|number|string} month the month of the local date to convert. + * @param {Integer|number|string} day the day of the local date to convert. + * @return {Integer} epoch day representing the given date. + */ +export function dateToEpochDay ( + year: NumberOrInteger | string, + month: NumberOrInteger | string, + day: NumberOrInteger | string +): Integer { + year = int(year) + month = int(month) + day = int(day) + + let epochDay = year.multiply(365) + + if (year.greaterThanOrEqual(0)) { + epochDay = epochDay.add( + year + .add(3) + .div(4) + .subtract(year.add(99).div(100)) + .add(year.add(399).div(400)) + ) + } else { + epochDay = epochDay.subtract( + year + .div(-4) + .subtract(year.div(-100)) + .add(year.div(-400)) + ) + } + + epochDay = epochDay.add( + month + .multiply(367) + .subtract(362) + .div(12) + ) + epochDay = epochDay.add(day.subtract(1)) + if (month.greaterThan(2)) { + epochDay = epochDay.subtract(1) + if (!isLeapYear(year)) { + epochDay = epochDay.subtract(1) + } + } + return epochDay.subtract(DAYS_0000_TO_1970) +} + +/** + * Format given duration to an ISO 8601 string. + * @param {Integer|number|string} months the number of months. + * @param {Integer|number|string} days the number of days. + * @param {Integer|number|string} seconds the number of seconds. + * @param {Integer|number|string} nanoseconds the number of nanoseconds. + * @return {string} ISO string that represents given duration. + */ +export function durationToIsoString ( + months: NumberOrInteger | string, + days: NumberOrInteger | string, + seconds: NumberOrInteger | string, + nanoseconds: NumberOrInteger | string +): string { + const monthsString = formatNumber(months) + const daysString = formatNumber(days) + const secondsAndNanosecondsString = formatSecondsAndNanosecondsForDuration( + seconds, + nanoseconds + ) + return `P${monthsString}M${daysString}DT${secondsAndNanosecondsString}S` +} + +/** + * Formats given time to an ISO 8601 string. + * @param {Integer|number|string} hour the hour value. + * @param {Integer|number|string} minute the minute value. + * @param {Integer|number|string} second the second value. + * @param {Integer|number|string} nanosecond the nanosecond value. + * @return {string} ISO string that represents given time. + */ +export function timeToIsoString ( + hour: NumberOrInteger | string, + minute: NumberOrInteger | string, + second: NumberOrInteger | string, + nanosecond: NumberOrInteger | string +): string { + const hourString = formatNumber(hour, 2) + const minuteString = formatNumber(minute, 2) + const secondString = formatNumber(second, 2) + const nanosecondString = formatNanosecond(nanosecond) + return `${hourString}:${minuteString}:${secondString}${nanosecondString}` +} + +/** + * Formats given time zone offset in seconds to string representation like '±HH:MM', '±HH:MM:SS' or 'Z' for UTC. + * @param {Integer|number|string} offsetSeconds the offset in seconds. + * @return {string} ISO string that represents given offset. + */ +export function timeZoneOffsetToIsoString ( + offsetSeconds: NumberOrInteger | string +): string { + offsetSeconds = int(offsetSeconds) + if (offsetSeconds.equals(0)) { + return 'Z' + } + + const isNegative = offsetSeconds.isNegative() + if (isNegative) { + offsetSeconds = offsetSeconds.multiply(-1) + } + const signPrefix = isNegative ? '-' : '+' + + const hours = formatNumber(offsetSeconds.div(SECONDS_PER_HOUR), 2) + const minutes = formatNumber( + offsetSeconds.div(SECONDS_PER_MINUTE).modulo(MINUTES_PER_HOUR), + 2 + ) + const secondsValue = offsetSeconds.modulo(SECONDS_PER_MINUTE) + const seconds = secondsValue.equals(0) ? null : formatNumber(secondsValue, 2) + + return seconds != null + ? `${signPrefix}${hours}:${minutes}:${seconds}` + : `${signPrefix}${hours}:${minutes}` +} + +/** + * Formats given date to an ISO 8601 string. + * @param {Integer|number|string} year the date year. + * @param {Integer|number|string} month the date month. + * @param {Integer|number|string} day the date day. + * @return {string} ISO string that represents given date. + */ +export function dateToIsoString ( + year: NumberOrInteger | string, + month: NumberOrInteger | string, + day: NumberOrInteger | string +): string { + const yearString = formatYear(year) + const monthString = formatNumber(month, 2) + const dayString = formatNumber(day, 2) + return `${yearString}-${monthString}-${dayString}` +} + +/** + * Convert the given iso date string to a JavaScript Date object + * + * @param {string} isoString The iso date string + * @returns {Date} the date + */ +export function isoStringToStandardDate (isoString: string): Date { + return new Date(isoString) +} + +/** + * Convert the given utc timestamp to a JavaScript Date object + * + * @param {number} utc Timestamp in UTC + * @returns {Date} the date + */ +export function toStandardDate (utc: number): Date { + return new Date(utc) +} + +/** + * Shortcut for creating a new StandardDate + * @param date + * @returns {Date} the standard date + */ +export function newDate (date: string | number | Date): Date { + return new Date(date) +} + +/** + * Get the total number of nanoseconds from the milliseconds of the given standard JavaScript date and optional nanosecond part. + * @param {global.Date} standardDate the standard JavaScript date. + * @param {Integer|number|bigint|undefined} nanoseconds the optional number of nanoseconds. + * @return {Integer|number|bigint} the total amount of nanoseconds. + */ +export function totalNanoseconds ( + standardDate: Date, + nanoseconds?: NumberOrInteger +): NumberOrInteger { + nanoseconds = nanoseconds ?? 0 + const nanosFromMillis = standardDate.getMilliseconds() * NANOS_PER_MILLISECOND + return add(nanoseconds, nanosFromMillis) +} + +/** + * Get the time zone offset in seconds from the given standard JavaScript date. + * + * Implementation note: + * Time zone offset returned by the standard JavaScript date is the difference, in minutes, from local time to UTC. + * So positive value means offset is behind UTC and negative value means it is ahead. + * For Neo4j temporal types, like `Time` or `DateTime` offset is in seconds and represents difference from UTC to local time. + * This is different from standard JavaScript dates and that's why implementation negates the returned value. + * + * @param {global.Date} standardDate the standard JavaScript date. + * @return {number} the time zone offset in seconds. + */ +export function timeZoneOffsetInSeconds (standardDate: Date): number { + const secondsPortion = standardDate.getSeconds() >= standardDate.getUTCSeconds() + ? standardDate.getSeconds() - standardDate.getUTCSeconds() + : standardDate.getSeconds() - standardDate.getUTCSeconds() + 60 + const offsetInMinutes = standardDate.getTimezoneOffset() + if (offsetInMinutes === 0) { + return 0 + secondsPortion + } + return -1 * offsetInMinutes * SECONDS_PER_MINUTE + secondsPortion +} + +/** + * Assert that the year value is valid. + * @param {Integer|number} year the value to check. + * @return {Integer|number} the value of the year if it is valid. Exception is thrown otherwise. + */ +export function assertValidYear (year: NumberOrInteger): NumberOrInteger { + return assertValidTemporalValue(year, YEAR_RANGE, 'Year') +} + +/** + * Assert that the month value is valid. + * @param {Integer|number} month the value to check. + * @return {Integer|number} the value of the month if it is valid. Exception is thrown otherwise. + */ +export function assertValidMonth (month: NumberOrInteger): NumberOrInteger { + return assertValidTemporalValue(month, MONTH_OF_YEAR_RANGE, 'Month') +} + +/** + * Assert that the day value is valid. + * @param {Integer|number} day the value to check. + * @return {Integer|number} the value of the day if it is valid. Exception is thrown otherwise. + */ +export function assertValidDay (day: NumberOrInteger): NumberOrInteger { + return assertValidTemporalValue(day, DAY_OF_MONTH_RANGE, 'Day') +} + +/** + * Assert that the hour value is valid. + * @param {Integer|number} hour the value to check. + * @return {Integer|number} the value of the hour if it is valid. Exception is thrown otherwise. + */ +export function assertValidHour (hour: NumberOrInteger): NumberOrInteger { + return assertValidTemporalValue(hour, HOUR_OF_DAY_RANGE, 'Hour') +} + +/** + * Assert that the minute value is valid. + * @param {Integer|number} minute the value to check. + * @return {Integer|number} the value of the minute if it is valid. Exception is thrown otherwise. + */ +export function assertValidMinute (minute: NumberOrInteger): NumberOrInteger { + return assertValidTemporalValue(minute, MINUTE_OF_HOUR_RANGE, 'Minute') +} + +/** + * Assert that the second value is valid. + * @param {Integer|number} second the value to check. + * @return {Integer|number} the value of the second if it is valid. Exception is thrown otherwise. + */ +export function assertValidSecond (second: NumberOrInteger): NumberOrInteger { + return assertValidTemporalValue(second, SECOND_OF_MINUTE_RANGE, 'Second') +} + +/** + * Assert that the nanosecond value is valid. + * @param {Integer|number} nanosecond the value to check. + * @return {Integer|number} the value of the nanosecond if it is valid. Exception is thrown otherwise. + */ +export function assertValidNanosecond ( + nanosecond: NumberOrInteger +): NumberOrInteger { + return assertValidTemporalValue( + nanosecond, + NANOSECOND_OF_SECOND_RANGE, + 'Nanosecond' + ) +} + +export function assertValidZoneId (fieldName: string, zoneId: string): void { + try { + Intl.DateTimeFormat(undefined, { timeZone: zoneId }) + } catch (e) { + throw newError( + `${fieldName} is expected to be a valid ZoneId but was: "${zoneId}"` + ) + } +} + +/** + * Check if the given value is of expected type and is in the expected range. + * @param {Integer|number} value the value to check. + * @param {ValueRange} range the range. + * @param {string} name the name of the value. + * @return {Integer|number} the value if valid. Exception is thrown otherwise. + */ +function assertValidTemporalValue ( + value: NumberOrInteger, + range: ValueRange, + name: string +): NumberOrInteger { + assertNumberOrInteger(value, name) + if (!range.contains(value)) { + throw newError( + `${name} is expected to be in range ${range.toString()} but was: ${value.toString()}` + ) + } + return value +} + +/** + * Converts given local time into a single integer representing this same time in seconds of the day. Nanoseconds are skipped. + * @param {Integer|number|string} hour the hour of the local time. + * @param {Integer|number|string} minute the minute of the local time. + * @param {Integer|number|string} second the second of the local time. + * @return {Integer} seconds representing the given local time. + */ +function localTimeToSecondOfDay ( + hour: NumberOrInteger | string, + minute: NumberOrInteger | string, + second: NumberOrInteger | string +): Integer { + hour = int(hour) + minute = int(minute) + second = int(second) + + let totalSeconds = hour.multiply(SECONDS_PER_HOUR) + totalSeconds = totalSeconds.add(minute.multiply(SECONDS_PER_MINUTE)) + return totalSeconds.add(second) +} + +/** + * Check if given year is a leap year. Uses algorithm described here {@link https://en.wikipedia.org/wiki/Leap_year#Algorithm}. + * @param {Integer|number|string} year the year to check. Will be converted to {@link Integer} for all calculations. + * @return {boolean} `true` if given year is a leap year, `false` otherwise. + */ +function isLeapYear (year: NumberOrInteger | string): boolean { + year = int(year) + + if (!year.modulo(4).equals(0)) { + return false + } else if (!year.modulo(100).equals(0)) { + return true + } else if (!year.modulo(400).equals(0)) { + return false + } else { + return true + } +} + +/** + * @param {Integer|number|string} x the divident. + * @param {Integer|number|string} y the divisor. + * @return {Integer} the result. + */ +export function floorDiv ( + x: NumberOrInteger | string, + y: NumberOrInteger | string +): Integer { + x = int(x) + y = int(y) + + let result = x.div(y) + if (x.isPositive() !== y.isPositive() && result.multiply(y).notEquals(x)) { + result = result.subtract(1) + } + return result +} + +/** + * @param {Integer|number|string} x the divident. + * @param {Integer|number|string} y the divisor. + * @return {Integer} the result. + */ +export function floorMod ( + x: NumberOrInteger | string, + y: NumberOrInteger | string +): Integer { + x = int(x) + y = int(y) + + return x.subtract(floorDiv(x, y).multiply(y)) +} + +/** + * @param {Integer|number|string} seconds the number of seconds to format. + * @param {Integer|number|string} nanoseconds the number of nanoseconds to format. + * @return {string} formatted value. + */ +function formatSecondsAndNanosecondsForDuration ( + seconds: NumberOrInteger | string, + nanoseconds: NumberOrInteger | string +): string { + seconds = int(seconds) + nanoseconds = int(nanoseconds) + + let secondsString + let nanosecondsString + + const secondsNegative = seconds.isNegative() + const nanosecondsGreaterThanZero = nanoseconds.greaterThan(0) + if (secondsNegative && nanosecondsGreaterThanZero) { + if (seconds.equals(-1)) { + secondsString = '-0' + } else { + secondsString = seconds.add(1).toString() + } + } else { + secondsString = seconds.toString() + } + + if (nanosecondsGreaterThanZero) { + if (secondsNegative) { + nanosecondsString = formatNanosecond( + nanoseconds + .negate() + .add(2 * NANOS_PER_SECOND) + .modulo(NANOS_PER_SECOND) + ) + } else { + nanosecondsString = formatNanosecond( + nanoseconds.add(NANOS_PER_SECOND).modulo(NANOS_PER_SECOND) + ) + } + } + + return nanosecondsString != null ? secondsString + nanosecondsString : secondsString +} + +/** + * @param {Integer|number|string} value the number of nanoseconds to format. + * @return {string} formatted and possibly left-padded nanoseconds part as string. + */ +function formatNanosecond (value: NumberOrInteger | string): string { + value = int(value) + return value.equals(0) ? '' : '.' + formatNumber(value, 9) +} + +/** + * + * @param {Integer|number|string} year The year to be formatted + * @return {string} formatted year + */ +function formatYear (year: NumberOrInteger | string): string { + const yearInteger = int(year) + if (yearInteger.isNegative() || yearInteger.greaterThan(9999)) { + return formatNumber(yearInteger, 6, { usePositiveSign: true }) + } + return formatNumber(yearInteger, 4) +} + +/** + * @param {Integer|number|string} num the number to format. + * @param {number} [stringLength=undefined] the string length to left-pad to. + * @return {string} formatted and possibly left-padded number as string. + */ +function formatNumber ( + num: NumberOrInteger | string, + stringLength?: number, + params?: { + usePositiveSign?: boolean + } +): string { + num = int(num) + const isNegative = num.isNegative() + if (isNegative) { + num = num.negate() + } + + let numString = num.toString() + if (stringLength != null) { + // left pad the string with zeroes + while (numString.length < stringLength) { + numString = '0' + numString + } + } + if (isNegative) { + return '-' + numString + } else if (params?.usePositiveSign === true) { + return '+' + numString + } + return numString +} + +function add (x: NumberOrInteger, y: number): NumberOrInteger { + if (x instanceof Integer) { + return x.add(y) + } else if (typeof x === 'bigint') { + return x + BigInt(y) + } + return x + y +} diff --git a/packages/neo4j-driver-deno/lib/core/internal/transaction-executor.ts b/packages/neo4j-driver-deno/lib/core/internal/transaction-executor.ts new file mode 100644 index 000000000..df0f131b3 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/internal/transaction-executor.ts @@ -0,0 +1,268 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ +/* eslint-disable @typescript-eslint/promise-function-async */ + +import { newError, isRetriableError } from '../error.ts' +import Transaction from '../transaction.ts' +import TransactionPromise from '../transaction-promise.ts' + +const DEFAULT_MAX_RETRY_TIME_MS = 30 * 1000 // 30 seconds +const DEFAULT_INITIAL_RETRY_DELAY_MS = 1000 // 1 seconds +const DEFAULT_RETRY_DELAY_MULTIPLIER = 2.0 +const DEFAULT_RETRY_DELAY_JITTER_FACTOR = 0.2 + +type TransactionCreator = () => TransactionPromise +type TransactionWork = (tx: Tx) => T | Promise +type Resolve = (value: T | PromiseLike) => void +type Reject = (value: any) => void +type Timeout = ReturnType + +export class TransactionExecutor { + private readonly _maxRetryTimeMs: number + private readonly _initialRetryDelayMs: number + private readonly _multiplier: number + private readonly _jitterFactor: number + private _inFlightTimeoutIds: Timeout[] + + constructor ( + maxRetryTimeMs?: number | null, + initialRetryDelayMs?: number, + multiplier?: number, + jitterFactor?: number + ) { + this._maxRetryTimeMs = _valueOrDefault( + maxRetryTimeMs, + DEFAULT_MAX_RETRY_TIME_MS + ) + this._initialRetryDelayMs = _valueOrDefault( + initialRetryDelayMs, + DEFAULT_INITIAL_RETRY_DELAY_MS + ) + this._multiplier = _valueOrDefault( + multiplier, + DEFAULT_RETRY_DELAY_MULTIPLIER + ) + this._jitterFactor = _valueOrDefault( + jitterFactor, + DEFAULT_RETRY_DELAY_JITTER_FACTOR + ) + + this._inFlightTimeoutIds = [] + + this._verifyAfterConstruction() + } + + execute( + transactionCreator: TransactionCreator, + transactionWork: TransactionWork, + transactionWrapper?: (tx: Transaction) => Tx + ): Promise { + return new Promise((resolve, reject) => { + this._executeTransactionInsidePromise( + transactionCreator, + transactionWork, + resolve, + reject, + transactionWrapper + ).catch(reject) + }).catch(error => { + const retryStartTimeMs = Date.now() + const retryDelayMs = this._initialRetryDelayMs + return this._retryTransactionPromise( + transactionCreator, + transactionWork, + error, + retryStartTimeMs, + retryDelayMs, + transactionWrapper + ) + }) + } + + close (): void { + // cancel all existing timeouts to prevent further retries + this._inFlightTimeoutIds.forEach(timeoutId => clearTimeout(timeoutId)) + this._inFlightTimeoutIds = [] + } + + _retryTransactionPromise( + transactionCreator: TransactionCreator, + transactionWork: TransactionWork, + error: Error, + retryStartTime: number, + retryDelayMs: number, + transactionWrapper?: (tx: Transaction) => Tx + ): Promise { + const elapsedTimeMs = Date.now() - retryStartTime + + if (elapsedTimeMs > this._maxRetryTimeMs || !isRetriableError(error)) { + return Promise.reject(error) + } + + return new Promise((resolve, reject) => { + const nextRetryTime = this._computeDelayWithJitter(retryDelayMs) + const timeoutId = setTimeout(() => { + // filter out this timeoutId when time has come and function is being executed + this._inFlightTimeoutIds = this._inFlightTimeoutIds.filter( + id => id !== timeoutId + ) + this._executeTransactionInsidePromise( + transactionCreator, + transactionWork, + resolve, + reject, + transactionWrapper + ).catch(reject) + }, nextRetryTime) + // add newly created timeoutId to the list of all in-flight timeouts + this._inFlightTimeoutIds.push(timeoutId) + }).catch(error => { + const nextRetryDelayMs = retryDelayMs * this._multiplier + return this._retryTransactionPromise( + transactionCreator, + transactionWork, + error, + retryStartTime, + nextRetryDelayMs, + transactionWrapper + ) + }) + } + + async _executeTransactionInsidePromise( + transactionCreator: TransactionCreator, + transactionWork: TransactionWork, + resolve: Resolve, + reject: Reject, + transactionWrapper?: (tx: Transaction) => Tx + ): Promise { + let tx: Transaction + try { + tx = await transactionCreator() + } catch (error) { + // failed to create a transaction + reject(error) + return + } + + // The conversion from `tx` as `unknown` then to `Tx` is necessary + // because it is not possible to be sure that `Tx` is a subtype of `Transaction` + // in using static type checking. + const wrap = transactionWrapper ?? ((tx: Transaction) => tx as unknown as Tx) + const wrappedTx = wrap(tx) + const resultPromise = this._safeExecuteTransactionWork(wrappedTx, transactionWork) + + resultPromise + .then(result => + this._handleTransactionWorkSuccess(result, tx, resolve, reject) + ) + .catch(error => this._handleTransactionWorkFailure(error, tx, reject)) + } + + _safeExecuteTransactionWork( + tx: Tx, + transactionWork: TransactionWork + ): Promise { + try { + const result = transactionWork(tx) + // user defined callback is supposed to return a promise, but it might not; so to protect against an + // incorrect API usage we wrap the returned value with a resolved promise; this is effectively a + // validation step without type checks + return Promise.resolve(result) + } catch (error) { + return Promise.reject(error) + } + } + + _handleTransactionWorkSuccess( + result: T, + tx: Transaction, + resolve: Resolve, + reject: Reject + ): void { + if (tx.isOpen()) { + // transaction work returned resolved promise and transaction has not been committed/rolled back + // try to commit the transaction + tx.commit() + .then(() => { + // transaction was committed, return result to the user + resolve(result) + }) + .catch(error => { + // transaction failed to commit, propagate the failure + reject(error) + }) + } else { + // transaction work returned resolved promise and transaction is already committed/rolled back + // return the result returned by given transaction work + resolve(result) + } + } + + _handleTransactionWorkFailure (error: any, tx: Transaction, reject: Reject): void { + if (tx.isOpen()) { + // transaction work failed and the transaction is still open, roll it back and propagate the failure + tx.rollback() + .catch(ignore => { + // ignore the rollback error + }) + .then(() => reject(error)) // propagate the original error we got from the transaction work + .catch(reject) + } else { + // transaction is already rolled back, propagate the error + reject(error) + } + } + + _computeDelayWithJitter (delayMs: number): number { + const jitter = delayMs * this._jitterFactor + const min = delayMs - jitter + const max = delayMs + jitter + return Math.random() * (max - min) + min + } + + _verifyAfterConstruction (): void { + if (this._maxRetryTimeMs < 0) { + throw newError('Max retry time should be >= 0: ' + this._maxRetryTimeMs.toString()) + } + if (this._initialRetryDelayMs < 0) { + throw newError( + 'Initial retry delay should >= 0: ' + this._initialRetryDelayMs.toString() + ) + } + if (this._multiplier < 1.0) { + throw newError('Multiplier should be >= 1.0: ' + this._multiplier.toString()) + } + if (this._jitterFactor < 0 || this._jitterFactor > 1) { + throw newError( + 'Jitter factor should be in [0.0, 1.0]: ' + this._jitterFactor.toFixed() + ) + } + } +} + +function _valueOrDefault ( + value: number | undefined | null, + defaultValue: number +): number { + if (value != null) { + return value + } + return defaultValue +} diff --git a/packages/neo4j-driver-deno/lib/core/internal/tx-config.ts b/packages/neo4j-driver-deno/lib/core/internal/tx-config.ts new file mode 100644 index 000000000..825af19cf --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/internal/tx-config.ts @@ -0,0 +1,97 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 * as util from './util.ts' +import { newError } from '../error.ts' +import Integer, { int } from '../integer.ts' + +/** + * Internal holder of the transaction configuration. + * It performs input validation and value conversion for further serialization by the Bolt protocol layer. + * Users of the driver provide transaction configuration as regular objects `{timeout: 10, metadata: {key: 'value'}}`. + * Driver converts such objects to {@link TxConfig} immediately and uses converted values everywhere. + */ +export class TxConfig { + readonly timeout: Integer | null + readonly metadata: any + + /** + * @constructor + * @param {Object} config the raw configuration object. + */ + constructor (config: any) { + assertValidConfig(config) + this.timeout = extractTimeout(config) + this.metadata = extractMetadata(config) + } + + /** + * Get an empty config object. + * @return {TxConfig} an empty config. + */ + static empty (): TxConfig { + return EMPTY_CONFIG + } + + /** + * Check if this config object is empty. I.e. has no configuration values specified. + * @return {boolean} `true` if this object is empty, `false` otherwise. + */ + isEmpty (): boolean { + return Object.values(this).every(value => value == null) + } +} + +const EMPTY_CONFIG = new TxConfig({}) + +/** + * @return {Integer|null} + */ +function extractTimeout (config: any): Integer | null { + if (util.isObject(config) && config.timeout != null) { + util.assertNumberOrInteger(config.timeout, 'Transaction timeout') + const timeout = int(config.timeout) + if (timeout.isNegative()) { + throw newError('Transaction timeout should not be negative') + } + return timeout + } + return null +} + +/** + * @return {object|null} + */ +function extractMetadata (config: any): any { + if (util.isObject(config) && config.metadata != null) { + const metadata = config.metadata + util.assertObject(metadata, 'config.metadata') + if (Object.keys(metadata).length !== 0) { + // not an empty object + return metadata + } + } + return null +} + +function assertValidConfig (config: any): void { + if (config != null) { + util.assertObject(config, 'Transaction config') + } +} diff --git a/packages/neo4j-driver-deno/lib/core/internal/url-util.ts b/packages/neo4j-driver-deno/lib/core/internal/url-util.ts new file mode 100644 index 000000000..1f1913d0a --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/internal/url-util.ts @@ -0,0 +1,343 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { assertString } from './util.ts' + +const DEFAULT_BOLT_PORT = 7687 +const DEFAULT_HTTP_PORT = 7474 +const DEFAULT_HTTPS_PORT = 7473 + +class Url { + readonly scheme: string | null + readonly host: string + readonly port: number + readonly hostAndPort: string + readonly query: Object + + constructor ( + scheme: string | null, + host: string, + port: number, + hostAndPort: string, + query: Object + ) { + /** + * Nullable scheme (protocol) of the URL. + * Example: 'bolt', 'neo4j', 'http', 'https', etc. + * @type {string} + */ + this.scheme = scheme + + /** + * Nonnull host name or IP address. IPv6 not wrapped in square brackets. + * Example: 'neo4j.com', 'localhost', '127.0.0.1', '192.168.10.15', '::1', '2001:4860:4860::8844', etc. + * @type {string} + */ + this.host = host + + /** + * Nonnull number representing port. Default port for the given scheme is used if given URL string + * does not contain port. Example: 7687 for bolt, 7474 for HTTP and 7473 for HTTPS. + * @type {number} + */ + this.port = port + + /** + * Nonnull host name or IP address plus port, separated by ':'. IPv6 wrapped in square brackets. + * Example: 'neo4j.com', 'neo4j.com:7687', '127.0.0.1', '127.0.0.1:8080', '[2001:4860:4860::8844]', + * '[2001:4860:4860::8844]:9090', etc. + * @type {string} + */ + this.hostAndPort = hostAndPort + + /** + * Nonnull object representing parsed query string key-value pairs. Duplicated keys not supported. + * Example: '{}', '{'key1': 'value1', 'key2': 'value2'}', etc. + * @type {Object} + */ + this.query = query + } +} + +interface ParsedUri { + scheme?: string + host?: string + port?: number | string + query?: string + fragment?: string + userInfo?: string + authority?: string + path?: string +} + +function parseDatabaseUrl (url: string): Url { + assertString(url, 'URL') + + const sanitized = sanitizeUrl(url) + const parsedUrl = uriJsParse(sanitized.url) + + const scheme = sanitized.schemeMissing + ? null + : extractScheme(parsedUrl.scheme) + const host = extractHost(parsedUrl.host) // no square brackets for IPv6 + const formattedHost = formatHost(host) // has square brackets for IPv6 + const port = extractPort(parsedUrl.port, scheme) + const hostAndPort = `${formattedHost}:${port}` + const query = extractQuery( + // @ts-expect-error + parsedUrl.query ?? extractResourceQueryString(parsedUrl.resourceName), + url + ) + + return new Url(scheme, host, port, hostAndPort, query) +} + +function extractResourceQueryString (resource?: string): string | null { + if (typeof resource !== 'string') { + return null + } + const [, query] = resource.split('?') + return query +} + +function sanitizeUrl (url: string): { schemeMissing: boolean, url: string } { + url = url.trim() + + if (!url.includes('://')) { + // url does not contain scheme, add dummy 'none://' to make parser work correctly + return { schemeMissing: true, url: `none://${url}` } + } + + return { schemeMissing: false, url: url } +} + +function extractScheme (scheme?: string): string | null { + if (scheme != null) { + scheme = scheme.trim() + if (scheme.charAt(scheme.length - 1) === ':') { + scheme = scheme.substring(0, scheme.length - 1) + } + return scheme + } + return null +} + +function extractHost (host?: string, url?: string): string { + if (host == null) { + throw new Error('Unable to extract host from null or undefined URL') + } + return host.trim() +} + +function extractPort ( + portString: string | number | undefined, + scheme: string | null +): number { + const port = + typeof portString === 'string' ? parseInt(portString, 10) : portString + return port != null && !isNaN(port) ? port : defaultPortForScheme(scheme) +} + +function extractQuery ( + queryString: string | undefined | null, + url: string +): Object { + const query = queryString != null ? trimAndSanitizeQuery(queryString) : null + const context: any = {} + + if (query != null) { + query.split('&').forEach((pair: string) => { + const keyValue = pair.split('=') + if (keyValue.length !== 2) { + throw new Error(`Invalid parameters: '${keyValue.toString()}' in URL '${url}'.`) + } + + const key = trimAndVerifyQueryElement(keyValue[0], 'key', url) + const value = trimAndVerifyQueryElement(keyValue[1], 'value', url) + + if (context[key] !== undefined) { + throw new Error( + `Duplicated query parameters with key '${key}' in URL '${url}'` + ) + } + + context[key] = value + }) + } + + return context +} + +function trimAndSanitizeQuery (query: string): string { + query = (query ?? '').trim() + if (query?.charAt(0) === '?') { + query = query.substring(1, query.length) + } + return query +} + +function trimAndVerifyQueryElement (element: string, name: string, url: string): string { + element = (element ?? '').trim() + if (element === '') { + throw new Error(`Illegal empty ${name} in URL query '${url}'`) + } + return element +} + +function escapeIPv6Address (address: string): string { + const startsWithSquareBracket = address.charAt(0) === '[' + const endsWithSquareBracket = address.charAt(address.length - 1) === ']' + + if (!startsWithSquareBracket && !endsWithSquareBracket) { + return `[${address}]` + } else if (startsWithSquareBracket && endsWithSquareBracket) { + return address + } else { + throw new Error(`Illegal IPv6 address ${address}`) + } +} + +function formatHost (host: string): string { + if (host === '' || host == null) { + throw new Error(`Illegal host ${host}`) + } + const isIPv6Address = host.includes(':') + return isIPv6Address ? escapeIPv6Address(host) : host +} + +function formatIPv4Address (address: string, port: number): string { + return `${address}:${port}` +} + +function formatIPv6Address (address: string, port: number): string { + const escapedAddress = escapeIPv6Address(address) + return `${escapedAddress}:${port}` +} + +function defaultPortForScheme (scheme: string | null): number { + if (scheme === 'http') { + return DEFAULT_HTTP_PORT + } else if (scheme === 'https') { + return DEFAULT_HTTPS_PORT + } else { + return DEFAULT_BOLT_PORT + } +} + +function uriJsParse (value: string): ParsedUri { + // JS version of Python partition function + function partition (s: string, delimiter: string): [string, string, string] { + const i = s.indexOf(delimiter) + if (i >= 0) return [s.substring(0, i), s[i], s.substring(i + 1)] + else return [s, '', ''] + } + + // JS version of Python rpartition function + function rpartition (s: string, delimiter: string): [string, string, string] { + const i = s.lastIndexOf(delimiter) + if (i >= 0) return [s.substring(0, i), s[i], s.substring(i + 1)] + else return ['', '', s] + } + + function between ( + s: string, + ldelimiter: string, + rdelimiter: string + ): [string, string] { + const lpartition = partition(s, ldelimiter) + const rpartition = partition(lpartition[2], rdelimiter) + return [rpartition[0], rpartition[2]] + } + + // Parse an authority string into an object + // with the following keys: + // - userInfo (optional, might contain both user name and password) + // - host + // - port (optional, included only as a string) + function parseAuthority (value: string): ParsedUri { + const parsed: ParsedUri = {} + let parts: [string, string, string] + + // Parse user info + parts = rpartition(value, '@') + if (parts[1] === '@') { + parsed.userInfo = decodeURIComponent(parts[0]) + value = parts[2] + } + + // Parse host and port + const [ipv6Host, rest] = between(value, '[', ']') + if (ipv6Host !== '') { + parsed.host = ipv6Host + parts = partition(rest, ':') + } else { + parts = partition(value, ':') + parsed.host = parts[0] + } + + if (parts[1] === ':') { + parsed.port = parts[2] + } + + return parsed + } + + let parsed: ParsedUri = {} + let parts: string[] + + // Parse scheme + parts = partition(value, ':') + if (parts[1] === ':') { + parsed.scheme = decodeURIComponent(parts[0]) + value = parts[2] + } + + // Parse fragment + parts = partition(value, '#') + if (parts[1] === '#') { + parsed.fragment = decodeURIComponent(parts[2]) + value = parts[0] + } + + // Parse query + parts = partition(value, '?') + if (parts[1] === '?') { + parsed.query = parts[2] + value = parts[0] + } + + // Parse authority and path + if (value.startsWith('//')) { + parts = partition(value.substr(2), '/') + parsed = { ...parsed, ...parseAuthority(parts[0]) } + parsed.path = parts[1] + parts[2] + } else { + parsed.path = value + } + + return parsed +} + +export { + parseDatabaseUrl, + defaultPortForScheme, + formatIPv4Address, + formatIPv6Address, + Url +} diff --git a/packages/neo4j-driver-deno/lib/core/internal/util.ts b/packages/neo4j-driver-deno/lib/core/internal/util.ts new file mode 100644 index 000000000..3a181fdae --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/internal/util.ts @@ -0,0 +1,238 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import Integer, { isInt } from '../integer.ts' +import { NumberOrInteger } from '../graph-types.ts' +import { EncryptionLevel } from '../types.ts' +import { stringify } from '../json.ts' + +const ENCRYPTION_ON: EncryptionLevel = 'ENCRYPTION_ON' +const ENCRYPTION_OFF: EncryptionLevel = 'ENCRYPTION_OFF' +/** + * Verifies if the object is null or empty + * @param obj The subject object + * @returns {boolean} True if it's empty object or null + */ +function isEmptyObjectOrNull (obj?: any): boolean { + if (obj === null) { + return true + } + + if (!isObject(obj)) { + return false + } + + for (const prop in obj) { + if (obj[prop] !== undefined) { + return false + } + } + + return true +} + +/** + * Verify if it's an object + * @param obj The subject + * @returns {boolean} True if it's an object + */ +function isObject (obj: any): boolean { + return typeof obj === 'object' && !Array.isArray(obj) && obj !== null +} + +/** + * Check and normalize given query and parameters. + * @param {string|{text: string, parameters: Object}} query the query to check. + * @param {Object} parameters + * @return {{validatedQuery: string|{text: string, parameters: Object}, params: Object}} the normalized query with parameters. + * @throws TypeError when either given query or parameters are invalid. + */ +function validateQueryAndParameters ( + query: string | String | { text: string, parameters?: any }, + parameters?: any, + opt?: { skipAsserts: boolean } +): { + validatedQuery: string + params: any + } { + let validatedQuery: string = '' + let params = parameters ?? {} + const skipAsserts: boolean = opt?.skipAsserts ?? false + + if (typeof query === 'string') { + validatedQuery = query + } else if (query instanceof String) { + validatedQuery = query.toString() + } else if (typeof query === 'object' && query.text != null) { + validatedQuery = query.text + params = query.parameters ?? {} + } + + if (!skipAsserts) { + assertCypherQuery(validatedQuery) + assertQueryParameters(params) + } + + return { validatedQuery, params } +} + +/** + * Assert it's a object + * @param {any} obj The subject + * @param {string} objName The object name + * @returns {object} The subject object + * @throws {TypeError} when the supplied param is not an object + */ +function assertObject (obj: any, objName: string): Object { + if (!isObject(obj)) { + throw new TypeError( + objName + ' expected to be an object but was: ' + stringify(obj) + ) + } + return obj +} + +/** + * Assert it's a string + * @param {any} obj The subject + * @param {string} objName The object name + * @returns {string} The subject string + * @throws {TypeError} when the supplied param is not a string + */ +function assertString (obj: any, objName: Object): string { + if (!isString(obj)) { + throw new TypeError( + stringify(objName) + ' expected to be string but was: ' + stringify(obj) + ) + } + return obj +} + +/** + * Assert it's a number + * @param {any} obj The subject + * @param {string} objName The object name + * @returns {number} The number + * @throws {TypeError} when the supplied param is not a number + */ +function assertNumber (obj: any, objName: string): number { + if (typeof obj !== 'number') { + throw new TypeError( + objName + ' expected to be a number but was: ' + stringify(obj) + ) + } + return obj +} + +/** + * Assert it's a number or integer + * @param {any} obj The subject + * @param {string} objName The object name + * @returns {number|Integer} The subject object + * @throws {TypeError} when the supplied param is not a number or integer + */ +function assertNumberOrInteger (obj: any, objName: string): NumberOrInteger { + if (typeof obj !== 'number' && typeof obj !== 'bigint' && !isInt(obj)) { + throw new TypeError( + objName + + ' expected to be either a number or an Integer object but was: ' + + stringify(obj) + ) + } + return obj +} + +/** + * Assert it's a valid datae + * @param {any} obj The subject + * @param {string} objName The object name + * @returns {Date} The valida date + * @throws {TypeError} when the supplied param is not a valid date + */ +function assertValidDate (obj: any, objName: string): Date { + if (Object.prototype.toString.call(obj) !== '[object Date]') { + throw new TypeError( + objName + + ' expected to be a standard JavaScript Date but was: ' + + stringify(obj) + ) + } + if (Number.isNaN(obj.getTime())) { + throw new TypeError( + objName + + ' expected to be valid JavaScript Date but its time was NaN: ' + + stringify(obj) + ) + } + return obj +} + +/** + * Validates a cypher query string + * @param {any} obj The query + * @returns {void} + * @throws {TypeError} if the query is not valid + */ +function assertCypherQuery (obj: any): void { + assertString(obj, 'Cypher query') + if (obj.trim().length === 0) { + throw new TypeError('Cypher query is expected to be a non-empty string.') + } +} + +/** + * Validates if the query parameters is an object + * @param {any} obj The parameters + * @returns {void} + * @throws {TypeError} if the parameters is not valid + */ +function assertQueryParameters (obj: any): void { + if (!isObject(obj)) { + // objects created with `Object.create(null)` do not have a constructor property + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + const constructor = obj.constructor != null ? ' ' + obj.constructor.name : '' + throw new TypeError( + `Query parameters are expected to either be undefined/null or an object, given:${constructor} ${JSON.stringify(obj)}` + ) + } +} + +/** + * Verify if the supplied object is a string + * + * @param str The string + * @returns {boolean} True if the supplied object is an string + */ +function isString (str: any): str is string { + return Object.prototype.toString.call(str) === '[object String]' +} + +export { + isEmptyObjectOrNull, + isObject, + isString, + assertObject, + assertString, + assertNumber, + assertNumberOrInteger, + assertValidDate, + validateQueryAndParameters, + ENCRYPTION_ON, + ENCRYPTION_OFF +} diff --git a/packages/neo4j-driver-deno/lib/core/json.ts b/packages/neo4j-driver-deno/lib/core/json.ts new file mode 100644 index 000000000..9800682b8 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/json.ts @@ -0,0 +1,41 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { isBrokenObject, getBrokenObjectReason } from './internal/object-util.ts' + +/** + * Custom version on JSON.stringify that can handle values that normally don't support serialization, such as BigInt. + * @private + * @param val A JavaScript value, usually an object or array, to be converted. + * @returns A JSON string representing the given value. + */ +export function stringify (val: any): string { + return JSON.stringify(val, (_, value) => { + if (isBrokenObject(value)) { + return { + __isBrokenObject__: true, + __reason__: getBrokenObjectReason(value) + } + } + if (typeof value === 'bigint') { + return `${value}n` + } + return value + }) +} diff --git a/packages/neo4j-driver-deno/lib/core/record.ts b/packages/neo4j-driver-deno/lib/core/record.ts new file mode 100644 index 000000000..759cbf821 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/record.ts @@ -0,0 +1,245 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { newError } from './error.ts' + +type Dict = { + [K in Key]: Value +} + +type Visitor< + Entries extends Dict = Dict, + Key extends keyof Entries = keyof Entries +> = MapVisitor + +type MapVisitor< + ReturnType, + Entries extends Dict = Dict, + Key extends keyof Entries = keyof Entries +> = (value: Entries[Key], key: Key, record: Record) => ReturnType + +function generateFieldLookup< + Entries extends Dict = Dict, + Key extends keyof Entries = keyof Entries, + FieldLookup extends Dict = Dict +> (keys: Key[]): FieldLookup { + const lookup: Dict = {} + keys.forEach((name, idx) => { + lookup[name as string] = idx + }) + return lookup as FieldLookup +} + +/** + * Records make up the contents of the {@link Result}, and is how you access + * the output of a query. A simple query might yield a result stream + * with a single record, for instance: + * + * MATCH (u:User) RETURN u.name, u.age + * + * This returns a stream of records with two fields, named `u.name` and `u.age`, + * each record represents one user found by the query above. You can access + * the values of each field either by name: + * + * record.get("u.name") + * + * Or by it's position: + * + * record.get(0) + * + * @access public + */ +class Record< + Entries extends Dict = Dict, + Key extends keyof Entries = keyof Entries, + FieldLookup extends Dict = Dict +> { + keys: Key[] + length: number + private readonly _fields: any[] + private readonly _fieldLookup: FieldLookup + + /** + * Create a new record object. + * @constructor + * @protected + * @param {string[]} keys An array of field keys, in the order the fields appear in the record + * @param {Array} fields An array of field values + * @param {Object} fieldLookup An object of fieldName -> value index, used to map + * field names to values. If this is null, one will be + * generated. + */ + constructor (keys: Key[], fields: any[], fieldLookup?: FieldLookup) { + /** + * Field keys, in the order the fields appear in the record. + * @type {string[]} + */ + this.keys = keys + /** + * Number of fields + * @type {Number} + */ + this.length = keys.length + this._fields = fields + this._fieldLookup = fieldLookup ?? generateFieldLookup(keys) + } + + /** + * Run the given function for each field in this record. The function + * will get three arguments - the value, the key and this record, in that + * order. + * + * @param {function(value: Object, key: string, record: Record)} visitor the function to apply to each field. + * @returns {void} Nothing + */ + forEach (visitor: Visitor): void { + for (const [key, value] of this.entries()) { + visitor(value, key as Key, this) + } + } + + /** + * Run the given function for each field in this record. The function + * will get three arguments - the value, the key and this record, in that + * order. + * + * @param {function(value: Object, key: string, record: Record)} visitor the function to apply on each field + * and return a value that is saved to the returned Array. + * + * @returns {Array} + */ + map(visitor: MapVisitor): Value[] { + const resultArray = [] + + for (const [key, value] of this.entries()) { + resultArray.push(visitor(value, key as Key, this)) + } + + return resultArray + } + + /** + * Iterate over results. Each iteration will yield an array + * of exactly two items - the key, and the value (in order). + * + * @generator + * @returns {IterableIterator} + */ + * entries (): IterableIterator<[string, any]> { + for (let i = 0; i < this.keys.length; i++) { + yield [this.keys[i] as string, this._fields[i]] + } + } + + /** + * Iterate over values. + * + * @generator + * @returns {IterableIterator} + */ + * values (): IterableIterator { + for (let i = 0; i < this.keys.length; i++) { + yield this._fields[i] + } + } + + /** + * Iterate over values. Delegates to {@link Record#values} + * + * @generator + * @returns {IterableIterator} + */ + * [Symbol.iterator] (): IterableIterator { + for (let i = 0; i < this.keys.length; i++) { + yield this._fields[i] + } + } + + /** + * Generates an object out of the current Record + * + * @returns {Object} + */ + toObject (): Entries { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const obj: Entries = {} as Entries + + for (const [key, value] of this.entries()) { + obj[key as Key] = value + } + + return obj + } + + get(key: K): Entries[K] + get (key: keyof FieldLookup | number): any + + /** + * Get a value from this record, either by index or by field key. + * + * @param {string|Number} key Field key, or the index of the field. + * @returns {*} + */ + get (key: string | number): any { + let index + if (!(typeof key === 'number')) { + index = this._fieldLookup[key] + if (index === undefined) { + throw newError( + "This record has no field with key '" + + key + + "', available key are: [" + + this.keys.toString() + + '].' + ) + } + } else { + index = key + } + + if (index > this._fields.length - 1 || index < 0) { + throw newError( + "This record has no field with index '" + + index.toString() + + "'. Remember that indexes start at `0`, " + + 'and make sure your query returns records in the shape you meant it to.' + ) + } + + return this._fields[index] + } + + /** + * Check if a value from this record, either by index or by field key, exists. + * + * @param {string|Number} key Field key, or the index of the field. + * @returns {boolean} + */ + has (key: Key | string | number): boolean { + // if key is a number, we check if it is in the _fields array + if (typeof key === 'number') { + return key >= 0 && key < this._fields.length + } + + // if it's not a number, we check _fieldLookup dictionary directly + return this._fieldLookup[key as string] !== undefined + } +} + +export default Record diff --git a/packages/neo4j-driver-deno/lib/core/result-summary.ts b/packages/neo4j-driver-deno/lib/core/result-summary.ts new file mode 100644 index 000000000..771075922 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/result-summary.ts @@ -0,0 +1,549 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 Integer, { int } from './integer.ts' +import { NumberOrInteger } from './graph-types.ts' + +/** + * A ResultSummary instance contains structured metadata for a {@link Result}. + * @access public + */ +class ResultSummary { + query: { text: string, parameters: { [key: string]: any } } + queryType: string + counters: QueryStatistics + updateStatistics: QueryStatistics + plan: Plan | false + profile: ProfiledPlan | false + notifications: Notification[] + server: ServerInfo + resultConsumedAfter: T + resultAvailableAfter: T + database: { name: string | undefined | null } + /** + * @constructor + * @param {string} query - The query this summary is for + * @param {Object} parameters - Parameters for the query + * @param {Object} metadata - Query metadata + * @param {number|undefined} protocolVersion - Bolt Protocol Version + */ + constructor ( + query: string, + parameters: { [key: string]: any }, + metadata: any, + protocolVersion?: number + ) { + /** + * The query and parameters this summary is for. + * @type {{text: string, parameters: Object}} + * @public + */ + this.query = { text: query, parameters } + + /** + * The type of query executed. Can be "r" for read-only query, "rw" for read-write query, + * "w" for write-only query and "s" for schema-write query. + * String constants are available in {@link queryType} object. + * @type {string} + * @public + */ + this.queryType = metadata.type + + /** + * Counters for operations the query triggered. + * @type {QueryStatistics} + * @public + */ + this.counters = new QueryStatistics(metadata.stats ?? {}) + // for backwards compatibility, remove in future version + /** + * Use {@link ResultSummary.counters} instead. + * @type {QueryStatistics} + * @deprecated + */ + this.updateStatistics = this.counters + + /** + * This describes how the database will execute the query. + * Query plan for the executed query if available, otherwise undefined. + * Will only be populated for queries that start with "EXPLAIN". + * @type {Plan|false} + * @public + */ + this.plan = + metadata.plan != null || metadata.profile != null + ? new Plan(metadata.plan ?? metadata.profile) + : false + + /** + * This describes how the database did execute your query. This will contain detailed information about what + * each step of the plan did. Profiled query plan for the executed query if available, otherwise undefined. + * Will only be populated for queries that start with "PROFILE". + * @type {ProfiledPlan} + * @public + */ + this.profile = metadata.profile != null ? new ProfiledPlan(metadata.profile) : false + + /** + * An array of notifications that might arise when executing the query. Notifications can be warnings about + * problematic queries or other valuable information that can be presented in a client. Unlike failures + * or errors, notifications do not affect the execution of a query. + * @type {Array} + * @public + */ + this.notifications = this._buildNotifications(metadata.notifications) + + /** + * The basic information of the server where the result is obtained from. + * @type {ServerInfo} + * @public + */ + this.server = new ServerInfo(metadata.server, protocolVersion) + + /** + * The time it took the server to consume the result. + * @type {number} + * @public + */ + this.resultConsumedAfter = metadata.result_consumed_after + + /** + * The time it took the server to make the result available for consumption in milliseconds. + * @type {number} + * @public + */ + this.resultAvailableAfter = metadata.result_available_after + + /** + * The database name where this summary is obtained from. + * @type {{name: string}} + * @public + */ + this.database = { name: metadata.db ?? null } + } + + _buildNotifications (notifications: any[]): Notification[] { + if (notifications == null) { + return [] + } + return notifications.map(function (n: any): Notification { + return new Notification(n) + }) + } + + /** + * Check if the result summary has a plan + * @return {boolean} + */ + hasPlan (): boolean { + return this.plan instanceof Plan + } + + /** + * Check if the result summary has a profile + * @return {boolean} + */ + hasProfile (): boolean { + return this.profile instanceof ProfiledPlan + } +} + +/** + * Class for execution plan received by prepending Cypher with EXPLAIN. + * @access public + */ +class Plan { + operatorType: string + identifiers: string[] + arguments: { [key: string]: string } + children: Plan[] + + /** + * Create a Plan instance + * @constructor + * @param {Object} plan - Object with plan data + */ + constructor (plan: any) { + this.operatorType = plan.operatorType + this.identifiers = plan.identifiers + this.arguments = plan.args + this.children = plan.children != null + ? plan.children.map((child: any) => new Plan(child)) + : [] + } +} + +/** + * Class for execution plan received by prepending Cypher with PROFILE. + * @access public + */ +class ProfiledPlan { + operatorType: string + identifiers: string[] + arguments: { [key: string]: string } + dbHits: number + rows: number + pageCacheMisses: number + pageCacheHits: number + pageCacheHitRatio: number + time: number + children: ProfiledPlan[] + + /** + * Create a ProfiledPlan instance + * @constructor + * @param {Object} profile - Object with profile data + */ + constructor (profile: any) { + this.operatorType = profile.operatorType + this.identifiers = profile.identifiers + this.arguments = profile.args + this.dbHits = valueOrDefault('dbHits', profile) + this.rows = valueOrDefault('rows', profile) + this.pageCacheMisses = valueOrDefault('pageCacheMisses', profile) + this.pageCacheHits = valueOrDefault('pageCacheHits', profile) + this.pageCacheHitRatio = valueOrDefault('pageCacheHitRatio', profile) + this.time = valueOrDefault('time', profile) + this.children = profile.children != null + ? profile.children.map((child: any) => new ProfiledPlan(child)) + : [] + } + + hasPageCacheStats (): boolean { + return ( + this.pageCacheMisses > 0 || + this.pageCacheHits > 0 || + this.pageCacheHitRatio > 0 + ) + } +} + +/** + * Stats Query statistics dictionary for a {@link QueryStatistics} + * @public + */ +class Stats { + nodesCreated: number + nodesDeleted: number + relationshipsCreated: number + relationshipsDeleted: number + propertiesSet: number + labelsAdded: number + labelsRemoved: number + indexesAdded: number + indexesRemoved: number + constraintsAdded: number + constraintsRemoved: number; + [key: string]: number + + /** + * @constructor + * @private + */ + constructor () { + /** + * nodes created + * @type {number} + * @public + */ + this.nodesCreated = 0 + /** + * nodes deleted + * @type {number} + * @public + */ + this.nodesDeleted = 0 + /** + * relationships created + * @type {number} + * @public + */ + this.relationshipsCreated = 0 + /** + * relationships deleted + * @type {number} + * @public + */ + this.relationshipsDeleted = 0 + /** + * properties set + * @type {number} + * @public + */ + this.propertiesSet = 0 + /** + * labels added + * @type {number} + * @public + */ + this.labelsAdded = 0 + /** + * labels removed + * @type {number} + * @public + */ + this.labelsRemoved = 0 + /** + * indexes added + * @type {number} + * @public + */ + this.indexesAdded = 0 + /** + * indexes removed + * @type {number} + * @public + */ + this.indexesRemoved = 0 + /** + * constraints added + * @type {number} + * @public + */ + this.constraintsAdded = 0 + /** + * constraints removed + * @type {number} + * @public + */ + this.constraintsRemoved = 0 + } +} + +/** + * Get statistical information for a {@link Result}. + * @access public + */ +class QueryStatistics { + private _stats: Stats + private _systemUpdates: number + private _containsSystemUpdates?: boolean + private _containsUpdates?: boolean + + /** + * Structurize the statistics + * @constructor + * @param {Object} statistics - Result statistics + */ + constructor (statistics: any) { + this._stats = { + nodesCreated: 0, + nodesDeleted: 0, + relationshipsCreated: 0, + relationshipsDeleted: 0, + propertiesSet: 0, + labelsAdded: 0, + labelsRemoved: 0, + indexesAdded: 0, + indexesRemoved: 0, + constraintsAdded: 0, + constraintsRemoved: 0 + } + this._systemUpdates = 0 + Object.keys(statistics).forEach(index => { + // To camelCase + const camelCaseIndex = index.replace(/(-\w)/g, m => m[1].toUpperCase()) + if (camelCaseIndex in this._stats) { + this._stats[camelCaseIndex] = intValue(statistics[index]) + } else if (camelCaseIndex === 'systemUpdates') { + this._systemUpdates = intValue(statistics[index]) + } else if (camelCaseIndex === 'containsSystemUpdates') { + this._containsSystemUpdates = statistics[index] + } else if (camelCaseIndex === 'containsUpdates') { + this._containsUpdates = statistics[index] + } + }) + + this._stats = Object.freeze(this._stats) + } + + /** + * Did the database get updated? + * @return {boolean} + */ + containsUpdates (): boolean { + return this._containsUpdates !== undefined + ? this._containsUpdates + : ( + Object.keys(this._stats).reduce((last, current) => { + return last + this._stats[current] + }, 0) > 0 + ) + } + + /** + * Returns the query statistics updates in a dictionary. + * @returns {Stats} + */ + updates (): Stats { + return this._stats + } + + /** + * Return true if the system database get updated, otherwise false + * @returns {boolean} - If the system database get updated or not. + */ + containsSystemUpdates (): boolean { + return this._containsSystemUpdates !== undefined + ? this._containsSystemUpdates + : this._systemUpdates > 0 + } + + /** + * @returns {number} - Number of system updates + */ + systemUpdates (): number { + return this._systemUpdates + } +} + +interface NotificationPosition { + offset?: number + line?: number + column?: number +} + +/** + * Class for Cypher notifications + * @access public + */ +class Notification { + code: string + title: string + description: string + severity: string + position: NotificationPosition | {} + + /** + * Create a Notification instance + * @constructor + * @param {Object} notification - Object with notification data + */ + constructor (notification: any) { + this.code = notification.code + this.title = notification.title + this.description = notification.description + this.severity = notification.severity + this.position = Notification._constructPosition(notification.position) + } + + static _constructPosition (pos: NotificationPosition): NotificationPosition { + if (pos == null) { + return {} + } + /* eslint-disable @typescript-eslint/no-non-null-assertion */ + return { + offset: intValue(pos.offset!), + line: intValue(pos.line!), + column: intValue(pos.column!) + } + /* eslint-enable @typescript-eslint/no-non-null-assertion */ + } +} + +/** + * Class for exposing server info from a result. + * @access public + */ +class ServerInfo { + address?: string + protocolVersion?: number + agent?: string + + /** + * Create a ServerInfo instance + * @constructor + * @param {Object} serverMeta - Object with serverMeta data + * @param {Object} connectionInfo - Bolt connection info + * @param {number} protocolVersion - Bolt Protocol Version + */ + constructor (serverMeta?: any, protocolVersion?: number) { + if (serverMeta != null) { + /** + * The server adress + * @type {string} + * @public + */ + this.address = serverMeta.address + + /** + * The server user agent string + * @type {string} + * @public + */ + this.agent = serverMeta.version + } + + /** + * The protocol version used by the connection + * @type {number} + * @public + */ + this.protocolVersion = protocolVersion + } +} + +function intValue (value: NumberOrInteger): number { + if (value instanceof Integer) { + return value.toInt() + } else if (typeof value === 'bigint') { + return int(value).toInt() + } else { + return value + } +} + +function valueOrDefault ( + key: string, + values: { [key: string]: NumberOrInteger } | false, + defaultValue: number = 0 +): number { + if (values !== false && key in values) { + const value = values[key] + return intValue(value) + } else { + return defaultValue + } +} + +/** + * The constants for query types + * @type {{SCHEMA_WRITE: string, WRITE_ONLY: string, READ_ONLY: string, READ_WRITE: string}} + */ +const queryType = { + READ_ONLY: 'r', + READ_WRITE: 'rw', + WRITE_ONLY: 'w', + SCHEMA_WRITE: 's' +} + +export { + queryType, + ServerInfo, + Notification, + Plan, + ProfiledPlan, + QueryStatistics, + Stats +} +export type { + NotificationPosition +} + +export default ResultSummary diff --git a/packages/neo4j-driver-deno/lib/core/result.ts b/packages/neo4j-driver-deno/lib/core/result.ts new file mode 100644 index 000000000..e2e380aaa --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/result.ts @@ -0,0 +1,656 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ + +/* eslint-disable @typescript-eslint/promise-function-async */ + +import ResultSummary from './result-summary.ts' +import Record from './record.ts' +import { Query, PeekableAsyncIterator } from './types.ts' +import { observer, util, connectionHolder } from './internal/index.ts' +import { newError, PROTOCOL_ERROR } from './error.ts' +import { NumberOrInteger } from './graph-types.ts' + +const { EMPTY_CONNECTION_HOLDER } = connectionHolder + +/** + * @private + * @param {Error} error The error + * @returns {void} + */ +const DEFAULT_ON_ERROR = (error: Error): void => { + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands, @typescript-eslint/no-base-to-string + console.log('Uncaught error when processing result: ' + error) +} + +/** + * @private + * @param {ResultSummary} summary + * @returns {void} + */ +const DEFAULT_ON_COMPLETED = (summary: ResultSummary): void => {} + +/** + * @private + * @param {string[]} keys List of keys of the record in the result + * @return {void} + */ +const DEFAULT_ON_KEYS = (keys: string[]): void => {} + +/** + * The query result is the combination of the {@link ResultSummary} and + * the array {@link Record[]} produced by the query + */ +interface QueryResult { + records: Record[] + summary: ResultSummary +} + +/** + * Interface to observe updates on the Result which is being produced. + * + */ +interface ResultObserver { + /** + * Receive the keys present on the record whenever this information is available + * + * @param {string[]} keys The keys present on the {@link Record} + */ + onKeys?: (keys: string[]) => void + + /** + * Receive the each record present on the {@link @Result} + * @param {Record} record The {@link Record} produced + */ + onNext?: (record: Record) => void + + /** + * Called when the result is fully received + * @param {ResultSummary} summary The result summary + */ + onCompleted?: (summary: ResultSummary) => void + + /** + * Called when some error occurs during the result proccess or query execution + * @param {Error} error The error ocurred + */ + onError?: (error: Error) => void +} + +/** + * Defines a ResultObserver interface which can be used to enqueue records and dequeue + * them until the result is fully received. + * @access private + */ +interface QueuedResultObserver extends ResultObserver { + dequeue: () => Promise> + dequeueUntilDone: () => Promise> + head: () => Promise> + size: number +} + +/** + * A stream of {@link Record} representing the result of a query. + * Can be consumed eagerly as {@link Promise} resolved with array of records and {@link ResultSummary} + * summary, or rejected with error that contains {@link string} code and {@link string} message. + * Alternatively can be consumed lazily using {@link Result#subscribe} function. + * @access public + */ +class Result implements Promise { + private readonly _stack: string | null + private readonly _streamObserverPromise: Promise + private _p: Promise | null + private readonly _query: Query + private readonly _parameters: any + private readonly _connectionHolder: connectionHolder.ConnectionHolder + private _keys: string[] | null + private _summary: ResultSummary | null + private _error: Error | null + private readonly _watermarks: { high: number, low: number } + + /** + * Inject the observer to be used. + * @constructor + * @access private + * @param {Promise} streamObserverPromise + * @param {mixed} query - Cypher query to execute + * @param {Object} parameters - Map with parameters to use in query + * @param {ConnectionHolder} connectionHolder - to be notified when result is either fully consumed or error happened. + */ + constructor ( + streamObserverPromise: Promise, + query: Query, + parameters?: any, + connectionHolder?: connectionHolder.ConnectionHolder, + watermarks: { high: number, low: number } = { high: Number.MAX_VALUE, low: Number.MAX_VALUE } + ) { + this._stack = captureStacktrace() + this._streamObserverPromise = streamObserverPromise + this._p = null + this._query = query + this._parameters = parameters ?? {} + this._connectionHolder = connectionHolder ?? EMPTY_CONNECTION_HOLDER + this._keys = null + this._summary = null + this._error = null + this._watermarks = watermarks + } + + /** + * Returns a promise for the field keys. + * + * *Should not be combined with {@link Result#subscribe} function.* + * + * @public + * @returns {Promise} - Field keys, in the order they will appear in records. + } + */ + keys (): Promise { + if (this._keys !== null) { + return Promise.resolve(this._keys) + } else if (this._error !== null) { + return Promise.reject(this._error) + } + return new Promise((resolve, reject) => { + this._streamObserverPromise + .then(observer => + observer.subscribe(this._decorateObserver({ + onKeys: keys => resolve(keys), + onError: err => reject(err) + })) + ) + .catch(reject) + }) + } + + /** + * Returns a promise for the result summary. + * + * *Should not be combined with {@link Result#subscribe} function.* + * + * @public + * @returns {Promise} - Result summary. + * + */ + summary (): Promise { + if (this._summary !== null) { + return Promise.resolve(this._summary) + } else if (this._error !== null) { + return Promise.reject(this._error) + } + return new Promise((resolve, reject) => { + this._streamObserverPromise + .then(o => { + o.cancel() + o.subscribe(this._decorateObserver({ + onCompleted: summary => resolve(summary), + onError: err => reject(err) + })) + }) + .catch(reject) + }) + } + + /** + * Create and return new Promise + * + * @private + * @return {Promise} new Promise. + */ + private _getOrCreatePromise (): Promise { + if (this._p == null) { + this._p = new Promise((resolve, reject) => { + const records: Record[] = [] + const observer = { + onNext: (record: Record) => { + records.push(record) + }, + onCompleted: (summary: ResultSummary) => { + resolve({ records: records, summary: summary }) + }, + onError: (error: Error) => { + reject(error) + } + } + this.subscribe(observer) + }) + } + + return this._p + } + + /** + * Provides a async iterator over the records in the result. + * + * *Should not be combined with {@link Result#subscribe} or ${@link Result#then} functions.* + * + * @public + * @returns {PeekableAsyncIterator} The async iterator for the Results + */ + [Symbol.asyncIterator] (): PeekableAsyncIterator { + if (!this.isOpen()) { + const error = newError('Result is already consumed') + return { + next: () => Promise.reject(error), + peek: () => Promise.reject(error) + } + } + const state: { + paused: boolean + firstRun: boolean + finished: boolean + queuedObserver?: QueuedResultObserver + streaming?: observer.ResultStreamObserver + summary?: ResultSummary + } = { paused: true, firstRun: true, finished: false } + + const controlFlow = (): void => { + if (state.streaming == null) { + return + } + + const size = state.queuedObserver?.size ?? 0 + const queueSizeIsOverHighOrEqualWatermark = size >= this._watermarks.high + const queueSizeIsBellowOrEqualLowWatermark = size <= this._watermarks.low + + if (queueSizeIsOverHighOrEqualWatermark && !state.paused) { + state.paused = true + state.streaming.pause() + } else if ((queueSizeIsBellowOrEqualLowWatermark && state.paused) || (state.firstRun && !queueSizeIsOverHighOrEqualWatermark)) { + state.firstRun = false + state.paused = false + state.streaming.resume() + } + } + + const initializeObserver = async (): Promise => { + if (state.queuedObserver === undefined) { + state.queuedObserver = this._createQueuedResultObserver(controlFlow) + state.streaming = await this._subscribe(state.queuedObserver, true).catch(() => undefined) + controlFlow() + } + return state.queuedObserver + } + + const assertSummary = (summary: ResultSummary | undefined): summary is ResultSummary => { + if (summary === undefined) { + throw newError('InvalidState: Result stream finished without Summary', PROTOCOL_ERROR) + } + return true + } + + return { + next: async () => { + if (state.finished) { + if (assertSummary(state.summary)) { + return { done: true, value: state.summary } + } + } + const queuedObserver = await initializeObserver() + const next = await queuedObserver.dequeue() + if (next.done === true) { + state.finished = next.done + state.summary = next.value + } + return next + }, + return: async (value?: ResultSummary) => { + if (state.finished) { + if (assertSummary(state.summary)) { + return { done: true, value: value ?? state.summary } + } + } + state.streaming?.cancel() + const queuedObserver = await initializeObserver() + const last = await queuedObserver.dequeueUntilDone() + state.finished = true + last.value = value ?? last.value + state.summary = last.value as ResultSummary + return last + }, + peek: async () => { + if (state.finished) { + if (assertSummary(state.summary)) { + return { done: true, value: state.summary } + } + } + const queuedObserver = await initializeObserver() + return await queuedObserver.head() + } + } + } + + /** + * Waits for all results and calls the passed in function with the results. + * + * *Should not be combined with {@link Result#subscribe} function.* + * + * @param {function(result: {records:Array, summary: ResultSummary})} onFulfilled - function to be called + * when finished. + * @param {function(error: {message:string, code:string})} onRejected - function to be called upon errors. + * @return {Promise} promise. + */ + then( + onFulfilled?: + | ((value: QueryResult) => TResult1 | PromiseLike) + | null, + onRejected?: ((reason: any) => TResult2 | PromiseLike) | null + ): Promise { + return this._getOrCreatePromise().then(onFulfilled, onRejected) + } + + /** + * Catch errors when using promises. + * + * *Should not be combined with {@link Result#subscribe} function.* + * + * @param {function(error: Neo4jError)} onRejected - Function to be called upon errors. + * @return {Promise} promise. + */ + catch ( + onRejected?: ((reason: any) => TResult | PromiseLike) | null + ): Promise { + return this._getOrCreatePromise().catch(onRejected) + } + + /** + * Called when finally the result is done + * + * *Should not be combined with {@link Result#subscribe} function.* + * @param {function()|null} onfinally - function when the promise finished + * @return {Promise} promise. + */ + [Symbol.toStringTag]: string + finally (onfinally?: (() => void) | null): Promise { + return this._getOrCreatePromise().finally(onfinally) + } + + /** + * Stream records to observer as they come in, this is a more efficient method + * of handling the results, and allows you to handle arbitrarily large results. + * + * @param {Object} observer - Observer object + * @param {function(keys: string[])} observer.onKeys - handle stream head, the field keys. + * @param {function(record: Record)} observer.onNext - handle records, one by one. + * @param {function(summary: ResultSummary)} observer.onCompleted - handle stream tail, the result summary. + * @param {function(error: {message:string, code:string})} observer.onError - handle errors. + * @return {void} + */ + subscribe (observer: ResultObserver): void { + this._subscribe(observer) + .catch(() => {}) + } + + /** + * Check if this result is active, i.e., neither a summary nor an error has been received by the result. + * @return {boolean} `true` when neither a summary or nor an error has been received by the result. + */ + isOpen (): boolean { + return this._summary === null && this._error === null + } + + /** + * Stream records to observer as they come in, this is a more efficient method + * of handling the results, and allows you to handle arbitrarily large results. + * + * @access private + * @param {ResultObserver} observer The observer to send records to. + * @param {boolean} paused The flag to indicate if the stream should be started paused + * @returns {Promise} The result stream observer. + */ + _subscribe (observer: ResultObserver, paused: boolean = false): Promise { + const _observer = this._decorateObserver(observer) + + return this._streamObserverPromise + .then(o => { + if (paused) { + o.pause() + } + o.subscribe(_observer) + return o + }) + .catch(error => { + if (_observer.onError != null) { + _observer.onError(error) + } + return Promise.reject(error) + }) + } + + /** + * Decorates the ResultObserver with the necessary methods. + * + * @access private + * @param {ResultObserver} observer The ResultObserver to decorate. + * @returns The decorated result observer + */ + _decorateObserver (observer: ResultObserver): ResultObserver { + const onCompletedOriginal = observer.onCompleted ?? DEFAULT_ON_COMPLETED + const onErrorOriginal = observer.onError ?? DEFAULT_ON_ERROR + const onKeysOriginal = observer.onKeys ?? DEFAULT_ON_KEYS + + const onCompletedWrapper = (metadata: any): void => { + this._releaseConnectionAndGetSummary(metadata).then(summary => { + if (this._summary !== null) { + return onCompletedOriginal.call(observer, this._summary) + } + this._summary = summary + return onCompletedOriginal.call(observer, summary) + }).catch(onErrorOriginal) + } + + const onErrorWrapper = (error: Error): void => { + // notify connection holder that the used connection is not needed any more because error happened + // and result can't bee consumed any further; call the original onError callback after that + this._connectionHolder.releaseConnection().then(() => { + replaceStacktrace(error, this._stack) + this._error = error + onErrorOriginal.call(observer, error) + }).catch(onErrorOriginal) + } + + const onKeysWrapper = (keys: string[]): void => { + this._keys = keys + return onKeysOriginal.call(observer, keys) + } + + return { + onNext: (observer.onNext != null) ? observer.onNext.bind(observer) : undefined, + onKeys: onKeysWrapper, + onCompleted: onCompletedWrapper, + onError: onErrorWrapper + } + } + + /** + * Signals the stream observer that the future records should be discarded on the server. + * + * @protected + * @since 4.0.0 + * @returns {void} + */ + _cancel (): void { + if (this._summary === null && this._error === null) { + this._streamObserverPromise.then(o => o.cancel()) + .catch(() => {}) + } + } + + /** + * @access private + * @param metadata + * @returns + */ + private _releaseConnectionAndGetSummary (metadata: any): Promise { + const { + validatedQuery: query, + params: parameters + } = util.validateQueryAndParameters(this._query, this._parameters, { + skipAsserts: true + }) + const connectionHolder = this._connectionHolder + + return connectionHolder + .getConnection() + .then( + // onFulfilled: + connection => + connectionHolder + .releaseConnection() + .then(() => + connection?.protocol()?.version + ), + // onRejected: + _ => undefined + ) + .then( + protocolVersion => + new ResultSummary(query, parameters, metadata, protocolVersion) + ) + } + + /** + * @access private + */ + private _createQueuedResultObserver (onQueueSizeChanged: () => void): QueuedResultObserver { + interface ResolvablePromise { + promise: Promise + resolve: (arg: T) => any | undefined + reject: (arg: Error) => any | undefined + } + + function createResolvablePromise (): ResolvablePromise> { + const resolvablePromise: any = {} + resolvablePromise.promise = new Promise((resolve, reject) => { + resolvablePromise.resolve = resolve + resolvablePromise.reject = reject + }) + return resolvablePromise + } + + type QueuedResultElementOrError = IteratorResult | Error + + function isError (elementOrError: QueuedResultElementOrError): elementOrError is Error { + return elementOrError instanceof Error + } + + async function dequeue (): Promise> { + if (buffer.length > 0) { + const element = buffer.shift() ?? newError('Unexpected empty buffer', PROTOCOL_ERROR) + onQueueSizeChanged() + if (isError(element)) { + throw element + } + return element + } + promiseHolder.resolvable = createResolvablePromise() + return await promiseHolder.resolvable.promise + } + + const buffer: QueuedResultElementOrError[] = [] + const promiseHolder: { + resolvable: ResolvablePromise> | null + } = { resolvable: null } + + const observer = { + onNext: (record: Record) => { + observer._push({ done: false, value: record }) + }, + onCompleted: (summary: ResultSummary) => { + observer._push({ done: true, value: summary }) + }, + onError: (error: Error) => { + observer._push(error) + }, + _push (element: QueuedResultElementOrError) { + if (promiseHolder.resolvable !== null) { + const resolvable = promiseHolder.resolvable + promiseHolder.resolvable = null + if (isError(element)) { + resolvable.reject(element) + } else { + resolvable.resolve(element) + } + } else { + buffer.push(element) + onQueueSizeChanged() + } + }, + dequeue: dequeue, + dequeueUntilDone: async () => { + while (true) { + const element = await dequeue() + if (element.done === true) { + return element + } + } + }, + head: async () => { + if (buffer.length > 0) { + const element = buffer[0] + if (isError(element)) { + throw element + } + return element + } + promiseHolder.resolvable = createResolvablePromise() + try { + const element = await promiseHolder.resolvable.promise + buffer.unshift(element) + return element + } catch (error) { + buffer.unshift(error) + throw error + } finally { + onQueueSizeChanged() + } + }, + get size (): number { + return buffer.length + } + } + + return observer + } +} + +function captureStacktrace (): string | null { + const error = new Error('') + if (error.stack != null) { + return error.stack.replace(/^Error(\n\r)*/, '') // we don't need the 'Error\n' part, if only it exists + } + return null +} + +/** + * @private + * @param {Error} error The error + * @param {string| null} newStack The newStack + * @returns {void} + */ +function replaceStacktrace (error: Error, newStack?: string | null): void { + if (newStack != null) { + // Error.prototype.toString() concatenates error.name and error.message nicely + // then we add the rest of the stack trace + // eslint-disable-next-line @typescript-eslint/no-base-to-string + error.stack = error.toString() + '\n' + newStack + } +} + +export default Result +export type { QueryResult, ResultObserver } diff --git a/packages/neo4j-driver-deno/lib/core/session.ts b/packages/neo4j-driver-deno/lib/core/session.ts new file mode 100644 index 000000000..6a419e201 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/session.ts @@ -0,0 +1,594 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ + +/* eslint-disable @typescript-eslint/promise-function-async */ + +import { FailedObserver } from './internal/observers.ts' +import { validateQueryAndParameters } from './internal/util.ts' +import { FETCH_ALL, ACCESS_MODE_READ, ACCESS_MODE_WRITE } from './internal/constants.ts' +import { newError } from './error.ts' +import Result from './result.ts' +import Transaction from './transaction.ts' +import { ConnectionHolder } from './internal/connection-holder.ts' +import { TransactionExecutor } from './internal/transaction-executor.ts' +import { Bookmarks } from './internal/bookmarks.ts' +import { TxConfig } from './internal/tx-config.ts' +import ConnectionProvider from './connection-provider.ts' +import { Query, SessionMode } from './types.ts' +import Connection from './connection.ts' +import { NumberOrInteger } from './graph-types.ts' +import TransactionPromise from './transaction-promise.ts' +import ManagedTransaction from './transaction-managed.ts' +import BookmarkManager from './bookmark-manager.ts' + +type ConnectionConsumer = (connection: Connection | null) => any | undefined | Promise | Promise +type TransactionWork = (tx: Transaction) => Promise | T +type ManagedTransactionWork = (tx: ManagedTransaction) => Promise | T + +interface TransactionConfig { + timeout?: NumberOrInteger + metadata?: object +} + +/** + * A Session instance is used for handling the connection and + * sending queries through the connection. + * In a single session, multiple queries will be executed serially. + * In order to execute parallel queries, multiple sessions are required. + * @access public + */ +class Session { + private readonly _mode: SessionMode + private _database: string + private readonly _reactive: boolean + private readonly _fetchSize: number + private readonly _readConnectionHolder: ConnectionHolder + private readonly _writeConnectionHolder: ConnectionHolder + private _open: boolean + private _hasTx: boolean + private _lastBookmarks: Bookmarks + private _configuredBookmarks: Bookmarks + private readonly _transactionExecutor: TransactionExecutor + private readonly _impersonatedUser?: string + private _databaseNameResolved: boolean + private readonly _lowRecordWatermark: number + private readonly _highRecordWatermark: number + private readonly _results: Result[] + private readonly _bookmarkManager?: BookmarkManager + /** + * @constructor + * @protected + * @param {Object} args + * @param {string} args.mode the default access mode for this session. + * @param {ConnectionProvider} args.connectionProvider - The connection provider to acquire connections from. + * @param {Bookmarks} args.bookmarks - The initial bookmarks for this session. + * @param {string} args.database the database name + * @param {Object} args.config={} - This driver configuration. + * @param {boolean} args.reactive - Whether this session should create reactive streams + * @param {number} args.fetchSize - Defines how many records is pulled in each pulling batch + * @param {string} args.impersonatedUser - The username which the user wants to impersonate for the duration of the session. + */ + constructor ({ + mode, + connectionProvider, + bookmarks, + database, + config, + reactive, + fetchSize, + impersonatedUser, + bookmarkManager + }: { + mode: SessionMode + connectionProvider: ConnectionProvider + bookmarks?: Bookmarks + database: string + config: any + reactive: boolean + fetchSize: number + impersonatedUser?: string + bookmarkManager?: BookmarkManager + }) { + this._mode = mode + this._database = database + this._reactive = reactive + this._fetchSize = fetchSize + this._onDatabaseNameResolved = this._onDatabaseNameResolved.bind(this) + this._getConnectionAcquistionBookmarks = this._getConnectionAcquistionBookmarks.bind(this) + this._readConnectionHolder = new ConnectionHolder({ + mode: ACCESS_MODE_READ, + database, + bookmarks, + connectionProvider, + impersonatedUser, + onDatabaseNameResolved: this._onDatabaseNameResolved, + getConnectionAcquistionBookmarks: this._getConnectionAcquistionBookmarks + }) + this._writeConnectionHolder = new ConnectionHolder({ + mode: ACCESS_MODE_WRITE, + database, + bookmarks, + connectionProvider, + impersonatedUser, + onDatabaseNameResolved: this._onDatabaseNameResolved, + getConnectionAcquistionBookmarks: this._getConnectionAcquistionBookmarks + }) + this._open = true + this._hasTx = false + this._impersonatedUser = impersonatedUser + this._lastBookmarks = bookmarks ?? Bookmarks.empty() + this._configuredBookmarks = this._lastBookmarks + this._transactionExecutor = _createTransactionExecutor(config) + this._databaseNameResolved = this._database !== '' + const calculatedWatermaks = this._calculateWatermaks() + this._lowRecordWatermark = calculatedWatermaks.low + this._highRecordWatermark = calculatedWatermaks.high + this._results = [] + this._bookmarkManager = bookmarkManager + } + + /** + * Run Cypher query + * Could be called with a query object i.e.: `{text: "MATCH ...", parameters: {param: 1}}` + * or with the query and parameters as separate arguments. + * + * @public + * @param {mixed} query - Cypher query to execute + * @param {Object} parameters - Map with parameters to use in query + * @param {TransactionConfig} [transactionConfig] - Configuration for the new auto-commit transaction. + * @return {Result} New Result. + */ + run ( + query: Query, + parameters?: any, + transactionConfig?: TransactionConfig + ): Result { + const { validatedQuery, params } = validateQueryAndParameters( + query, + parameters + ) + const autoCommitTxConfig = (transactionConfig != null) + ? new TxConfig(transactionConfig) + : TxConfig.empty() + + const result = this._run(validatedQuery, params, async connection => { + const bookmarks = await this._bookmarks() + this._assertSessionIsOpen() + return (connection as Connection).protocol().run(validatedQuery, params, { + bookmarks, + txConfig: autoCommitTxConfig, + mode: this._mode, + database: this._database, + impersonatedUser: this._impersonatedUser, + afterComplete: (meta: any) => this._onCompleteCallback(meta, bookmarks), + reactive: this._reactive, + fetchSize: this._fetchSize, + lowRecordWatermark: this._lowRecordWatermark, + highRecordWatermark: this._highRecordWatermark + }) + }) + this._results.push(result) + return result + } + + _run ( + query: Query, + parameters: any, + customRunner: ConnectionConsumer + ): Result { + const connectionHolder = this._connectionHolderWithMode(this._mode) + + let observerPromise + if (!this._open) { + observerPromise = Promise.resolve( + new FailedObserver({ + error: newError('Cannot run query in a closed session.') + }) + ) + } else if (!this._hasTx && connectionHolder.initializeConnection()) { + observerPromise = connectionHolder + .getConnection() + .then(connection => customRunner(connection)) + .catch(error => Promise.resolve(new FailedObserver({ error }))) + } else { + observerPromise = Promise.resolve( + new FailedObserver({ + error: newError( + 'Queries cannot be run directly on a ' + + 'session with an open transaction; either run from within the ' + + 'transaction or use a different session.' + ) + }) + ) + } + const watermarks = { high: this._highRecordWatermark, low: this._lowRecordWatermark } + return new Result(observerPromise, query, parameters, connectionHolder, watermarks) + } + + _acquireConnection (connectionConsumer: ConnectionConsumer): Promise { + let promise + const connectionHolder = this._connectionHolderWithMode(this._mode) + if (!this._open) { + promise = Promise.reject( + newError('Cannot run query in a closed session.') + ) + } else if (!this._hasTx && connectionHolder.initializeConnection()) { + promise = connectionHolder + .getConnection() + .then(connection => connectionConsumer(connection)) + .then(async result => { + await connectionHolder.releaseConnection() + return result + }) + } else { + promise = Promise.reject( + newError( + 'Queries cannot be run directly on a ' + + 'session with an open transaction; either run from within the ' + + 'transaction or use a different session.' + ) + ) + } + + return promise + } + + /** + * Begin a new transaction in this session. A session can have at most one transaction running at a time, if you + * want to run multiple concurrent transactions, you should use multiple concurrent sessions. + * + * While a transaction is open the session cannot be used to run queries outside the transaction. + * + * @param {TransactionConfig} [transactionConfig] - Configuration for the new auto-commit transaction. + * @returns {TransactionPromise} New Transaction. + */ + beginTransaction (transactionConfig?: TransactionConfig): TransactionPromise { + // this function needs to support bookmarks parameter for backwards compatibility + // parameter was of type {string|string[]} and represented either a single or multiple bookmarks + // that's why we need to check parameter type and decide how to interpret the value + const arg = transactionConfig + + let txConfig = TxConfig.empty() + if (arg != null) { + txConfig = new TxConfig(arg) + } + + return this._beginTransaction(this._mode, txConfig) + } + + _beginTransaction (accessMode: SessionMode, txConfig: TxConfig): TransactionPromise { + if (!this._open) { + throw newError('Cannot begin a transaction on a closed session.') + } + if (this._hasTx) { + throw newError( + 'You cannot begin a transaction on a session with an open transaction; ' + + 'either run from within the transaction or use a different session.' + ) + } + + const mode = Session._validateSessionMode(accessMode) + const connectionHolder = this._connectionHolderWithMode(mode) + connectionHolder.initializeConnection() + this._hasTx = true + + const tx = new TransactionPromise({ + connectionHolder, + impersonatedUser: this._impersonatedUser, + onClose: this._transactionClosed.bind(this), + onBookmarks: (newBm, oldBm, db) => this._updateBookmarks(newBm, oldBm, db), + onConnection: this._assertSessionIsOpen.bind(this), + reactive: this._reactive, + fetchSize: this._fetchSize, + lowRecordWatermark: this._lowRecordWatermark, + highRecordWatermark: this._highRecordWatermark + }) + tx._begin(() => this._bookmarks(), txConfig) + return tx + } + + /** + * @private + * @returns {void} + */ + _assertSessionIsOpen (): void { + if (!this._open) { + throw newError('You cannot run more transactions on a closed session.') + } + } + + /** + * @private + * @returns {void} + */ + _transactionClosed (): void { + this._hasTx = false + } + + /** + * Return the bookmarks received following the last completed {@link Transaction}. + * + * @deprecated This method will be removed in version 6.0. Please, use {@link Session#lastBookmarks} instead. + * + * @return {string[]} A reference to a previous transaction. + */ + lastBookmark (): string[] { + return this.lastBookmarks() + } + + /** + * Return the bookmarks received following the last completed {@link Transaction}. + * + * @return {string[]} A reference to a previous transaction. + */ + lastBookmarks (): string[] { + return this._lastBookmarks.values() + } + + private async _bookmarks (): Promise { + const bookmarks = await this._bookmarkManager?.getAllBookmarks() + if (bookmarks === undefined) { + return this._lastBookmarks + } + return new Bookmarks([...bookmarks, ...this._configuredBookmarks]) + } + + /** + * Execute given unit of work in a {@link READ} transaction. + * + * Transaction will automatically be committed unless the given function throws or returns a rejected promise. + * Some failures of the given function or the commit itself will be retried with exponential backoff with initial + * delay of 1 second and maximum retry time of 30 seconds. Maximum retry time is configurable via driver config's + * `maxTransactionRetryTime` property in milliseconds. + * + * @deprecated This method will be removed in version 6.0. Please, use {@link Session#executeRead} instead. + * + * @param {function(tx: Transaction): Promise} transactionWork - Callback that executes operations against + * a given {@link Transaction}. + * @param {TransactionConfig} [transactionConfig] - Configuration for all transactions started to execute the unit of work. + * @return {Promise} Resolved promise as returned by the given function or rejected promise when given + * function or commit fails. + */ + readTransaction( + transactionWork: TransactionWork, + transactionConfig?: TransactionConfig + ): Promise { + const config = new TxConfig(transactionConfig) + return this._runTransaction(ACCESS_MODE_READ, config, transactionWork) + } + + /** + * Execute given unit of work in a {@link WRITE} transaction. + * + * Transaction will automatically be committed unless the given function throws or returns a rejected promise. + * Some failures of the given function or the commit itself will be retried with exponential backoff with initial + * delay of 1 second and maximum retry time of 30 seconds. Maximum retry time is configurable via driver config's + * `maxTransactionRetryTime` property in milliseconds. + * + * @deprecated This method will be removed in version 6.0. Please, use {@link Session#executeWrite} instead. + * + * @param {function(tx: Transaction): Promise} transactionWork - Callback that executes operations against + * a given {@link Transaction}. + * @param {TransactionConfig} [transactionConfig] - Configuration for all transactions started to execute the unit of work. + * @return {Promise} Resolved promise as returned by the given function or rejected promise when given + * function or commit fails. + */ + writeTransaction( + transactionWork: TransactionWork, + transactionConfig?: TransactionConfig + ): Promise { + const config = new TxConfig(transactionConfig) + return this._runTransaction(ACCESS_MODE_WRITE, config, transactionWork) + } + + _runTransaction( + accessMode: SessionMode, + transactionConfig: TxConfig, + transactionWork: TransactionWork + ): Promise { + return this._transactionExecutor.execute( + () => this._beginTransaction(accessMode, transactionConfig), + transactionWork + ) + } + + /** + * Execute given unit of work in a {@link READ} transaction. + * + * Transaction will automatically be committed unless the given function throws or returns a rejected promise. + * Some failures of the given function or the commit itself will be retried with exponential backoff with initial + * delay of 1 second and maximum retry time of 30 seconds. Maximum retry time is configurable via driver config's + * `maxTransactionRetryTime` property in milliseconds. + * + * @param {function(tx: ManagedTransaction): Promise} transactionWork - Callback that executes operations against + * a given {@link Transaction}. + * @param {TransactionConfig} [transactionConfig] - Configuration for all transactions started to execute the unit of work. + * @return {Promise} Resolved promise as returned by the given function or rejected promise when given + * function or commit fails. + */ + executeRead( + transactionWork: ManagedTransactionWork, + transactionConfig?: TransactionConfig + ): Promise { + const config = new TxConfig(transactionConfig) + return this._executeInTransaction(ACCESS_MODE_READ, config, transactionWork) + } + + /** + * Execute given unit of work in a {@link WRITE} transaction. + * + * Transaction will automatically be committed unless the given function throws or returns a rejected promise. + * Some failures of the given function or the commit itself will be retried with exponential backoff with initial + * delay of 1 second and maximum retry time of 30 seconds. Maximum retry time is configurable via driver config's + * `maxTransactionRetryTime` property in milliseconds. + * + * @param {function(tx: ManagedTransaction): Promise} transactionWork - Callback that executes operations against + * a given {@link Transaction}. + * @param {TransactionConfig} [transactionConfig] - Configuration for all transactions started to execute the unit of work. + * @return {Promise} Resolved promise as returned by the given function or rejected promise when given + * function or commit fails. + */ + executeWrite( + transactionWork: ManagedTransactionWork, + transactionConfig?: TransactionConfig + ): Promise { + const config = new TxConfig(transactionConfig) + return this._executeInTransaction(ACCESS_MODE_WRITE, config, transactionWork) + } + + /** + * @private + * @param {SessionMode} accessMode + * @param {TxConfig} transactionConfig + * @param {ManagedTransactionWork} transactionWork + * @returns {Promise} + */ + private _executeInTransaction( + accessMode: SessionMode, + transactionConfig: TxConfig, + transactionWork: ManagedTransactionWork + ): Promise { + return this._transactionExecutor.execute( + () => this._beginTransaction(accessMode, transactionConfig), + transactionWork, + ManagedTransaction.fromTransaction + ) + } + + /** + * Sets the resolved database name in the session context. + * @private + * @param {string|undefined} database The resolved database name + * @returns {void} + */ + _onDatabaseNameResolved (database?: string): void { + if (!this._databaseNameResolved) { + const normalizedDatabase = database ?? '' + this._database = normalizedDatabase + this._readConnectionHolder.setDatabase(normalizedDatabase) + this._writeConnectionHolder.setDatabase(normalizedDatabase) + this._databaseNameResolved = true + } + } + + private async _getConnectionAcquistionBookmarks (): Promise { + const bookmarks = await this._bookmarkManager?.getBookmarks('system') + if (bookmarks === undefined) { + return this._lastBookmarks + } + return new Bookmarks([...this._configuredBookmarks, ...bookmarks]) + } + + /** + * Update value of the last bookmarks. + * @private + * @param {Bookmarks} newBookmarks - The new bookmarks. + * @returns {void} + */ + _updateBookmarks (newBookmarks?: Bookmarks, previousBookmarks?: Bookmarks, database?: string): void { + if ((newBookmarks != null) && !newBookmarks.isEmpty()) { + this._bookmarkManager?.updateBookmarks( + database ?? this._database, + previousBookmarks?.values() ?? [], + newBookmarks?.values() ?? [] + ) + this._lastBookmarks = newBookmarks + this._configuredBookmarks = Bookmarks.empty() + } + } + + /** + * Close this session. + * @return {Promise} + */ + async close (): Promise { + if (this._open) { + this._open = false + + this._results.forEach(result => result._cancel()) + + this._transactionExecutor.close() + + await this._readConnectionHolder.close(this._hasTx) + await this._writeConnectionHolder.close(this._hasTx) + } + } + + _connectionHolderWithMode (mode: SessionMode): ConnectionHolder { + if (mode === ACCESS_MODE_READ) { + return this._readConnectionHolder + } else if (mode === ACCESS_MODE_WRITE) { + return this._writeConnectionHolder + } else { + throw newError('Unknown access mode: ' + (mode as string)) + } + } + + /** + * @private + * @param {Object} meta Connection metadatada + * @returns {void} + */ + _onCompleteCallback (meta: { bookmark: string | string[], db?: string }, previousBookmarks?: Bookmarks): void { + this._updateBookmarks(new Bookmarks(meta.bookmark), previousBookmarks, meta.db) + } + + /** + * @private + * @returns {void} + */ + private _calculateWatermaks (): { low: number, high: number } { + if (this._fetchSize === FETCH_ALL) { + return { + low: Number.MAX_VALUE, // we shall always lower than this number to enable auto pull + high: Number.MAX_VALUE // we shall never reach this number to disable auto pull + } + } + return { + low: 0.3 * this._fetchSize, + high: 0.7 * this._fetchSize + } + } + + /** + * @protected + */ + static _validateSessionMode (rawMode?: SessionMode): SessionMode { + const mode: string = rawMode ?? ACCESS_MODE_WRITE + if (mode !== ACCESS_MODE_READ && mode !== ACCESS_MODE_WRITE) { + throw newError('Illegal session mode ' + mode) + } + return mode as SessionMode + } +} + +/** + * @private + * @param {object} config + * @returns {TransactionExecutor} The transaction executor + */ +function _createTransactionExecutor (config?: { + maxTransactionRetryTime: number | null +}): TransactionExecutor { + const maxRetryTimeMs = config?.maxTransactionRetryTime ?? null + return new TransactionExecutor(maxRetryTimeMs) +} + +export default Session +export type { TransactionConfig } diff --git a/packages/neo4j-driver-deno/lib/core/spatial-types.ts b/packages/neo4j-driver-deno/lib/core/spatial-types.ts new file mode 100644 index 000000000..f0911dafc --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/spatial-types.ts @@ -0,0 +1,98 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 { assertNumber, assertNumberOrInteger } from './internal/util.ts' +import { NumberOrInteger } from './graph-types.ts' +import Integer from './integer.ts' + +const POINT_IDENTIFIER_PROPERTY = '__isPoint__' + +/** + * Represents a single two or three-dimensional point in a particular coordinate reference system. + * Created `Point` objects are frozen with `Object.freeze()` in constructor and thus immutable. + */ +export class Point { + readonly srid: T + readonly x: number + readonly y: number + readonly z: number | undefined + + /** + * @constructor + * @param {T} srid - The coordinate reference system identifier. + * @param {number} x - The `x` coordinate of the point. + * @param {number} y - The `y` coordinate of the point. + * @param {number} [z=undefined] - The `z` coordinate of the point or `undefined` if point has 2 dimensions. + */ + constructor (srid: T, x: number, y: number, z?: number) { + /** + * The coordinate reference system identifier. + * @type {T} + */ + this.srid = assertNumberOrInteger(srid, 'SRID') as T + /** + * The `x` coordinate of the point. + * @type {number} + */ + this.x = assertNumber(x, 'X coordinate') + /** + * The `y` coordinate of the point. + * @type {number} + */ + this.y = assertNumber(y, 'Y coordinate') + /** + * The `z` coordinate of the point or `undefined` if point is 2-dimensional. + * @type {number} + */ + this.z = z === null || z === undefined ? z : assertNumber(z, 'Z coordinate') + Object.freeze(this) + } + + /** + * @ignore + */ + toString (): string { + return this.z != null && !isNaN(this.z) + ? `Point{srid=${formatAsFloat(this.srid)}, x=${formatAsFloat( + this.x + )}, y=${formatAsFloat(this.y)}, z=${formatAsFloat(this.z)}}` + : `Point{srid=${formatAsFloat(this.srid)}, x=${formatAsFloat( + this.x + )}, y=${formatAsFloat(this.y)}}` + } +} + +function formatAsFloat (number: NumberOrInteger): string { + return Number.isInteger(number) ? number.toString() + '.0' : number.toString() +} + +Object.defineProperty(Point.prototype, POINT_IDENTIFIER_PROPERTY, { + value: true, + enumerable: false, + configurable: false, + writable: false +}) + +/** + * Test if given object is an instance of {@link Point} class. + * @param {Object} obj the object to test. + * @return {boolean} `true` if given object is a {@link Point}, `false` otherwise. + */ +export function isPoint (obj?: any): obj is Point { + return obj != null && obj[POINT_IDENTIFIER_PROPERTY] === true +} diff --git a/packages/neo4j-driver-deno/lib/core/temporal-types.ts b/packages/neo4j-driver-deno/lib/core/temporal-types.ts new file mode 100644 index 000000000..7e79de0a2 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/temporal-types.ts @@ -0,0 +1,809 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 * as util from './internal/temporal-util.ts' +import { NumberOrInteger, StandardDate } from './graph-types.ts' +import { + assertNumberOrInteger, + assertString, + assertValidDate +} from './internal/util.ts' +import { newError } from './error.ts' +import Integer, { int, toNumber } from './integer.ts' + +const IDENTIFIER_PROPERTY_ATTRIBUTES = { + value: true, + enumerable: false, + configurable: false, + writable: false +} + +const DURATION_IDENTIFIER_PROPERTY: string = '__isDuration__' +const LOCAL_TIME_IDENTIFIER_PROPERTY: string = '__isLocalTime__' +const TIME_IDENTIFIER_PROPERTY: string = '__isTime__' +const DATE_IDENTIFIER_PROPERTY: string = '__isDate__' +const LOCAL_DATE_TIME_IDENTIFIER_PROPERTY: string = '__isLocalDateTime__' +const DATE_TIME_IDENTIFIER_PROPERTY: string = '__isDateTime__' + +/** + * Represents an ISO 8601 duration. Contains both date-based values (years, months, days) and time-based values (seconds, nanoseconds). + * Created `Duration` objects are frozen with `Object.freeze()` in constructor and thus immutable. + */ +export class Duration { + readonly months: T + readonly days: T + readonly seconds: T + readonly nanoseconds: T + + /** + * @constructor + * @param {NumberOrInteger} months - The number of months for the new duration. + * @param {NumberOrInteger} days - The number of days for the new duration. + * @param {NumberOrInteger} seconds - The number of seconds for the new duration. + * @param {NumberOrInteger} nanoseconds - The number of nanoseconds for the new duration. + */ + constructor (months: T, days: T, seconds: T, nanoseconds: T) { + /** + * The number of months. + * @type {NumberOrInteger} + */ + this.months = assertNumberOrInteger(months, 'Months') as T + /** + * The number of days. + * @type {NumberOrInteger} + */ + this.days = assertNumberOrInteger(days, 'Days') as T + assertNumberOrInteger(seconds, 'Seconds') + assertNumberOrInteger(nanoseconds, 'Nanoseconds') + /** + * The number of seconds. + * @type {NumberOrInteger} + */ + this.seconds = util.normalizeSecondsForDuration(seconds, nanoseconds) as T + /** + * The number of nanoseconds. + * @type {NumberOrInteger} + */ + this.nanoseconds = util.normalizeNanosecondsForDuration(nanoseconds) as T + Object.freeze(this) + } + + /** + * @ignore + */ + toString (): string { + return util.durationToIsoString( + this.months, + this.days, + this.seconds, + this.nanoseconds + ) + } +} + +Object.defineProperty( + Duration.prototype, + DURATION_IDENTIFIER_PROPERTY, + IDENTIFIER_PROPERTY_ATTRIBUTES +) + +/** + * Test if given object is an instance of {@link Duration} class. + * @param {Object} obj the object to test. + * @return {boolean} `true` if given object is a {@link Duration}, `false` otherwise. + */ +export function isDuration (obj: object): obj is Duration { + return hasIdentifierProperty(obj, DURATION_IDENTIFIER_PROPERTY) +} + +/** + * Represents an instant capturing the time of day, but not the date, nor the timezone. + * Created {@link LocalTime} objects are frozen with `Object.freeze()` in constructor and thus immutable. + */ +export class LocalTime { + readonly hour: T + readonly minute: T + readonly second: T + readonly nanosecond: T + /** + * @constructor + * @param {NumberOrInteger} hour - The hour for the new local time. + * @param {NumberOrInteger} minute - The minute for the new local time. + * @param {NumberOrInteger} second - The second for the new local time. + * @param {NumberOrInteger} nanosecond - The nanosecond for the new local time. + */ + constructor (hour: T, minute: T, second: T, nanosecond: T) { + /** + * The hour. + * @type {NumberOrInteger} + */ + this.hour = util.assertValidHour(hour) as T + /** + * The minute. + * @type {NumberOrInteger} + */ + this.minute = util.assertValidMinute(minute) as T + /** + * The second. + * @type {NumberOrInteger} + */ + this.second = util.assertValidSecond(second) as T + /** + * The nanosecond. + * @type {NumberOrInteger} + */ + this.nanosecond = util.assertValidNanosecond(nanosecond) as T + Object.freeze(this) + } + + /** + * Create a {@link LocalTime} object from the given standard JavaScript `Date` and optional nanoseconds. + * Year, month, day and time zone offset components of the given date are ignored. + * @param {global.Date} standardDate - The standard JavaScript date to convert. + * @param {NumberOrInteger|undefined} nanosecond - The optional amount of nanoseconds. + * @return {LocalTime} New LocalTime. + */ + static fromStandardDate ( + standardDate: StandardDate, + nanosecond?: NumberOrInteger + ): LocalTime { + verifyStandardDateAndNanos(standardDate, nanosecond) + + const totalNanoseconds: number | Integer | bigint = util.totalNanoseconds( + standardDate, + nanosecond + ) + + return new LocalTime( + standardDate.getHours(), + standardDate.getMinutes(), + standardDate.getSeconds(), + totalNanoseconds instanceof Integer + ? totalNanoseconds.toInt() + : typeof totalNanoseconds === 'bigint' + ? int(totalNanoseconds).toInt() + : totalNanoseconds + ) + } + + /** + * @ignore + */ + toString (): string { + return util.timeToIsoString( + this.hour, + this.minute, + this.second, + this.nanosecond + ) + } +} + +Object.defineProperty( + LocalTime.prototype, + LOCAL_TIME_IDENTIFIER_PROPERTY, + IDENTIFIER_PROPERTY_ATTRIBUTES +) + +/** + * Test if given object is an instance of {@link LocalTime} class. + * @param {Object} obj the object to test. + * @return {boolean} `true` if given object is a {@link LocalTime}, `false` otherwise. + */ +export function isLocalTime (obj: object): boolean { + return hasIdentifierProperty(obj, LOCAL_TIME_IDENTIFIER_PROPERTY) +} + +/** + * Represents an instant capturing the time of day, and the timezone offset in seconds, but not the date. + * Created {@link Time} objects are frozen with `Object.freeze()` in constructor and thus immutable. + */ +export class Time { + readonly hour: T + readonly minute: T + readonly second: T + readonly nanosecond: T + readonly timeZoneOffsetSeconds: T + /** + * @constructor + * @param {NumberOrInteger} hour - The hour for the new local time. + * @param {NumberOrInteger} minute - The minute for the new local time. + * @param {NumberOrInteger} second - The second for the new local time. + * @param {NumberOrInteger} nanosecond - The nanosecond for the new local time. + * @param {NumberOrInteger} timeZoneOffsetSeconds - The time zone offset in seconds. Value represents the difference, in seconds, from UTC to local time. + * This is different from standard JavaScript `Date.getTimezoneOffset()` which is the difference, in minutes, from local time to UTC. + */ + constructor ( + hour: T, + minute: T, + second: T, + nanosecond: T, + timeZoneOffsetSeconds: T + ) { + /** + * The hour. + * @type {NumberOrInteger} + */ + this.hour = util.assertValidHour(hour) as T + /** + * The minute. + * @type {NumberOrInteger} + */ + this.minute = util.assertValidMinute(minute) as T + /** + * The second. + * @type {NumberOrInteger} + */ + this.second = util.assertValidSecond(second) as T + /** + * The nanosecond. + * @type {NumberOrInteger} + */ + this.nanosecond = util.assertValidNanosecond(nanosecond) as T + /** + * The time zone offset in seconds. + * @type {NumberOrInteger} + */ + this.timeZoneOffsetSeconds = assertNumberOrInteger( + timeZoneOffsetSeconds, + 'Time zone offset in seconds' + ) as T + Object.freeze(this) + } + + /** + * Create a {@link Time} object from the given standard JavaScript `Date` and optional nanoseconds. + * Year, month and day components of the given date are ignored. + * @param {global.Date} standardDate - The standard JavaScript date to convert. + * @param {NumberOrInteger|undefined} nanosecond - The optional amount of nanoseconds. + * @return {Time} New Time. + */ + static fromStandardDate ( + standardDate: StandardDate, + nanosecond?: NumberOrInteger + ): Time { + verifyStandardDateAndNanos(standardDate, nanosecond) + + return new Time( + standardDate.getHours(), + standardDate.getMinutes(), + standardDate.getSeconds(), + toNumber(util.totalNanoseconds(standardDate, nanosecond)), + util.timeZoneOffsetInSeconds(standardDate) + ) + } + + /** + * @ignore + */ + toString (): string { + return ( + util.timeToIsoString( + this.hour, + this.minute, + this.second, + this.nanosecond + ) + util.timeZoneOffsetToIsoString(this.timeZoneOffsetSeconds) + ) + } +} + +Object.defineProperty( + Time.prototype, + TIME_IDENTIFIER_PROPERTY, + IDENTIFIER_PROPERTY_ATTRIBUTES +) + +/** + * Test if given object is an instance of {@link Time} class. + * @param {Object} obj the object to test. + * @return {boolean} `true` if given object is a {@link Time}, `false` otherwise. + */ +export function isTime (obj: object): obj is Time { + return hasIdentifierProperty(obj, TIME_IDENTIFIER_PROPERTY) +} + +/** + * Represents an instant capturing the date, but not the time, nor the timezone. + * Created {@link Date} objects are frozen with `Object.freeze()` in constructor and thus immutable. + */ +export class Date { + readonly year: T + readonly month: T + readonly day: T + /** + * @constructor + * @param {NumberOrInteger} year - The year for the new local date. + * @param {NumberOrInteger} month - The month for the new local date. + * @param {NumberOrInteger} day - The day for the new local date. + */ + constructor (year: T, month: T, day: T) { + /** + * The year. + * @type {NumberOrInteger} + */ + this.year = util.assertValidYear(year) as T + /** + * The month. + * @type {NumberOrInteger} + */ + this.month = util.assertValidMonth(month) as T + /** + * The day. + * @type {NumberOrInteger} + */ + this.day = util.assertValidDay(day) as T + Object.freeze(this) + } + + /** + * Create a {@link Date} object from the given standard JavaScript `Date`. + * Hour, minute, second, millisecond and time zone offset components of the given date are ignored. + * @param {global.Date} standardDate - The standard JavaScript date to convert. + * @return {Date} New Date. + */ + static fromStandardDate (standardDate: StandardDate): Date { + verifyStandardDateAndNanos(standardDate) + + return new Date( + standardDate.getFullYear(), + standardDate.getMonth() + 1, + standardDate.getDate() + ) + } + + /** + * Convert date to standard JavaScript `Date`. + * + * The time component of the returned `Date` is set to midnight + * and the time zone is set to UTC. + * + * @returns {StandardDate} Standard JavaScript `Date` at `00:00:00.000` UTC. + */ + toStandardDate (): StandardDate { + return util.isoStringToStandardDate(this.toString()) + } + + /** + * @ignore + */ + toString (): string { + return util.dateToIsoString(this.year, this.month, this.day) + } +} + +Object.defineProperty( + Date.prototype, + DATE_IDENTIFIER_PROPERTY, + IDENTIFIER_PROPERTY_ATTRIBUTES +) + +/** + * Test if given object is an instance of {@link Date} class. + * @param {Object} obj - The object to test. + * @return {boolean} `true` if given object is a {@link Date}, `false` otherwise. + */ +export function isDate (obj: object): boolean { + return hasIdentifierProperty(obj, DATE_IDENTIFIER_PROPERTY) +} + +/** + * Represents an instant capturing the date and the time, but not the timezone. + * Created {@link LocalDateTime} objects are frozen with `Object.freeze()` in constructor and thus immutable. + */ +export class LocalDateTime { + readonly year: T + readonly month: T + readonly day: T + readonly hour: T + readonly minute: T + readonly second: T + readonly nanosecond: T + /** + * @constructor + * @param {NumberOrInteger} year - The year for the new local date. + * @param {NumberOrInteger} month - The month for the new local date. + * @param {NumberOrInteger} day - The day for the new local date. + * @param {NumberOrInteger} hour - The hour for the new local time. + * @param {NumberOrInteger} minute - The minute for the new local time. + * @param {NumberOrInteger} second - The second for the new local time. + * @param {NumberOrInteger} nanosecond - The nanosecond for the new local time. + */ + constructor ( + year: T, + month: T, + day: T, + hour: T, + minute: T, + second: T, + nanosecond: T + ) { + /** + * The year. + * @type {NumberOrInteger} + */ + this.year = util.assertValidYear(year) as T + /** + * The month. + * @type {NumberOrInteger} + */ + this.month = util.assertValidMonth(month) as T + /** + * The day. + * @type {NumberOrInteger} + */ + this.day = util.assertValidDay(day) as T + /** + * The hour. + * @type {NumberOrInteger} + */ + this.hour = util.assertValidHour(hour) as T + /** + * The minute. + * @type {NumberOrInteger} + */ + this.minute = util.assertValidMinute(minute) as T + /** + * The second. + * @type {NumberOrInteger} + */ + this.second = util.assertValidSecond(second) as T + /** + * The nanosecond. + * @type {NumberOrInteger} + */ + this.nanosecond = util.assertValidNanosecond(nanosecond) as T + Object.freeze(this) + } + + /** + * Create a {@link LocalDateTime} object from the given standard JavaScript `Date` and optional nanoseconds. + * Time zone offset component of the given date is ignored. + * @param {global.Date} standardDate - The standard JavaScript date to convert. + * @param {NumberOrInteger|undefined} nanosecond - The optional amount of nanoseconds. + * @return {LocalDateTime} New LocalDateTime. + */ + static fromStandardDate ( + standardDate: StandardDate, + nanosecond?: NumberOrInteger + ): LocalDateTime { + verifyStandardDateAndNanos(standardDate, nanosecond) + + return new LocalDateTime( + standardDate.getFullYear(), + standardDate.getMonth() + 1, + standardDate.getDate(), + standardDate.getHours(), + standardDate.getMinutes(), + standardDate.getSeconds(), + toNumber(util.totalNanoseconds(standardDate, nanosecond)) + ) + } + + /** + * Convert date to standard JavaScript `Date`. + * + * @returns {StandardDate} Standard JavaScript `Date` at the local timezone + */ + toStandardDate (): StandardDate { + return util.isoStringToStandardDate(this.toString()) + } + + /** + * @ignore + */ + toString (): string { + return localDateTimeToString( + this.year, + this.month, + this.day, + this.hour, + this.minute, + this.second, + this.nanosecond + ) + } +} + +Object.defineProperty( + LocalDateTime.prototype, + LOCAL_DATE_TIME_IDENTIFIER_PROPERTY, + IDENTIFIER_PROPERTY_ATTRIBUTES +) + +/** + * Test if given object is an instance of {@link LocalDateTime} class. + * @param {Object} obj - The object to test. + * @return {boolean} `true` if given object is a {@link LocalDateTime}, `false` otherwise. + */ +export function isLocalDateTime (obj: any): obj is LocalDateTime { + return hasIdentifierProperty(obj, LOCAL_DATE_TIME_IDENTIFIER_PROPERTY) +} + +/** + * Represents an instant capturing the date, the time and the timezone identifier. + * Created {@ DateTime} objects are frozen with `Object.freeze()` in constructor and thus immutable. + */ +export class DateTime { + readonly year: T + readonly month: T + readonly day: T + readonly hour: T + readonly minute: T + readonly second: T + readonly nanosecond: T + readonly timeZoneOffsetSeconds?: T + readonly timeZoneId?: string + /** + * @constructor + * @param {NumberOrInteger} year - The year for the new date-time. + * @param {NumberOrInteger} month - The month for the new date-time. + * @param {NumberOrInteger} day - The day for the new date-time. + * @param {NumberOrInteger} hour - The hour for the new date-time. + * @param {NumberOrInteger} minute - The minute for the new date-time. + * @param {NumberOrInteger} second - The second for the new date-time. + * @param {NumberOrInteger} nanosecond - The nanosecond for the new date-time. + * @param {NumberOrInteger} timeZoneOffsetSeconds - The time zone offset in seconds. Either this argument or `timeZoneId` should be defined. + * Value represents the difference, in seconds, from UTC to local time. + * This is different from standard JavaScript `Date.getTimezoneOffset()` which is the difference, in minutes, from local time to UTC. + * @param {string|null} timeZoneId - The time zone id for the new date-time. Either this argument or `timeZoneOffsetSeconds` should be defined. + */ + constructor ( + year: T, + month: T, + day: T, + hour: T, + minute: T, + second: T, + nanosecond: T, + timeZoneOffsetSeconds?: T, + timeZoneId?: string | null + ) { + /** + * The year. + * @type {NumberOrInteger} + */ + this.year = util.assertValidYear(year) as T + /** + * The month. + * @type {NumberOrInteger} + */ + this.month = util.assertValidMonth(month) as T + /** + * The day. + * @type {NumberOrInteger} + */ + this.day = util.assertValidDay(day) as T + /** + * The hour. + * @type {NumberOrInteger} + */ + this.hour = util.assertValidHour(hour) as T + /** + * The minute. + * @type {NumberOrInteger} + */ + this.minute = util.assertValidMinute(minute) as T + /** + * The second. + * @type {NumberOrInteger} + */ + this.second = util.assertValidSecond(second) as T + /** + * The nanosecond. + * @type {NumberOrInteger} + */ + this.nanosecond = util.assertValidNanosecond(nanosecond) as T + + const [offset, id] = verifyTimeZoneArguments( + timeZoneOffsetSeconds, + timeZoneId + ) + /** + * The time zone offset in seconds. + * + * *Either this or {@link timeZoneId} is defined.* + * + * @type {NumberOrInteger} + */ + this.timeZoneOffsetSeconds = offset as T + /** + * The time zone id. + * + * *Either this or {@link timeZoneOffsetSeconds} is defined.* + * + * @type {string} + */ + this.timeZoneId = id ?? undefined + + Object.freeze(this) + } + + /** + * Create a {@link DateTime} object from the given standard JavaScript `Date` and optional nanoseconds. + * @param {global.Date} standardDate - The standard JavaScript date to convert. + * @param {NumberOrInteger|undefined} nanosecond - The optional amount of nanoseconds. + * @return {DateTime} New DateTime. + */ + static fromStandardDate ( + standardDate: StandardDate, + nanosecond?: NumberOrInteger + ): DateTime { + verifyStandardDateAndNanos(standardDate, nanosecond) + + return new DateTime( + standardDate.getFullYear(), + standardDate.getMonth() + 1, + standardDate.getDate(), + standardDate.getHours(), + standardDate.getMinutes(), + standardDate.getSeconds(), + toNumber(util.totalNanoseconds(standardDate, nanosecond)), + util.timeZoneOffsetInSeconds(standardDate), + null /* no time zone id */ + ) + } + + /** + * Convert date to standard JavaScript `Date`. + * + * @returns {StandardDate} Standard JavaScript `Date` at the defined time zone offset + * @throws {Error} If the time zone offset is not defined in the object. + */ + toStandardDate (): StandardDate { + return util.toStandardDate(this._toUTC()) + } + + /** + * @ignore + */ + toString (): string { + const localDateTimeStr = localDateTimeToString( + this.year, + this.month, + this.day, + this.hour, + this.minute, + this.second, + this.nanosecond + ) + + const timeOffset = this.timeZoneOffsetSeconds != null + ? util.timeZoneOffsetToIsoString(this.timeZoneOffsetSeconds ?? 0) + : '' + + const timeZoneStr = this.timeZoneId != null + ? `[${this.timeZoneId}]` + : '' + + return localDateTimeStr + timeOffset + timeZoneStr + } + + /** + * @private + * @returns {number} + */ + private _toUTC (): number { + if (this.timeZoneOffsetSeconds === undefined) { + throw new Error('Requires DateTime created with time zone offset') + } + const epochSecond = util.localDateTimeToEpochSecond( + this.year, + this.month, + this.day, + this.hour, + this.minute, + this.second, + this.nanosecond + ) + + const utcSecond = epochSecond.subtract(this.timeZoneOffsetSeconds ?? 0) + + return int(utcSecond) + .multiply(1000) + .add(int(this.nanosecond).div(1_000_000)) + .toNumber() + } +} + +Object.defineProperty( + DateTime.prototype, + DATE_TIME_IDENTIFIER_PROPERTY, + IDENTIFIER_PROPERTY_ATTRIBUTES +) + +/** + * Test if given object is an instance of {@link DateTime} class. + * @param {Object} obj - The object to test. + * @return {boolean} `true` if given object is a {@link DateTime}, `false` otherwise. + */ +export function isDateTime (obj: object): boolean { + return hasIdentifierProperty(obj, DATE_TIME_IDENTIFIER_PROPERTY) +} + +function hasIdentifierProperty (obj: any, property: string): boolean { + return obj != null && obj[property] === true +} + +function localDateTimeToString ( + year: NumberOrInteger, + month: NumberOrInteger, + day: NumberOrInteger, + hour: NumberOrInteger, + minute: NumberOrInteger, + second: NumberOrInteger, + nanosecond: NumberOrInteger +): string { + return ( + util.dateToIsoString(year, month, day) + + 'T' + + util.timeToIsoString(hour, minute, second, nanosecond) + ) +} + +/** + * @private + * @param {NumberOrInteger} timeZoneOffsetSeconds + * @param {string | null } timeZoneId + * @returns {Array} + */ +function verifyTimeZoneArguments ( + timeZoneOffsetSeconds?: NumberOrInteger, + timeZoneId?: string | null +): [NumberOrInteger | undefined | null, string | undefined | null] { + const offsetDefined = timeZoneOffsetSeconds !== null && timeZoneOffsetSeconds !== undefined + const idDefined = timeZoneId !== null && timeZoneId !== undefined && timeZoneId !== '' + + if (!offsetDefined && !idDefined) { + throw newError( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Unable to create DateTime without either time zone offset or id. Please specify either of them. Given offset: ${timeZoneOffsetSeconds} and id: ${timeZoneId}` + ) + } + + const result: [NumberOrInteger | undefined | null, string | undefined | null] = [undefined, undefined] + if (offsetDefined) { + assertNumberOrInteger(timeZoneOffsetSeconds, 'Time zone offset in seconds') + result[0] = timeZoneOffsetSeconds + } + + if (idDefined) { + assertString(timeZoneId, 'Time zone ID') + util.assertValidZoneId('Time zone ID', timeZoneId) + result[1] = timeZoneId + } + + return result +} + +/** + * @private + * @param {StandardDate} standardDate + * @param {NumberOrInteger} nanosecond + * @returns {void} + */ +function verifyStandardDateAndNanos ( + standardDate: StandardDate, + nanosecond?: NumberOrInteger +): void { + assertValidDate(standardDate, 'Standard date') + if (nanosecond !== null && nanosecond !== undefined) { + assertNumberOrInteger(nanosecond, 'Nanosecond') + } +} diff --git a/packages/neo4j-driver-deno/lib/core/transaction-managed.ts b/packages/neo4j-driver-deno/lib/core/transaction-managed.ts new file mode 100644 index 000000000..162af33c9 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/transaction-managed.ts @@ -0,0 +1,68 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 Result from './result.ts' +import Transaction from './transaction.ts' +import { Query } from './types.ts' + +type Run = (query: Query, parameters?: any) => Result + +/** + * Represents a transaction that is managed by the transaction executor. + * + * @public + */ +class ManagedTransaction { + private readonly _run: Run + + /** + * @private + */ + private constructor ({ run }: { run: Run }) { + /** + * @private + */ + this._run = run + } + + /** + * @private + * @param {Transaction} tx - Transaction to wrap + * @returns {ManagedTransaction} the ManagedTransaction + */ + static fromTransaction (tx: Transaction): ManagedTransaction { + return new ManagedTransaction({ + run: tx.run.bind(tx) + }) + } + + /** + * Run Cypher query + * Could be called with a query object i.e.: `{text: "MATCH ...", parameters: {param: 1}}` + * or with the query and parameters as separate arguments. + * @param {mixed} query - Cypher query to execute + * @param {Object} parameters - Map with parameters to use in query + * @return {Result} New Result + */ + run (query: Query, parameters?: any): Result { + return this._run(query, parameters) + } +} + +export default ManagedTransaction diff --git a/packages/neo4j-driver-deno/lib/core/transaction-promise.ts b/packages/neo4j-driver-deno/lib/core/transaction-promise.ts new file mode 100644 index 000000000..157588735 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/transaction-promise.ts @@ -0,0 +1,195 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ + +/* eslint-disable @typescript-eslint/promise-function-async */ + +import Transaction from './transaction.ts' +import { + ConnectionHolder +} from './internal/connection-holder.ts' + +import { Bookmarks } from './internal/bookmarks.ts' +import { TxConfig } from './internal/tx-config.ts' + +/** + * Represents a {@link Promise} object and a {@link Transaction} object. + * + * Resolving this object promise verifies the result of the transaction begin and returns the {@link Transaction} object in case of success. + * + * The object can still also used as {@link Transaction} for convenience. The result of begin will be checked + * during the next API calls in the object as it is in the transaction. + * + * @access public + */ +class TransactionPromise extends Transaction implements Promise { + [Symbol.toStringTag]: string = 'TransactionPromise' + private _beginError?: Error + private _beginMetadata?: any + private _beginPromise?: Promise + private _reject?: (error: Error) => void + private _resolve?: (value: Transaction | PromiseLike) => void + + /** + * @constructor + * @param {ConnectionHolder} connectionHolder - the connection holder to get connection from. + * @param {function()} onClose - Function to be called when transaction is committed or rolled back. + * @param {function(bookmarks: Bookmarks)} onBookmarks callback invoked when new bookmark is produced. + * @param {function()} onConnection - Function to be called when a connection is obtained to ensure the connection + * is not yet released. + * @param {boolean} reactive whether this transaction generates reactive streams + * @param {number} fetchSize - the record fetch size in each pulling batch. + * @param {string} impersonatedUser - The name of the user which should be impersonated for the duration of the session. + */ + constructor ({ + connectionHolder, + onClose, + onBookmarks, + onConnection, + reactive, + fetchSize, + impersonatedUser, + highRecordWatermark, + lowRecordWatermark + }: { + connectionHolder: ConnectionHolder + onClose: () => void + onBookmarks: (newBookmarks: Bookmarks, previousBookmarks: Bookmarks, database?: string) => void + onConnection: () => void + reactive: boolean + fetchSize: number + impersonatedUser?: string + highRecordWatermark: number + lowRecordWatermark: number + }) { + super({ + connectionHolder, + onClose, + onBookmarks, + onConnection, + reactive, + fetchSize, + impersonatedUser, + highRecordWatermark, + lowRecordWatermark + }) + } + + /** + * Waits for the begin to complete. + * + * @param {function(transaction: Transaction)} onFulfilled - function to be called when finished. + * @param {function(error: {message:string, code:string})} onRejected - function to be called upon errors. + * @return {Promise} promise. + */ + then( + onfulfilled?: + ((value: Transaction) => TResult1 | PromiseLike) + | null, + onrejected?: + ((reason: any) => TResult2 | PromiseLike) + | null + ): Promise { + return this._getOrCreateBeginPromise().then(onfulfilled, onrejected) + } + + /** + * Catch errors when using promises. + * + * @param {function(error: Neo4jError)} onRejected - Function to be called upon errors. + * @return {Promise} promise. + */ + catch (onrejected?: ((reason: any) => TResult | PromiseLike) | null): Promise { + return this._getOrCreateBeginPromise().catch(onrejected) + } + + /** + * Called when finally the begin is done + * + * @param {function()|null} onfinally - function when the promise finished + * @return {Promise} promise. + */ + finally (onfinally?: (() => void) | null): Promise { + return this._getOrCreateBeginPromise().finally(onfinally) + } + + private _getOrCreateBeginPromise (): Promise { + if (this._beginPromise == null) { + this._beginPromise = new Promise((resolve, reject) => { + this._resolve = resolve + this._reject = reject + if (this._beginError != null) { + reject(this._beginError) + } + if (this._beginMetadata != null) { + resolve(this._toTransaction()) + } + }) + } + return this._beginPromise + } + + /** + * @access private + */ + private _toTransaction (): Transaction { + return { + ...this, + run: super.run.bind(this), + commit: super.commit.bind(this), + rollback: super.rollback.bind(this), + close: super.close.bind(this), + isOpen: super.isOpen.bind(this), + _begin: this._begin.bind(this) + } + } + + /** + * @access private + */ + _begin (bookmarks: () => Promise, txConfig: TxConfig): void { + return super._begin(bookmarks, txConfig, { + onError: this._onBeginError.bind(this), + onComplete: this._onBeginMetadata.bind(this) + }) + } + + /** + * @access private + * @returns {void} + */ + private _onBeginError (error: Error): void { + this._beginError = error + if (this._reject != null) { + this._reject(error) + } + } + + /** + * @access private + * @returns {void} + */ + private _onBeginMetadata (metadata: any): void { + this._beginMetadata = metadata ?? {} + if (this._resolve != null) { + this._resolve(this._toTransaction()) + } + } +} + +export default TransactionPromise diff --git a/packages/neo4j-driver-deno/lib/core/transaction.ts b/packages/neo4j-driver-deno/lib/core/transaction.ts new file mode 100644 index 000000000..66c4c22e1 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/transaction.ts @@ -0,0 +1,699 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ + +/* eslint-disable @typescript-eslint/promise-function-async */ +import { validateQueryAndParameters } from './internal/util.ts' +import Connection from './connection.ts' +import { + ConnectionHolder, + ReadOnlyConnectionHolder, + EMPTY_CONNECTION_HOLDER +} from './internal/connection-holder.ts' +import { Bookmarks } from './internal/bookmarks.ts' +import { TxConfig } from './internal/tx-config.ts' + +import { + ResultStreamObserver, + FailedObserver, + CompletedObserver +} from './internal/observers.ts' + +import { newError } from './error.ts' +import Result from './result.ts' +import { Query } from './types.ts' + +/** + * Represents a transaction in the Neo4j database. + * + * @access public + */ +class Transaction { + private readonly _connectionHolder: ConnectionHolder + private readonly _reactive: boolean + private _state: any + private readonly _onClose: () => void + private readonly _onBookmarks: (newBookmarks: Bookmarks, previousBookmarks: Bookmarks, database?: string) => void + private readonly _onConnection: () => void + private readonly _onError: (error: Error) => Promise + private readonly _onComplete: (metadata: any, previousBookmarks?: Bookmarks) => void + private readonly _fetchSize: number + private readonly _results: any[] + private readonly _impersonatedUser?: string + private readonly _lowRecordWatermak: number + private readonly _highRecordWatermark: number + private _bookmarks: Bookmarks + private readonly _activePromise: Promise + private _acceptActive: () => void + + /** + * @constructor + * @param {ConnectionHolder} connectionHolder - the connection holder to get connection from. + * @param {function()} onClose - Function to be called when transaction is committed or rolled back. + * @param {function(bookmarks: Bookmarks)} onBookmarks callback invoked when new bookmark is produced. + * @param {function()} onConnection - Function to be called when a connection is obtained to ensure the conneciton + * is not yet released. + * @param {boolean} reactive whether this transaction generates reactive streams + * @param {number} fetchSize - the record fetch size in each pulling batch. + * @param {string} impersonatedUser - The name of the user which should be impersonated for the duration of the session. + * @param {number} highRecordWatermark - The high watermark for the record buffer. + * @param {number} lowRecordWatermark - The low watermark for the record buffer. + */ + constructor ({ + connectionHolder, + onClose, + onBookmarks, + onConnection, + reactive, + fetchSize, + impersonatedUser, + highRecordWatermark, + lowRecordWatermark + }: { + connectionHolder: ConnectionHolder + onClose: () => void + onBookmarks: (newBookmarks: Bookmarks, previousBookmarks: Bookmarks, database?: string) => void + onConnection: () => void + reactive: boolean + fetchSize: number + impersonatedUser?: string + highRecordWatermark: number + lowRecordWatermark: number + }) { + this._connectionHolder = connectionHolder + this._reactive = reactive + this._state = _states.ACTIVE + this._onClose = onClose + this._onBookmarks = onBookmarks + this._onConnection = onConnection + this._onError = this._onErrorCallback.bind(this) + this._fetchSize = fetchSize + this._onComplete = this._onCompleteCallback.bind(this) + this._results = [] + this._impersonatedUser = impersonatedUser + this._lowRecordWatermak = lowRecordWatermark + this._highRecordWatermark = highRecordWatermark + this._bookmarks = Bookmarks.empty() + this._acceptActive = () => { } // satisfy DenoJS + this._activePromise = new Promise((resolve, reject) => { + this._acceptActive = resolve + }) + } + + /** + * @private + * @param {Bookmarks | string | string []} bookmarks + * @param {TxConfig} txConfig + * @returns {void} + */ + _begin (getBookmarks: () => Promise, txConfig: TxConfig, events?: { + onError: (error: Error) => void + onComplete: (metadata: any) => void + }): void { + this._connectionHolder + .getConnection() + .then(async connection => { + this._onConnection() + if (connection != null) { + this._bookmarks = await getBookmarks() + return connection.protocol().beginTransaction({ + bookmarks: this._bookmarks, + txConfig: txConfig, + mode: this._connectionHolder.mode(), + database: this._connectionHolder.database(), + impersonatedUser: this._impersonatedUser, + beforeError: (error: Error) => { + if (events != null) { + events.onError(error) + } + return this._onError(error) + }, + afterComplete: (metadata: any) => { + if (events != null) { + events.onComplete(metadata) + } + return this._onComplete(metadata) + } + }) + } else { + throw newError('No connection available') + } + }) + .catch(error => { + if (events != null) { + events.onError(error) + } + this._onError(error).catch(() => {}) + }) + // It should make the transaction active anyway + // further errors will be treated by the existing + // observers + .finally(() => this._acceptActive()) + } + + /** + * Run Cypher query + * Could be called with a query object i.e.: `{text: "MATCH ...", parameters: {param: 1}}` + * or with the query and parameters as separate arguments. + * @param {mixed} query - Cypher query to execute + * @param {Object} parameters - Map with parameters to use in query + * @return {Result} New Result + */ + run (query: Query, parameters?: any): Result { + const { validatedQuery, params } = validateQueryAndParameters( + query, + parameters + ) + + const result = this._state.run(validatedQuery, params, { + connectionHolder: this._connectionHolder, + onError: this._onError, + onComplete: this._onComplete, + onConnection: this._onConnection, + reactive: this._reactive, + fetchSize: this._fetchSize, + highRecordWatermark: this._highRecordWatermark, + lowRecordWatermark: this._lowRecordWatermak, + preparationJob: this._activePromise + }) + this._results.push(result) + return result + } + + /** + * Commits the transaction and returns the result. + * + * After committing the transaction can no longer be used. + * + * @returns {Promise} An empty promise if committed successfully or error if any error happened during commit. + */ + commit (): Promise { + const committed = this._state.commit({ + connectionHolder: this._connectionHolder, + onError: this._onError, + onComplete: (meta: any) => this._onCompleteCallback(meta, this._bookmarks), + onConnection: this._onConnection, + pendingResults: this._results, + preparationJob: this._activePromise + }) + this._state = committed.state + // clean up + this._onClose() + return new Promise((resolve, reject) => { + committed.result.subscribe({ + onCompleted: () => resolve(), + onError: (error: any) => reject(error) + }) + }) + } + + /** + * Rollbacks the transaction. + * + * After rolling back, the transaction can no longer be used. + * + * @returns {Promise} An empty promise if rolled back successfully or error if any error happened during + * rollback. + */ + rollback (): Promise { + const rolledback = this._state.rollback({ + connectionHolder: this._connectionHolder, + onError: this._onError, + onComplete: this._onComplete, + onConnection: this._onConnection, + pendingResults: this._results, + preparationJob: this._activePromise + }) + this._state = rolledback.state + // clean up + this._onClose() + return new Promise((resolve, reject) => { + rolledback.result.subscribe({ + onCompleted: () => resolve(), + onError: (error: any) => reject(error) + }) + }) + } + + /** + * Check if this transaction is active, which means commit and rollback did not happen. + * @return {boolean} `true` when not committed and not rolled back, `false` otherwise. + */ + isOpen (): boolean { + return this._state === _states.ACTIVE + } + + /** + * Closes the transaction + * + * This method will roll back the transaction if it is not already committed or rolled back. + * + * @returns {Promise} An empty promise if closed successfully or error if any error happened during + */ + async close (): Promise { + if (this.isOpen()) { + await this.rollback() + } + } + + _onErrorCallback (): Promise { + // error will be "acknowledged" by sending a RESET message + // database will then forget about this transaction and cleanup all corresponding resources + // it is thus safe to move this transaction to a FAILED state and disallow any further interactions with it + this._state = _states.FAILED + this._onClose() + + // release connection back to the pool + return this._connectionHolder.releaseConnection() + } + + /** + * @private + * @param {object} meta The meta with bookmarks + * @returns {void} + */ + _onCompleteCallback (meta: { bookmark?: string | string[], db?: string }, previousBookmarks?: Bookmarks): void { + this._onBookmarks(new Bookmarks(meta?.bookmark), previousBookmarks ?? Bookmarks.empty(), meta?.db) + } +} + +/** + * Defines the structure of state transition function + * @private + */ +interface StateTransitionParams { + connectionHolder: ConnectionHolder + onError: (error: Error) => void + onComplete: (metadata: any) => void + onConnection: () => void + pendingResults: any[] + reactive: boolean + fetchSize: number + highRecordWatermark: number + lowRecordWatermark: number + preparationJob?: Promise +} + +const _states = { + // The transaction is running with no explicit success or failure marked + ACTIVE: { + commit: ({ + connectionHolder, + onError, + onComplete, + onConnection, + pendingResults, + preparationJob + }: StateTransitionParams): any => { + return { + result: finishTransaction( + true, + connectionHolder, + onError, + onComplete, + onConnection, + pendingResults, + preparationJob + ), + state: _states.SUCCEEDED + } + }, + rollback: ({ + connectionHolder, + onError, + onComplete, + onConnection, + pendingResults, + preparationJob + }: StateTransitionParams): any => { + return { + result: finishTransaction( + false, + connectionHolder, + onError, + onComplete, + onConnection, + pendingResults, + preparationJob + ), + state: _states.ROLLED_BACK + } + }, + run: ( + query: Query, + parameters: any, + { + connectionHolder, + onError, + onComplete, + onConnection, + reactive, + fetchSize, + highRecordWatermark, + lowRecordWatermark, + preparationJob + }: StateTransitionParams + ): any => { + // RUN in explicit transaction can't contain bookmarks and transaction configuration + // No need to include mode and database name as it shall be included in begin + const requirements = preparationJob ?? Promise.resolve() + + const observerPromise = + connectionHolder.getConnection() + .then(conn => requirements.then(() => conn)) + .then(conn => { + onConnection() + if (conn != null) { + return conn.protocol().run(query, parameters, { + bookmarks: Bookmarks.empty(), + txConfig: TxConfig.empty(), + beforeError: onError, + afterComplete: onComplete, + reactive: reactive, + fetchSize: fetchSize, + highRecordWatermark: highRecordWatermark, + lowRecordWatermark: lowRecordWatermark + }) + } else { + throw newError('No connection available') + } + }) + .catch(error => new FailedObserver({ error, onError })) + + return newCompletedResult( + observerPromise, + query, + parameters, + connectionHolder, + highRecordWatermark, + lowRecordWatermark + ) + } + }, + + // An error has occurred, transaction can no longer be used and no more messages will + // be sent for this transaction. + FAILED: { + commit: ({ + connectionHolder, + onError, + onComplete + }: StateTransitionParams): any => { + return { + result: newCompletedResult( + new FailedObserver({ + error: newError( + 'Cannot commit this transaction, because it has been rolled back either because of an error or explicit termination.' + ), + onError + }), + 'COMMIT', + {}, + connectionHolder, + 0, // high watermark + 0 // low watermark + ), + state: _states.FAILED + } + }, + rollback: ({ + connectionHolder, + onError, + onComplete + }: StateTransitionParams): any => { + return { + result: newCompletedResult( + new CompletedObserver(), + 'ROLLBACK', + {}, + connectionHolder, + 0, // high watermark + 0 // low watermark + ), + state: _states.FAILED + } + }, + run: ( + query: Query, + parameters: any, + { connectionHolder, onError, onComplete }: StateTransitionParams + ): any => { + return newCompletedResult( + new FailedObserver({ + error: newError( + 'Cannot run query in this transaction, because it has been rolled back either because of an error or explicit termination.' + ), + onError + }), + query, + parameters, + connectionHolder, + 0, // high watermark + 0 // low watermark + ) + } + }, + + // This transaction has successfully committed + SUCCEEDED: { + commit: ({ + connectionHolder, + onError, + onComplete + }: StateTransitionParams): any => { + return { + result: newCompletedResult( + new FailedObserver({ + error: newError( + 'Cannot commit this transaction, because it has already been committed.' + ), + onError + }), + 'COMMIT', + {}, + EMPTY_CONNECTION_HOLDER, + 0, // high watermark + 0 // low watermark + ), + state: _states.SUCCEEDED, + connectionHolder + } + }, + rollback: ({ + connectionHolder, + onError, + onComplete + }: StateTransitionParams): any => { + return { + result: newCompletedResult( + new FailedObserver({ + error: newError( + 'Cannot rollback this transaction, because it has already been committed.' + ), + onError + }), + 'ROLLBACK', + {}, + EMPTY_CONNECTION_HOLDER, + 0, // high watermark + 0 // low watermark + ), + state: _states.SUCCEEDED, + connectionHolder + } + }, + run: ( + query: Query, + parameters: any, + { connectionHolder, onError, onComplete }: StateTransitionParams + ): any => { + return newCompletedResult( + new FailedObserver({ + error: newError( + 'Cannot run query in this transaction, because it has already been committed.' + ), + onError + }), + query, + parameters, + connectionHolder, + 0, // high watermark + 0 // low watermark + ) + } + }, + + // This transaction has been rolled back + ROLLED_BACK: { + commit: ({ + connectionHolder, + onError, + onComplete + }: StateTransitionParams): any => { + return { + result: newCompletedResult( + new FailedObserver({ + error: newError( + 'Cannot commit this transaction, because it has already been rolled back.' + ), + onError + }), + 'COMMIT', + {}, + connectionHolder, + 0, // high watermark + 0 // low watermark + ), + state: _states.ROLLED_BACK + } + }, + rollback: ({ + connectionHolder, + onError, + onComplete + }: StateTransitionParams): any => { + return { + result: newCompletedResult( + new FailedObserver({ + error: newError( + 'Cannot rollback this transaction, because it has already been rolled back.' + ) + }), + 'ROLLBACK', + {}, + connectionHolder, + 0, // high watermark + 0 // low watermark + ), + state: _states.ROLLED_BACK + } + }, + run: ( + query: Query, + parameters: any, + { connectionHolder, onError, onComplete }: StateTransitionParams + ): any => { + return newCompletedResult( + new FailedObserver({ + error: newError( + 'Cannot run query in this transaction, because it has already been rolled back.' + ), + onError + }), + query, + parameters, + connectionHolder, + 0, // high watermark + 0 // low watermark + ) + } + } +} + +/** + * + * @param {boolean} commit + * @param {ConnectionHolder} connectionHolder + * @param {function(err:Error): any} onError + * @param {function(metadata:object): any} onComplete + * @param {function() : any} onConnection + * @param {list>}pendingResults all run results in this transaction + */ +function finishTransaction ( + commit: boolean, + connectionHolder: ConnectionHolder, + onError: (err: Error) => any, + onComplete: (metadata: any) => any, + onConnection: () => any, + pendingResults: Result[], + preparationJob?: Promise +): Result { + const requirements = preparationJob ?? Promise.resolve() + + const observerPromise = + connectionHolder.getConnection() + .then(conn => requirements.then(() => conn)) + .then(connection => { + onConnection() + pendingResults.forEach(r => r._cancel()) + return Promise.all(pendingResults.map(result => result.summary())).then(results => { + if (connection != null) { + if (commit) { + return connection.protocol().commitTransaction({ + beforeError: onError, + afterComplete: onComplete + }) + } else { + return connection.protocol().rollbackTransaction({ + beforeError: onError, + afterComplete: onComplete + }) + } + } else { + throw newError('No connection available') + } + }) + }) + .catch(error => new FailedObserver({ error, onError })) + + // for commit & rollback we need result that uses real connection holder and notifies it when + // connection is not needed and can be safely released to the pool + return new Result( + observerPromise, + commit ? 'COMMIT' : 'ROLLBACK', + {}, + connectionHolder, + { + high: Number.MAX_VALUE, + low: Number.MAX_VALUE + } + ) +} + +/** + * Creates a {@link Result} with empty connection holder. + * For cases when result represents an intermediate or failed action, does not require any metadata and does not + * need to influence real connection holder to release connections. + * @param {ResultStreamObserver} observer - an observer for the created result. + * @param {string} query - the cypher query that produced the result. + * @param {Object} parameters - the parameters for cypher query that produced the result. + * @param {ConnectionHolder} connectionHolder - the connection holder used to get the result + * @return {Result} new result. + * @private + */ +function newCompletedResult ( + observerPromise: ResultStreamObserver | Promise, + query: Query, + parameters: any, + connectionHolder: ConnectionHolder = EMPTY_CONNECTION_HOLDER, + highRecordWatermark: number, + lowRecordWatermark: number +): Result { + return new Result( + Promise.resolve(observerPromise), + query, + parameters, + new ReadOnlyConnectionHolder(connectionHolder ?? EMPTY_CONNECTION_HOLDER), + { + low: lowRecordWatermark, + high: highRecordWatermark + } + ) +} + +export default Transaction diff --git a/packages/neo4j-driver-deno/lib/core/types.ts b/packages/neo4j-driver-deno/lib/core/types.ts new file mode 100644 index 000000000..63140cc16 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/types.ts @@ -0,0 +1,81 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ + +/** + * @private + */ +export type Query = string | String | { text: string, parameters?: any } + +export type EncryptionLevel = 'ENCRYPTION_ON' | 'ENCRYPTION_OFF' + +export type LogLevel = 'error' | 'warn' | 'info' | 'debug' + +export type LoggerFunction = (level: LogLevel, message: string) => unknown + +export type SessionMode = 'READ' | 'WRITE' + +export interface LoggingConfig { + level?: LogLevel + logger: LoggerFunction +} + +export type TrustStrategy = + | 'TRUST_ALL_CERTIFICATES' + | 'TRUST_CUSTOM_CA_SIGNED_CERTIFICATES' + | 'TRUST_SYSTEM_CA_SIGNED_CERTIFICATES' + +export interface Parameters { [key: string]: any } +export interface AuthToken { + scheme: string + principal: string + credentials: string + realm?: string + parameters?: Parameters +} +export interface Config { + encrypted?: boolean | EncryptionLevel + trust?: TrustStrategy + trustedCertificates?: string[] + knownHosts?: string + fetchSize?: number + maxConnectionPoolSize?: number + maxTransactionRetryTime?: number + maxConnectionLifetime?: number + connectionAcquisitionTimeout?: number + connectionTimeout?: number + disableLosslessIntegers?: boolean + useBigInt?: boolean + logging?: LoggingConfig + resolver?: (address: string) => string[] | Promise + userAgent?: string +} + +/** + * Extension interface for {@link AsyncIterator} with peek capabilities. + * + * @public + */ +export interface PeekableAsyncIterator extends AsyncIterator { + /** + * Returns the next element in the iteration without advancing the iterator. + * + * @return {IteratorResult} The next element in the iteration. + */ + peek: () => Promise> +} diff --git a/packages/neo4j-driver-deno/lib/logging.ts b/packages/neo4j-driver-deno/lib/logging.ts new file mode 100644 index 000000000..57d81ef71 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/logging.ts @@ -0,0 +1,20 @@ +import { types as coreTypes } from './core/index.ts' + +type LogLevel = coreTypes.LogLevel + +/** + * Object containing predefined logging configurations. These are expected to be used as values of the driver config's `logging` property. + * @property {function(level: ?string): object} console the function to create a logging config that prints all messages to `console.log` with + * timestamp, level and message. It takes an optional `level` parameter which represents the maximum log level to be logged. Default value is 'info'. + */ +export const logging = { + console: (level: LogLevel) => { + return { + level: level, + logger: (level: LogLevel, message: string) => + console.log(`${Date.now()} ${level.toUpperCase()} ${message}`) + // Note: This 'logging' object is in its own file so we can easily access the global Date object here without conflicting + // with the Neo4j Date class, and without relying on 'globalThis' which isn't compatible with Node 10. + } + } +} diff --git a/packages/neo4j-driver-deno/lib/mod.ts b/packages/neo4j-driver-deno/lib/mod.ts new file mode 100644 index 000000000..0c221215b --- /dev/null +++ b/packages/neo4j-driver-deno/lib/mod.ts @@ -0,0 +1,537 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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 VERSION from './version.ts' +import { logging } from './logging.ts' + +import { + Neo4jError, + isRetriableError, + error, + Integer, + inSafeRange, + int, + isInt, + toNumber, + toString, + internal, + isPoint, + Point, + Date, + DateTime, + Duration, + isDate, + isDateTime, + isDuration, + isLocalDateTime, + isLocalTime, + isTime, + LocalDateTime, + LocalTime, + Time, + Node, + Path, + PathSegment, + Relationship, + UnboundRelationship, + Record, + ResultSummary, + Result, + ConnectionProvider, + Driver, + QueryResult, + ResultObserver, + Plan, + ProfiledPlan, + QueryStatistics, + Notification, + NotificationPosition, + Session, + Transaction, + ManagedTransaction, + TransactionPromise, + ServerInfo, + Connection, + driver as coreDriver, + types as coreTypes, + auth, + BookmarkManager, + bookmarkManager, + BookmarkManagerConfig, + SessionConfig +} from './core/index.ts' +// @deno-types=./bolt-connection/types/index.d.ts +import { + DirectConnectionProvider, + RoutingConnectionProvider +} from './bolt-connection/index.js' + +type AuthToken = coreTypes.AuthToken +type Config = coreTypes.Config +type TrustStrategy = coreTypes.TrustStrategy +type EncryptionLevel = coreTypes.EncryptionLevel +type SessionMode = coreTypes.SessionMode +type Logger = internal.logger.Logger +type ConfiguredCustomResolver = internal.resolver.ConfiguredCustomResolver + +const { READ, WRITE } = coreDriver + +const { + util: { ENCRYPTION_ON, assertString, isEmptyObjectOrNull }, + serverAddress: { ServerAddress }, + urlUtil +} = internal + +/** + * Construct a new Neo4j Driver. This is your main entry point for this + * library. + * + * ## Configuration + * + * This function optionally takes a configuration argument. Available configuration + * options are as follows: + * + * { + * // Encryption level: ENCRYPTION_ON or ENCRYPTION_OFF. + * encrypted: ENCRYPTION_ON|ENCRYPTION_OFF + * + * // Trust strategy to use if encryption is enabled. There is no mode to disable + * // trust other than disabling encryption altogether. The reason for + * // this is that if you don't know who you are talking to, it is easy for an + * // attacker to hijack your encrypted connection, rendering encryption pointless. + * // + * // TRUST_SYSTEM_CA_SIGNED_CERTIFICATES is the default choice. For NodeJS environments, this + * // means that you trust whatever certificates are in the default trusted certificate + * // store of the underlying system. For Browser environments, the trusted certificate + * // store is usually managed by the browser. Refer to your system or browser documentation + * // if you want to explicitly add a certificate as trusted. + * // + * // TRUST_CUSTOM_CA_SIGNED_CERTIFICATES is another option for trust verification - + * // whenever we establish an encrypted connection, we ensure the host is using + * // an encryption certificate that is in, or is signed by, a certificate given + * // as trusted through configuration. This option is only available for NodeJS environments. + * // + * // TRUST_ALL_CERTIFICATES means that you trust everything without any verifications + * // steps carried out. This option is only available for NodeJS environments and should not + * // be used on production systems. + * trust: "TRUST_SYSTEM_CA_SIGNED_CERTIFICATES" | "TRUST_CUSTOM_CA_SIGNED_CERTIFICATES" | + * "TRUST_ALL_CERTIFICATES", + * + * // List of one or more paths to trusted encryption certificates. This only + * // works in the NodeJS bundle, and only matters if you use "TRUST_CUSTOM_CA_SIGNED_CERTIFICATES". + * // The certificate files should be in regular X.509 PEM format. + * // For instance, ['./trusted.pem'] + * trustedCertificates: [], + * + * // The maximum total number of connections allowed to be managed by the connection pool, per host. + * // This includes both in-use and idle connections. No maximum connection pool size is imposed + * // by default. + * maxConnectionPoolSize: 100, + * + * // The maximum allowed lifetime for a pooled connection in milliseconds. Pooled connections older than this + * // threshold will be closed and removed from the pool. Such discarding happens during connection acquisition + * // so that new session is never backed by an old connection. Setting this option to a low value will cause + * // a high connection churn and might result in a performance hit. It is recommended to set maximum lifetime + * // to a slightly smaller value than the one configured in network equipment (load balancer, proxy, firewall, + * // etc. can also limit maximum connection lifetime). No maximum lifetime limit is imposed by default. Zero + * // and negative values result in lifetime not being checked. + * maxConnectionLifetime: 60 * 60 * 1000, // 1 hour + * + * // The maximum amount of time to wait to acquire a connection from the pool (to either create a new + * // connection or borrow an existing one. + * connectionAcquisitionTimeout: 60000, // 1 minute + * + * // Specify the maximum time in milliseconds transactions are allowed to retry via + * // `Session#executeRead()` and `Session#executeWrite()` functions. + * // These functions will retry the given unit of work on `ServiceUnavailable`, `SessionExpired` and transient + * // errors with exponential backoff using initial delay of 1 second. + * // Default value is 30000 which is 30 seconds. + * maxTransactionRetryTime: 30000, // 30 seconds + * + * // Specify socket connection timeout in milliseconds. Numeric values are expected. Negative and zero values + * // result in no timeout being applied. Connection establishment will be then bound by the timeout configured + * // on the operating system level. Default value is 30000, which is 30 seconds. + * connectionTimeout: 30000, // 30 seconds + * + * // Make this driver always return native JavaScript numbers for integer values, instead of the + * // dedicated {@link Integer} class. Values that do not fit in native number bit range will be represented as + * // `Number.NEGATIVE_INFINITY` or `Number.POSITIVE_INFINITY`. + * // **Warning:** ResultSummary It is not always safe to enable this setting when JavaScript applications are not the only ones + * // interacting with the database. Stored numbers might in such case be not representable by native + * // {@link Number} type and thus driver will return lossy values. This might also happen when data was + * // initially imported using neo4j import tool and contained numbers larger than + * // `Number.MAX_SAFE_INTEGER`. Driver will then return positive infinity, which is lossy. + * // Default value for this option is `false` because native JavaScript numbers might result + * // in loss of precision in the general case. + * disableLosslessIntegers: false, + * + * // Make this driver always return native Javascript {@link BigInt} for integer values, instead of the dedicated {@link Integer} class or {@link Number}. + * // + * // Default value for this option is `false` for backwards compatibility. + * // + * // **Warning:** `BigInt` doesn't implement the method `toJSON`. In maner of serialize it as `json`, It's needed to add a custom implementation of the `toJSON` on the + * // `BigInt.prototype` {@see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt#use_within_json} + * useBigInt: false, + * + * // Specify the logging configuration for the driver. Object should have two properties `level` and `logger`. + * // + * // Property `level` represents the logging level which should be one of: 'error', 'warn', 'info' or 'debug'. This property is optional and + * // its default value is 'info'. Levels have priorities: 'error': 0, 'warn': 1, 'info': 2, 'debug': 3. Enabling a certain level also enables all + * // levels with lower priority. For example: 'error', 'warn' and 'info' will be logged when 'info' level is configured. + * // + * // Property `logger` represents the logging function which will be invoked for every log call with an acceptable level. The function should + * // take two string arguments `level` and `message`. The function should not execute any blocking or long-running operations + * // because it is often executed on a hot path. + * // + * // No logging is done by default. See `neo4j.logging` object that contains predefined logging implementations. + * logging: { + * level: 'info', + * logger: (level, message) => console.log(level + ' ' + message) + * }, + * + * // Specify a custom server address resolver function used by the routing driver to resolve the initial address used to create the driver. + * // Such resolution happens: + * // * during the very first rediscovery when driver is created + * // * when all the known routers from the current routing table have failed and driver needs to fallback to the initial address + * // + * // In NodeJS environment driver defaults to performing a DNS resolution of the initial address using 'dns' module. + * // In browser environment driver uses the initial address as-is. + * // Value should be a function that takes a single string argument - the initial address. It should return an array of new addresses. + * // Address is a string of shape ':'. Provided function can return either a Promise resolved with an array of addresses + * // or array of addresses directly. + * resolver: function(address) { + * return ['127.0.0.1:8888', 'fallback.db.com:7687']; + * }, + * + * // Optionally override the default user agent name. + * userAgent: USER_AGENT + * } + * + * @param {string} url The URL for the Neo4j database, for instance "neo4j://localhost" and/or "bolt://localhost" + * @param {Map} authToken Authentication credentials. See {@link auth} for helpers. + * @param {Object} config Configuration object. See the configuration section above for details. + * @returns {Driver} + */ +function driver ( + url: string, + authToken: AuthToken, + config: Config = {} +): Driver { + assertString(url, 'Bolt URL') + const parsedUrl = urlUtil.parseDatabaseUrl(url) + + // Determine entryption/trust options from the URL. + let routing = false + let encrypted = false + let trust: TrustStrategy | undefined + switch (parsedUrl.scheme) { + case 'bolt': + break + case 'bolt+s': + encrypted = true + trust = 'TRUST_SYSTEM_CA_SIGNED_CERTIFICATES' + break + case 'bolt+ssc': + encrypted = true + trust = 'TRUST_ALL_CERTIFICATES' + break + case 'neo4j': + routing = true + break + case 'neo4j+s': + encrypted = true + trust = 'TRUST_SYSTEM_CA_SIGNED_CERTIFICATES' + routing = true + break + case 'neo4j+ssc': + encrypted = true + trust = 'TRUST_ALL_CERTIFICATES' + routing = true + break + default: + throw new Error(`Unknown scheme: ${parsedUrl.scheme ?? 'null'}`) + } + + // Encryption enabled on URL, propagate trust to the config. + if (encrypted) { + // Check for configuration conflict between URL and config. + if ('encrypted' in config || 'trust' in config) { + throw new Error( + 'Encryption/trust can only be configured either through URL or config, not both' + ) + } + config.encrypted = ENCRYPTION_ON + config.trust = trust + } + + // Sanitize authority token. Nicer error from server when a scheme is set. + authToken = authToken ?? {} + authToken.scheme = authToken.scheme ?? 'none' + + // Use default user agent or user agent specified by user. + config.userAgent = config.userAgent ?? USER_AGENT + const address = ServerAddress.fromUrl(parsedUrl.hostAndPort) + + const meta = { + address, + typename: routing ? 'Routing' : 'Direct', + routing + } + + return new Driver(meta, config, createConnectionProviderFunction()) + + function createConnectionProviderFunction (): (id: number, config: Config, log: Logger, hostNameResolver: ConfiguredCustomResolver) => ConnectionProvider { + if (routing) { + return ( + id: number, + config: Config, + log: Logger, + hostNameResolver: ConfiguredCustomResolver + ): ConnectionProvider => + new RoutingConnectionProvider({ + id, + config, + log, + hostNameResolver, + authToken, + address, + userAgent: config.userAgent, + routingContext: parsedUrl.query + }) + } else { + if (!isEmptyObjectOrNull(parsedUrl.query)) { + throw new Error( + `Parameters are not supported with none routed scheme. Given URL: '${url}'` + ) + } + + return (id: number, config: Config, log: Logger): ConnectionProvider => + new DirectConnectionProvider({ + id, + config, + log, + authToken, + address, + userAgent: config.userAgent + }) + } + } +} + +/** + * Verifies if the driver can reach a server at the given url. + * + * @experimental + * @since 5.0.0 + * @param {string} url The URL for the Neo4j database, for instance "neo4j://localhost" and/or "bolt://localhost" + * @param {Pick} config Configuration object. See the {@link driver} + * @returns {true} When the server is reachable + * @throws {Error} When the server is not reachable or the url is invalid + */ +async function hasReachableServer (url: string, config?: Pick): Promise { + const nonLoggedDriver = driver(url, { scheme: 'none', principal: '', credentials: '' }, config) + try { + await nonLoggedDriver.getNegotiatedProtocolVersion() + return true + } finally { + await nonLoggedDriver.close() + } +} + +const USER_AGENT: string = 'neo4j-javascript/' + VERSION + +/** + * Object containing constructors for all neo4j types. + */ +const types = { + Node, + Relationship, + UnboundRelationship, + PathSegment, + Path, + Result, + ResultSummary, + Record, + Point, + Date, + DateTime, + Duration, + LocalDateTime, + LocalTime, + Time, + Integer +} + +/** + * Object containing string constants representing session access modes. + */ +const session = { + READ, + WRITE +} + +/** + * Object containing functions to work with {@link Integer} objects. + */ +const integer = { + toNumber, + toString, + inSafeRange +} + +/** + * Object containing functions to work with spatial types, like {@link Point}. + */ +const spatial = { + isPoint +} + +/** + * Object containing functions to work with temporal types, like {@link Time} or {@link Duration}. + */ +const temporal = { + isDuration, + isLocalTime, + isTime, + isDate, + isLocalDateTime, + isDateTime +} + +/** + * @private + */ +const forExport = { + driver, + hasReachableServer, + int, + isInt, + isPoint, + isDuration, + isLocalTime, + isTime, + isDate, + isLocalDateTime, + isDateTime, + integer, + Neo4jError, + isRetriableError, + auth, + logging, + types, + session, + error, + spatial, + temporal, + Driver, + Result, + Record, + ResultSummary, + Node, + Relationship, + UnboundRelationship, + PathSegment, + Path, + Integer, + Plan, + ProfiledPlan, + QueryStatistics, + Notification, + ServerInfo, + Session, + Transaction, + ManagedTransaction, + TransactionPromise, + Point, + Duration, + LocalTime, + Time, + Date, + LocalDateTime, + DateTime, + ConnectionProvider, + Connection, + bookmarkManager +} + +export { + driver, + hasReachableServer, + int, + isInt, + isPoint, + isDuration, + isLocalTime, + isTime, + isDate, + isLocalDateTime, + isDateTime, + integer, + Neo4jError, + isRetriableError, + auth, + logging, + types, + session, + error, + spatial, + temporal, + Driver, + Result, + Record, + ResultSummary, + Node, + Relationship, + UnboundRelationship, + PathSegment, + Path, + Integer, + Plan, + ProfiledPlan, + QueryStatistics, + Notification, + ServerInfo, + Session, + Transaction, + ManagedTransaction, + TransactionPromise, + Point, + Duration, + LocalTime, + Time, + Date, + LocalDateTime, + DateTime, + ConnectionProvider, + Connection, + bookmarkManager +} +export type { + QueryResult, + AuthToken, + Config, + EncryptionLevel, + TrustStrategy, + SessionMode, + ResultObserver, + NotificationPosition, + BookmarkManager, + BookmarkManagerConfig, + SessionConfig +} +export default forExport diff --git a/packages/neo4j-driver-deno/lib/version.ts b/packages/neo4j-driver-deno/lib/version.ts new file mode 100644 index 000000000..b653b98b7 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/version.ts @@ -0,0 +1,20 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * 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. + */ + +export default "5.0.0-dev" // Specified using --version when running generate.ts diff --git a/testkit/build.py b/testkit/build.py index ad428a0b5..7fdbd7000 100644 --- a/testkit/build.py +++ b/testkit/build.py @@ -2,13 +2,12 @@ Executed in Javascript driver container. Responsible for building driver and test backend. """ -from common import is_deno, run, run_in_driver_repo, DRIVER_REPO +from common import is_deno, is_team_city, run, run_in_driver_repo, DRIVER_REPO import os def copy_files_to_workdir(): - run(["mkdir", DRIVER_REPO]) - run(["cp", "-fr", ".", DRIVER_REPO]) + run(["cp", "-fr", "./", DRIVER_REPO]) run(["chown", "-Rh", "driver:driver", DRIVER_REPO]) @@ -20,7 +19,13 @@ def init_monorepo(): def clean_and_build(): run_in_driver_repo(["npm", "run", "clean"], env=os.environ) run_in_driver_repo(["npm", "run", "build"], env=os.environ) - run_in_driver_repo(["npm", "run", "build::deno"], env=os.environ) + run_in_driver_repo(["npm", "run", "build::deno", "--", + "--output=lib2/"], env=os.environ) + + if is_deno() and is_team_city(): + run_in_driver_repo(["diff", "-r", "-u", + "packages/neo4j-driver-deno/lib/", + "packages/neo4j-driver-deno/lib2/"]) if __name__ == "__main__": diff --git a/testkit/common.py b/testkit/common.py index 93717b9d9..cd01753aa 100644 --- a/testkit/common.py +++ b/testkit/common.py @@ -46,3 +46,7 @@ def is_browser(): def is_deno(): return is_enabled(os.environ.get("TEST_DRIVER_DENO", "false")) + + +def is_team_city(): + return is_enabled(os.environ.get("TEST_IN_TEAMCITY", "false"))