diff --git a/package-lock.json b/package-lock.json index c0da99f..422bf3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,9 @@ "version": "1.0.13", "license": "MIT", "dependencies": { - "handlebars": "^4.7.8" + "@neondatabase/serverless": "^0.9.3", + "handlebars": "^4.7.8", + "ws": "^8.17.1" }, "bin": { "sync-database-models": "dist/generators/generate-models.js" @@ -17,6 +19,7 @@ "devDependencies": { "@jest/globals": "^29.7.0", "@types/node": "^20.12.12", + "@types/ws": "^8.5.10", "husky": "^9.0.11", "jest": "^29.7.0", "lint-staged": "^15.2.4", @@ -1251,6 +1254,14 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@neondatabase/serverless": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-0.9.3.tgz", + "integrity": "sha512-6ZBK8asl2Z3+ADEaELvbaVVGVlmY1oAzkxxZfpmXPKFuJhbDN+5fU3zYBamsahS/Ch1zE+CVWB3R+8QEI2LMSw==", + "dependencies": { + "@types/pg": "8.11.6" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -1377,17 +1388,35 @@ "version": "20.12.12", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz", "integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==", - "dev": true, "dependencies": { "undici-types": "~5.26.4" } }, + "node_modules/@types/pg": { + "version": "8.11.6", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.6.tgz", + "integrity": "sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^4.0.1" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "dev": true }, + "node_modules/@types/ws": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", + "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.32", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", @@ -4467,6 +4496,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -4593,6 +4627,44 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-numeric": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz", + "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==", + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-protocol": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.1.tgz", + "integrity": "sha512-jPIlvgoD63hrEuihvIg+tJhoGjUsLPn6poJY9N5CnlPd91c2T18T/9zBtLxZSb1EhYxBRoZJtzScCaWlYLtktg==" + }, + "node_modules/pg-types": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.0.2.tgz", + "integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==", + "dependencies": { + "pg-int8": "1.0.1", + "pg-numeric": "1.0.2", + "postgres-array": "~3.0.1", + "postgres-bytea": "~3.0.0", + "postgres-date": "~2.1.0", + "postgres-interval": "^3.0.0", + "postgres-range": "^1.1.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/picocolors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", @@ -4644,6 +4716,46 @@ "node": ">=8" } }, + "node_modules/postgres-array": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.2.tgz", + "integrity": "sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", + "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", + "dependencies": { + "obuf": "~1.1.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/postgres-date": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", + "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-interval": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", + "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-range": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz", + "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==" + }, "node_modules/prettier": { "version": "3.2.5", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", @@ -5284,8 +5396,7 @@ "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, "node_modules/update-browserslist-db": { "version": "1.0.16", @@ -5408,6 +5519,26 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 9806314..a45a0cc 100644 --- a/package.json +++ b/package.json @@ -31,11 +31,14 @@ "author": "Outerbase", "license": "MIT", "dependencies": { - "handlebars": "^4.7.8" + "@neondatabase/serverless": "^0.9.3", + "handlebars": "^4.7.8", + "ws": "^8.17.1" }, "devDependencies": { "@jest/globals": "^29.7.0", "@types/node": "^20.12.12", + "@types/ws": "^8.5.10", "husky": "^9.0.11", "jest": "^29.7.0", "lint-staged": "^15.2.4", diff --git a/playground/index.js b/playground/index.js index d7f20a2..766cd14 100644 --- a/playground/index.js +++ b/playground/index.js @@ -1,11 +1,28 @@ -import { CloudflareD1Connection, Outerbase, OuterbaseConnection, equalsNumber } from '../dist/index.js'; +import { CloudflareD1Connection, Outerbase, NeonHttpConnection, OuterbaseConnection, equalsNumber } from '../dist/index.js'; import express from 'express'; const app = express(); const port = 4000; app.get('/', async (req, res) => { - const data = {} + // Establish connection to your provider database + const d1 = new CloudflareD1Connection('API_KEY', 'ACCOUNT_ID', 'DATABASE_ID'); + const neon = new NeonHttpConnection({ + databaseUrl: 'postgresql://USER:PASSWORD@ep-damp-hill-a59vzq0g.us-east-2.aws.neon.tech/neondb?sslmode=require' + }); + + // Create an Outerbase instance from the data connection + await neon.connect(); + const db = Outerbase(neon); + + // SELECT: + // let { data, query } = await db.selectFrom([ + // { table: 'playing_with_neon', columns: ['id', 'name', 'value'] } + // ]) + // .where(equalsNumber('id', 1)) + // .query() + + let { data } = await db.queryRaw('SELECT * FROM playing_with_neon WHERE id = $1', ['1']); res.json(data); }); diff --git a/src/connections/neon-http.ts b/src/connections/neon-http.ts new file mode 100644 index 0000000..064eee9 --- /dev/null +++ b/src/connections/neon-http.ts @@ -0,0 +1,86 @@ +import { Client } from '@neondatabase/serverless'; +import ws from 'ws'; +import { Connection } from './index'; +import { Query, constructRawQuery } from '../query'; +import { QueryParamsPositional, QueryType } from '../query-params'; + +export type NeonConnectionDetails = { + databaseUrl: string +}; + +export class NeonHttpConnection implements Connection { + databaseUrl: string; + client: Client; + + // Default query type to named for Outerbase + queryType = QueryType.positional + + /** + * Creates a new NeonHttpConnection object with the provided API key, + * account ID, and database ID. + * + * @param databaseUrl - The URL to the database to be used for the connection. + */ + constructor(private _: NeonConnectionDetails) { + this.databaseUrl = _.databaseUrl; + + this.client = new Client(this.databaseUrl); + this.client.neonConfig.webSocketConstructor = ws; + } + + /** + * Performs a connect action on the current Connection object. + * + * @param details - Unused in the Neon scenario. + * @returns Promise + */ + async connect(): Promise { + return this.client.connect(); + } + + /** + * Performs a disconnect action on the current Connection object. + * + * @returns Promise + */ + async disconnect(): Promise { + return this.client.end(); + } + + /** + * Triggers a query action on the current Connection object. The query + * is a SQL query that will be executed on a Neon database. Neon's driver + * requires positional parameters to be used in the specific format of `$1`, + * `$2`, etc. The query is sent to the Neon database and the response is returned. + * + * @param query - The SQL query to be executed. + * @param parameters - An object containing the parameters to be used in the query. + * @returns Promise<{ data: any, error: Error | null }> + */ + async query(query: Query): Promise<{ data: any; error: Error | null; query: string }> { + let items = null + let error = null + + // Replace all `?` with `$1`, `$2`, etc. + let index = 0; + const formattedQuery = query.query.replace(/\?/g, () => `$${++index}`); + + try { + await this.client.query('BEGIN'); + const { rows } = await this.client.query(formattedQuery, query.parameters as QueryParamsPositional); + items = rows; + await this.client.query('COMMIT'); + } catch (error) { + await this.client.query('ROLLBACK'); + throw error; + } + + const rawSQL = constructRawQuery(query) + + return { + data: items, + error: error, + query: rawSQL + }; + } +}; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index d915917..6069579 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ export * from './connections'; export * from './connections/outerbase'; export * from './connections/cloudflare'; +export * from './connections/neon-http'; export * from './client'; export * from './models'; export * from './models/decorators'; diff --git a/tests/connections/neon.test.ts b/tests/connections/neon.test.ts new file mode 100644 index 0000000..ce8125d --- /dev/null +++ b/tests/connections/neon.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from '@jest/globals' + +import { NeonHttpConnection } from 'src/connections/neon-http' +import { QueryType } from 'src/query-params' + +describe('NeonHttpConnection', () => { + describe('Query Type', () => { + const connection = new NeonHttpConnection({ + databaseUrl: 'postgresql://USER:PASSWORD@some-random-string0g.us-east-2.aws.neon.tech/neondb?sslmode=require' + }) + + test('Query type is set to positional', () => { + expect(connection.queryType).toBe(QueryType.positional) + }) + + test('Query type is set not named', () => { + expect(connection.queryType).not.toBe(QueryType.named) + }) + }) +})