diff --git a/.github/dependabot.yml b/.github/dependabot.yml index bc23406..bfd9062 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,10 +2,14 @@ # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - - package-ecosystem: "npm" - directory: "/" # Location of package.json + - package-ecosystem: 'npm' + directory: '/' # Location of package.json schedule: - interval: "weekly" + interval: 'weekly' + groups: + all-dependencies: + patterns: + - '*' # Always increase the version requirement # to match the new version. versioning-strategy: increase diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7965f1d..68c3cf2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,6 +3,10 @@ on: push: workflow_dispatch: +concurrency: + group: all + cancel-in-progress: true + env: DATABASE_URL: ${{ secrets.CHINOOK_DATABASE_URL }} @@ -81,7 +85,7 @@ jobs: node_test=$(curl localhost:3000) if [[ $node_test == *"{\"tracks\":[{\"TrackId\":1,\"Name\":\"For Those About To Rock (We Salute You)\",\"AlbumId\":1,\"MediaTypeId\":1,\"GenreId\":1,\"Composer\":\"Angus Young, Malcolm Young, Brian Johnson\",\"Milliseconds\":343719,\"Bytes\":11170334,\"UnitPrice\":0.99},{"* ]]; then echo "✅ node with-javascript-express test passed" - npx kill-port 3000 + npx kill-port 3000 -y exit 0 fi echo "❌ node with-javascript-express test failed" @@ -98,7 +102,7 @@ jobs: bun_test=$(curl localhost:3000) if [[ $bun_test == *"{\"tracks\":[{\"TrackId\":1,\"Name\":\"For Those About To Rock (We Salute You)\",\"AlbumId\":1,\"MediaTypeId\":1,\"GenreId\":1,\"Composer\":\"Angus Young, Malcolm Young, Brian Johnson\",\"Milliseconds\":343719,\"Bytes\":11170334,\"UnitPrice\":0.99},{"* ]]; then echo "✅ bun with-javascript-express test passed" - npx kill-port 3000 + npx kill-port 3000 -y exit 0 fi echo "❌ bun with-javascript-express test failed" @@ -112,7 +116,7 @@ jobs: deno_test=$(curl localhost:3000) if [[ $deno_test == *"{\"tracks\":[{\"TrackId\":1,\"Name\":\"For Those About To Rock (We Salute You)\",\"AlbumId\":1,\"MediaTypeId\":1,\"GenreId\":1,\"Composer\":\"Angus Young, Malcolm Young, Brian Johnson\",\"Milliseconds\":343719,\"Bytes\":11170334,\"UnitPrice\":0.99},{"* ]]; then echo "✅ deno with-javascript-express test passed" - npx kill-port 3000 + npx kill-port 3000 -y exit 0 fi echo "❌ deno with-javascript-express test failed" @@ -224,14 +228,14 @@ jobs: working-directory: examples/with-typescript-nextjs run: | npm i - npm run dev & + npx next dev -p 3005 & sleep 3 - node_test=$(curl localhost:3003) - node_test2=$(curl localhost:3003/api/hello) + node_test=$(curl localhost:3005) + node_test2=$(curl localhost:3005/api/hello) if [[ $node_test == *"next.js json route"* && $node_test2 == *"{\"data\":[{\"TrackId\":1,\"Name\":\"For Those About To Rock (We Salute You)\",\"AlbumId\":1,\"MediaTypeId\":1,\"GenreId\":1,\"Composer\":\"Angus Young, Malcolm Young, Brian Johnson\",\"Milliseconds\":343719,\"Bytes\":11170334,\"UnitPrice\":0.99},{"* ]]; then echo "✅ node with-typescript-nextjs test passed" - npx kill-port 3003 + npx kill-port 3005 -y exit 0 fi echo "❌ node with-typescript-nextjs test failed" @@ -243,13 +247,13 @@ jobs: if [ "$RUNNER_OS" != "Windows" ]; then bun i #re-installing dependencies in windows with bash causes a panic fi - bun run dev & + bun next dev -p 3004 & sleep 3 - bun_test=$(curl localhost:3003) - bun_test2=$(curl localhost:3003/api/hello) + bun_test=$(curl localhost:3004) + bun_test2=$(curl localhost:3004/api/hello) if [[ $bun_test == *"next.js json route"* && $bun_test2 == *"{\"data\":[{\"TrackId\":1,\"Name\":\"For Those About To Rock (We Salute You)\",\"AlbumId\":1,\"MediaTypeId\":1,\"GenreId\":1,\"Composer\":\"Angus Young, Malcolm Young, Brian Johnson\",\"Milliseconds\":343719,\"Bytes\":11170334,\"UnitPrice\":0.99},{"* ]]; then echo "✅ bun with-typescript-nextjs test passed" - npx kill-port 3003 + npx kill-port 3004 -y exit 0 fi echo "❌ bun with-typescript-nextjs test failed" @@ -264,13 +268,14 @@ jobs: deno_test=$(curl localhost:3003/api/hello) if [[ $deno_test == *"{\"data\":[{\"TrackId\":1,\"Name\":\"For Those About To Rock (We Salute You)\",\"AlbumId\":1,\"MediaTypeId\":1,\"GenreId\":1,\"Composer\":\"Angus Young, Malcolm Young, Brian Johnson\",\"Milliseconds\":343719,\"Bytes\":11170334,\"UnitPrice\":0.99},{"* ]]; then echo "✅ deno with-typescript-nextjs test passed" - npx kill-port 3003 + npx kill-port 3003 -y exit 0 fi echo "❌ deno with-typescript-nextjs test failed" exit 1 - name: remove with-typescript-nextjs + if: matrix.os != 'ubuntu-latest' #rm: cannot remove examples/with-typescript-nextjs: Directory not empty run: rm -rf examples/with-typescript-nextjs - name: node with-javascript-vite diff --git a/README.md b/README.md index 2a07d94..f5211b4 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ let database = new Database('sqlitecloud://user:password@xxx.sqlite.cloud:8860/c let name = 'Breaking The Rules' -let results = await database.sql`SELECT * FROM tracks WHERE name = ${name}` +let results = await database.sql('SELECT * FROM tracks WHERE name = ?', name) // => returns [{ AlbumId: 1, Name: 'Breaking The Rules', Composer: 'Angus Young... }] ``` @@ -82,7 +82,7 @@ await pubSub.listen(PUBSUB_ENTITY_TYPE.TABLE, 'albums', (error, results, data) = } }) -await database.sql`INSERT INTO albums (Title, ArtistId) values ('Brand new song', 1)` +await database.sql("INSERT INTO albums (Title, ArtistId) values ('Brand new song', 1)") // Stop listening changes on the table await pubSub.unlisten(PUBSUB_ENTITY_TYPE.TABLE, 'albums') diff --git a/examples/with-javascript-browser/index.html b/examples/with-javascript-browser/index.html index c2f0673..e5b9656 100644 --- a/examples/with-javascript-browser/index.html +++ b/examples/with-javascript-browser/index.html @@ -48,7 +48,7 @@

Results:

var database = null; sendButton.addEventListener('click', () => { - if (!database) { + if (!database || !database.isConnected()) { // Get the input element by ID var connectionStringinputElement = document.getElementById('connectionStringInput'); var connectionstring = connectionStringinputElement.value; diff --git a/examples/with-javascript-expo/components/AddTaskModal.js b/examples/with-javascript-expo/components/AddTaskModal.js index 5195205..57b6e47 100644 --- a/examples/with-javascript-expo/components/AddTaskModal.js +++ b/examples/with-javascript-expo/components/AddTaskModal.js @@ -2,7 +2,7 @@ import React, { useState, useEffect } from "react"; import { View, StyleSheet, Alert, Platform } from "react-native"; import { TextInput, Button, Modal } from "react-native-paper"; import DropdownMenu from "./DropdownMenu"; -import db from "../db/dbConnection"; +import getDbConnection from "../db/dbConnection"; export default AddTaskModal = ({ modalVisible, @@ -30,7 +30,7 @@ export default AddTaskModal = ({ const getTags = async () => { try { - const tags = await db.sql("SELECT * FROM tags"); + const tags = await getDbConnection().sql("SELECT * FROM tags"); setTagsList(tags); } catch (error) { console.error("Error getting tags", error); diff --git a/examples/with-javascript-expo/db/dbConnection.js b/examples/with-javascript-expo/db/dbConnection.js index 736b112..26958e4 100644 --- a/examples/with-javascript-expo/db/dbConnection.js +++ b/examples/with-javascript-expo/db/dbConnection.js @@ -1,4 +1,14 @@ import { DATABASE_URL } from "@env"; import { Database } from "@sqlitecloud/drivers"; -export default db = new Database(DATABASE_URL); \ No newline at end of file +/** + * @type {Database} + */ +let database = null; + +export default function getDbConnection() { + if (!database || !database.isConnected()) { + database = new Database(DATABASE_URL); + } + return database; +} \ No newline at end of file diff --git a/examples/with-javascript-expo/hooks/useCategories.js b/examples/with-javascript-expo/hooks/useCategories.js index a899fc7..db425b0 100644 --- a/examples/with-javascript-expo/hooks/useCategories.js +++ b/examples/with-javascript-expo/hooks/useCategories.js @@ -1,12 +1,12 @@ import { useState, useEffect } from "react"; -import db from "../db/dbConnection"; +import getDbConnection from "../db/dbConnection"; const useCategories = () => { const [moreCategories, setMoreCategories] = useState(["Work", "Personal"]); const getCategories = async () => { try { - const tags = await db.sql("SELECT * FROM tags"); + const tags = await getDbConnection().sql("SELECT * FROM tags"); const filteredTags = tags.filter((tag) => { return tag["name"] !== "Work" && tag["name"] !== "Personal"; }); @@ -21,7 +21,7 @@ const useCategories = () => { const addCategory = async (newCategory) => { try { - await db.sql( + await getDbConnection().sql( "INSERT INTO tags (name) VALUES (?) RETURNING *", newCategory ); @@ -33,15 +33,15 @@ const useCategories = () => { const initializeTables = async () => { try { - const createTasksTable = await db.sql( + const createTasksTable = await getDbConnection().sql( "CREATE TABLE IF NOT EXISTS tasks (id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, isCompleted INT NOT NULL);" ); - const createTagsTable = await db.sql( + const createTagsTable = await getDbConnection().sql( "CREATE TABLE IF NOT EXISTS tags (id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, UNIQUE(name));" ); - const createTagsTasksTable = await db.sql( + const createTagsTasksTable = await getDbConnection().sql( "CREATE TABLE IF NOT EXISTS tasks_tags (id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, task_id INTEGER NOT NULL, tag_id INTEGER NOT NULL, FOREIGN KEY (task_id) REFERENCES tasks(id), FOREIGN KEY (tag_id) REFERENCES tags(id));" ); @@ -52,8 +52,8 @@ const useCategories = () => { ) { console.log("Successfully created tables"); - await db.sql("INSERT OR IGNORE INTO tags (name) VALUES (?)", "Work"); - await db.sql( + await getDbConnection().sql("INSERT OR IGNORE INTO tags (name) VALUES (?)", "Work"); + await getDbConnection().sql( "INSERT OR IGNORE INTO tags (name) VALUES (?)", "Personal" ); diff --git a/examples/with-javascript-expo/hooks/useTasks.js b/examples/with-javascript-expo/hooks/useTasks.js index 4c9ba85..0a7ccad 100644 --- a/examples/with-javascript-expo/hooks/useTasks.js +++ b/examples/with-javascript-expo/hooks/useTasks.js @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from "react"; -import db from "../db/dbConnection"; +import getDbConnection from "../db/dbConnection"; const useTasks = (tag = null) => { const [taskList, setTaskList] = useState([]); @@ -8,7 +8,7 @@ const useTasks = (tag = null) => { try { let result; if (tag) { - result = await db.sql( + result = await getDbConnection().sql( ` SELECT tasks.*, tags.id AS tag_id, tags.name AS tag_name FROM tasks @@ -19,11 +19,11 @@ const useTasks = (tag = null) => { ); setTaskList(result); } else { - result = await db.sql` + result = await getDbConnection().sql(` SELECT tasks.*, tags.id AS tag_id, tags.name AS tag_name FROM tasks JOIN tasks_tags ON tasks.id = tasks_tags.task_id - JOIN tags ON tags.id = tasks_tags.tag_id`; + JOIN tags ON tags.id = tasks_tags.tag_id`); setTaskList(result); } } catch (error) { @@ -33,7 +33,7 @@ const useTasks = (tag = null) => { const updateTask = async (completedStatus, taskId) => { try { - await db.sql( + await getDbConnection().sql( "UPDATE tasks SET isCompleted=? WHERE id=? RETURNING *", completedStatus, taskId @@ -47,7 +47,7 @@ const useTasks = (tag = null) => { const addTaskTag = async (newTask, tag) => { try { if (tag.id) { - const addNewTask = await db.sql( + const addNewTask = await getDbConnection().sql( "INSERT INTO tasks (title, isCompleted) VALUES (?, ?) RETURNING *", newTask.title, newTask.isCompleted @@ -55,13 +55,13 @@ const useTasks = (tag = null) => { addNewTask[0].tag_id = tag.id; addNewTask[0].tag_name = tag.name; setTaskList([...taskList, addNewTask[0]]); - await db.sql( + await getDbConnection().sql( "INSERT INTO tasks_tags (task_id, tag_id) VALUES (?, ?)", addNewTask[0].id, tag.id ); } else { - const addNewTaskNoTag = await db.sql( + const addNewTaskNoTag = await getDbConnection().sql( "INSERT INTO tasks (title, isCompleted) VALUES (?, ?) RETURNING *", newTask.title, newTask.isCompleted @@ -75,8 +75,8 @@ const useTasks = (tag = null) => { const deleteTask = async (taskId) => { try { - await db.sql("DELETE FROM tasks_tags WHERE task_id=?", taskId); - const result = await db.sql("DELETE FROM tasks WHERE id=?", taskId); + await getDbConnection().sql("DELETE FROM tasks_tags WHERE task_id=?", taskId); + const result = await getDbConnection().sql("DELETE FROM tasks WHERE id=?", taskId); console.log(`Deleted ${result.totalChanges} task`); getTasks(); } catch (error) { diff --git a/examples/with-javascript-express/app.js b/examples/with-javascript-express/app.js index cdcf99a..ead1269 100644 --- a/examples/with-javascript-express/app.js +++ b/examples/with-javascript-express/app.js @@ -15,7 +15,7 @@ app.use(express.json()) /* http://localhost:3001/ returns chinook tracks as json */ app.get('/', async function (req, res, next) { var database = new sqlitecloud.Database(DATABASE_URL) - var tracks = await database.sql`USE DATABASE chinook.sqlite; SELECT * FROM tracks LIMIT 20;` + var tracks = await database.sql('USE DATABASE chinook.sqlite; SELECT * FROM tracks LIMIT 20;') res.send({ tracks }) }) diff --git a/examples/with-javascript-vite/src/App.jsx b/examples/with-javascript-vite/src/App.jsx index bff1ad4..cb79747 100644 --- a/examples/with-javascript-vite/src/App.jsx +++ b/examples/with-javascript-vite/src/App.jsx @@ -1,20 +1,29 @@ import { useEffect, useState } from "react"; import { Database } from "@sqlitecloud/drivers"; -const db = new Database(import.meta.env.VITE_DATABASE_URL); +let db = null + +function getDatabase() { + if (!db || !db.isConnected()) { + db = new Database(import.meta.env.VITE_DATABASE_URL); + } + + return db; +} + function App() { const [data, setData] = useState([]); const getAlbums = async () => { - const result = await db.sql` + const result = await getDatabase().sql(` USE DATABASE chinook.sqlite; SELECT albums.AlbumId as id, albums.Title as title, artists.name as artist FROM albums INNER JOIN artists WHERE artists.ArtistId = albums.ArtistId LIMIT 20; - `; + `); setData(result); }; diff --git a/examples/with-plain-javascript/app.js b/examples/with-plain-javascript/app.js index 43c1818..f0e44c4 100644 --- a/examples/with-plain-javascript/app.js +++ b/examples/with-plain-javascript/app.js @@ -13,7 +13,7 @@ async function selectTracks() { var database = new sqlitecloud.Database(DATABASE_URL) // run async query - var tracks = await database.sql`USE DATABASE chinook.sqlite; SELECT * FROM tracks LIMIT 20;` + var tracks = await database.sql('USE DATABASE chinook.sqlite; SELECT * FROM tracks LIMIT 20;') console.log(`selectTracks returned:`, tracks) // You can also use all the regular sqlite3 api with callbacks, see: diff --git a/examples/with-typescript-nextjs/app/api/hello/route.ts b/examples/with-typescript-nextjs/app/api/hello/route.ts index 5606ef9..a191eff 100644 --- a/examples/with-typescript-nextjs/app/api/hello/route.ts +++ b/examples/with-typescript-nextjs/app/api/hello/route.ts @@ -15,7 +15,7 @@ export async function GET(request: NextRequest) { const database = new Database(DATABASE_URL) // retrieve rows from chinook database using a plain SQL query - const tracks = await database.sql`USE DATABASE chinook.sqlite; SELECT * FROM tracks LIMIT 20;` + const tracks = await database.sql('USE DATABASE chinook.sqlite; SELECT * FROM tracks LIMIT 20;') // return as json response return NextResponse.json<{ data: any }>({ data: tracks }) diff --git a/examples/with-typescript-nextjs/app/page.tsx b/examples/with-typescript-nextjs/app/page.tsx index 15773a9..08d09e5 100644 --- a/examples/with-typescript-nextjs/app/page.tsx +++ b/examples/with-typescript-nextjs/app/page.tsx @@ -1,4 +1,3 @@ -import Image from 'next/image' import styles from './page.module.css' export default function Home() { diff --git a/examples/with-typescript-react-native/App.tsx b/examples/with-typescript-react-native/App.tsx index 7237bc3..b71df81 100644 --- a/examples/with-typescript-react-native/App.tsx +++ b/examples/with-typescript-react-native/App.tsx @@ -11,7 +11,7 @@ export default function App() { const db = new Database(`${DATABASE_URL}`); const result = - await db.sql`USE DATABASE chinook.sqlite; SELECT albums.AlbumId as id, albums.Title as title, artists.name as artist FROM albums INNER JOIN artists WHERE artists.ArtistId = albums.ArtistId LIMIT 20;`; + await db.sql('USE DATABASE chinook.sqlite; SELECT albums.AlbumId as id, albums.Title as title, artists.name as artist FROM albums INNER JOIN artists WHERE artists.ArtistId = albums.ArtistId LIMIT 20;'); setAlbums(result); } diff --git a/package-lock.json b/package-lock.json index dbb6823..94efd43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@sqlitecloud/drivers", - "version": "1.0.417", + "version": "1.0.422", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@sqlitecloud/drivers", - "version": "1.0.417", + "version": "1.0.422", "license": "MIT", "dependencies": { "buffer": "^6.0.3", diff --git a/package.json b/package.json index 8273976..1af498b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sqlitecloud/drivers", - "version": "1.0.417", + "version": "1.0.422", "description": "SQLiteCloud drivers for Typescript/Javascript in edge, web and node clients", "main": "./lib/index.js", "types": "./lib/index.d.ts", diff --git a/src/drivers/connection-tls.ts b/src/drivers/connection-tls.ts index c632a7e..ed872e7 100644 --- a/src/drivers/connection-tls.ts +++ b/src/drivers/connection-tls.ts @@ -2,21 +2,21 @@ * connection-tls.ts - connection via tls socket and sqlitecloud protocol */ -import { type SQLiteCloudConfig, SQLiteCloudError, type ErrorCallback, type ResultsCallback, SQLiteCloudCommand } from './types' import { SQLiteCloudConnection } from './connection' -import { getInitializationCommands } from './utilities' import { + bufferEndsWith, + CMD_COMPRESSED, + CMD_ROWSET_CHUNK, + decompressBuffer, formatCommand, hasCommandLength, parseCommandLength, - popData, - decompressBuffer, parseRowsetChunks, - CMD_COMPRESSED, - CMD_ROWSET_CHUNK, - bufferEndsWith, + popData, ROWSET_CHUNKS_END } from './protocol' +import { type ErrorCallback, type ResultsCallback, SQLiteCloudCommand, type SQLiteCloudConfig, SQLiteCloudError } from './types' +import { getInitializationCommands } from './utilities' // explicitly importing buffer library to allow cross-platform support by replacing it import { Buffer } from 'buffer' @@ -77,6 +77,10 @@ export class SQLiteCloudTlsConnection extends SQLiteCloudConnection { callback?.call(this, error) }) }) + this.socket.setKeepAlive(true) + // disable Nagle algorithm because we want our writes to be sent ASAP + // https://brooker.co.za/blog/2024/05/09/nagle.html + this.socket.setNoDelay(true) this.socket.on('data', data => { this.processCommandsData(data) @@ -97,6 +101,11 @@ export class SQLiteCloudTlsConnection extends SQLiteCloudConnection { this.processCommandsFinish(new SQLiteCloudError('Connection closed', { errorCode: 'ERR_CONNECTION_CLOSED' })) }) + this.socket.on('timeout', () => { + this.close() + this.processCommandsFinish(new SQLiteCloudError('Connection ened due to timeout', { errorCode: 'ERR_CONNECTION_TIMEOUT' })) + }) + return this } diff --git a/src/drivers/connection-ws.ts b/src/drivers/connection-ws.ts index cb85cd5..1c9e716 100644 --- a/src/drivers/connection-ws.ts +++ b/src/drivers/connection-ws.ts @@ -2,10 +2,10 @@ * transport-ws.ts - handles low level communication with sqlitecloud server via socket.io websocket */ -import { SQLiteCloudConfig, SQLiteCloudError, ErrorCallback, ResultsCallback, SQLiteCloudCommand, SQLiteCloudDataTypes } from './types' -import { SQLiteCloudRowset } from './rowset' -import { SQLiteCloudConnection } from './connection' import { io, Socket } from 'socket.io-client' +import { SQLiteCloudConnection } from './connection' +import { SQLiteCloudRowset } from './rowset' +import { ErrorCallback, ResultsCallback, SQLiteCloudCommand, SQLiteCloudConfig, SQLiteCloudError } from './types' /** * Implementation of TransportConnection that connects to the database indirectly @@ -19,7 +19,7 @@ export class SQLiteCloudWebsocketConnection extends SQLiteCloudConnection { /** True if connection is open */ get connected(): boolean { - return !!this.socket + return !!(this.socket && this.socket?.connected) } /* Opens a connection with the server and sends the initialization commands. Will throw in case of errors. */ @@ -32,11 +32,25 @@ export class SQLiteCloudWebsocketConnection extends SQLiteCloudConnection { const connectionstring = this.config.connectionstring as string const gatewayUrl = this.config?.gatewayurl || `${this.config.host === 'localhost' ? 'ws' : 'wss'}://${this.config.host as string}:4000` this.socket = io(gatewayUrl, { auth: { token: connectionstring } }) + + this.socket.on('connect', () => { + callback?.call(this, null) + }) + + this.socket.on('disconnect', (reason) => { + this.close() + callback?.call(this, new SQLiteCloudError('Disconnected', { errorCode: 'ERR_CONNECTION_ENDED', cause: reason })) + }) + + this.socket.on('error', (error: Error) => { + this.close() + callback?.call(this, new SQLiteCloudError('Connection error', { errorCode: 'ERR_CONNECTION_ERROR', cause: error })) + }) } - callback?.call(this, null) } catch (error) { callback?.call(this, error as Error) } + return this } @@ -78,11 +92,12 @@ export class SQLiteCloudWebsocketConnection extends SQLiteCloudConnection { public close(): this { console.assert(this.socket !== null, 'SQLiteCloudWebsocketConnection.close - connection already closed') if (this.socket) { + this.socket?.removeAllListeners() this.socket?.close() this.socket = undefined } + this.operations.clear() - this.socket = undefined return this } } diff --git a/src/drivers/database.ts b/src/drivers/database.ts index 1227c63..797a221 100644 --- a/src/drivers/database.ts +++ b/src/drivers/database.ts @@ -10,10 +10,11 @@ import EventEmitter from 'eventemitter3' import { SQLiteCloudConnection } from './connection' import { PubSub } from './pubsub' +import { OperationsQueue } from './queue' import { SQLiteCloudRowset } from './rowset' import { Statement } from './statement' import { - ErrorCallback, + ErrorCallback as ConnectionCallback, ResultsCallback, RowCallback, RowCountCallback, @@ -36,11 +37,12 @@ import { isBrowser, popCallback } from './utilities' */ export class Database extends EventEmitter { /** Create and initialize a database from a full configuration object, or connection string */ - constructor(config: SQLiteCloudConfig | string, callback?: ErrorCallback) - constructor(config: SQLiteCloudConfig | string, mode?: number, callback?: ErrorCallback) - constructor(config: SQLiteCloudConfig | string, mode?: number | ErrorCallback, callback?: ErrorCallback) { + constructor(config: SQLiteCloudConfig | string, callback?: ConnectionCallback) + constructor(config: SQLiteCloudConfig | string, mode?: number, callback?: ConnectionCallback) + constructor(config: SQLiteCloudConfig | string, mode?: number | ConnectionCallback, callback?: ConnectionCallback) { super() this.config = typeof config === 'string' ? { connectionstring: config } : config + this.connection = null // mode is optional and so is callback // https://github.com/TryGhost/node-sqlite3/wiki/API#new-sqlite3databasefilename--mode--callback @@ -51,8 +53,8 @@ export class Database extends EventEmitter { // mode is ignored for now - // opens first connection to the database automatically - this.getConnection((error, _connection) => { + // opens the connection to the database automatically + this.createConnection(error => { if (callback) { callback.call(this, error) } @@ -62,70 +64,84 @@ export class Database extends EventEmitter { /** Configuration used to open database connections */ private config: SQLiteCloudConfig - /** Database connections */ - private connections: SQLiteCloudConnection[] = [] + /** Database connection */ + private connection: SQLiteCloudConnection | null + + /** Used to syncronize opening of connection and commands */ + private operations = new OperationsQueue() // // private methods // /** Returns first available connection from connection pool */ - private getConnection(callback: ResultsCallback) { - // TODO sqlitecloud-js / implement database connection pool #10 - if (this.connections?.length > 0) { - callback?.call(this, null, this.connections[0]) - } else { - // connect using websocket if tls is not supported or if explicitly requested - const useWebsocket = isBrowser || this.config?.usewebsocket || this.config?.gatewayurl - if (useWebsocket) { - // socket.io transport works in both node.js and browser environments and connects via SQLite Cloud Gateway + private createConnection(callback: ConnectionCallback) { + // connect using websocket if tls is not supported or if explicitly requested + const useWebsocket = isBrowser || this.config?.usewebsocket || this.config?.gatewayurl + if (useWebsocket) { + // socket.io transport works in both node.js and browser environments and connects via SQLite Cloud Gateway + this.operations.enqueue(done => { import('./connection-ws') .then(module => { - this.connections.push( - new module.default(this.config, error => { - if (error) { - this.handleError(this.connections[0], error, callback) - } else { - console.assert - callback?.call(this, null, this.connections[0]) - this.emitEvent('open') - } - }) - ) + this.connection = new module.default(this.config, (error: Error | null) => { + if (error) { + this.handleError(error, callback) + } else { + callback?.call(this, null) + this.emitEvent('open') + } + + done(error) + }) }) .catch(error => { - this.handleError(null, error, callback) + this.handleError(error, callback) + done(error) }) - } else { - // tls sockets work only in node.js environments + }) + } else { + this.operations.enqueue(done => { import('./connection-tls') .then(module => { - this.connections.push( - new module.default(this.config, error => { - if (error) { - this.handleError(this.connections[0], error, callback) - } else { - console.assert - callback?.call(this, null, this.connections[0]) - this.emitEvent('open') - } - }) - ) + this.connection = new module.default(this.config, (error: Error | null) => { + if (error) { + this.handleError(error, callback) + } else { + callback?.call(this, null) + this.emitEvent('open') + } + + done(error) + }) }) .catch(error => { - this.handleError(null, error, callback) + this.handleError(error, callback) + done(error) }) - } + }) } } + private enqueueCommand(command: string | SQLiteCloudCommand, callback?: ResultsCallback): void { + this.operations.enqueue(done => { + let error: Error | null = null + + // we don't wont to silently open a new connection after a disconnession + if (this.connection && this.connection.connected) { + this.connection.sendCommands(command, callback) + } else { + error = new SQLiteCloudError('Connection unavailable. Maybe it got disconnected?', { errorCode: 'ERR_CONNECTION_NOT_ESTABLISHED' }) + this.handleError(error, callback) + } + + done(error) + }) + } + /** Handles an error by closing the connection, calling the callback and/or emitting an error event */ - private handleError(connection: SQLiteCloudConnection | null, error: Error, callback?: ErrorCallback): void { + private handleError(error: Error, callback?: ConnectionCallback): void { // an errored connection is thrown out - if (connection) { - this.connections = this.connections.filter(c => c !== connection) - connection.close() - } + this.connection?.close() if (callback) { callback.call(this, error) @@ -186,9 +202,8 @@ export class Database extends EventEmitter { /** Enable verbose mode */ public verbose(): this { this.config.verbose = true - for (const connection of this.connections) { - connection.verbose() - } + this.connection?.verbose() + return this } @@ -210,19 +225,13 @@ export class Database extends EventEmitter { public run(sql: string, ...params: any[]): this { const { args, callback } = popCallback(params) const command: SQLiteCloudCommand = { query: sql, parameters: args } - this.getConnection((error, connection) => { - if (error || !connection) { - this.handleError(null, error as Error, callback) + this.enqueueCommand(command, (error, results) => { + if (error) { + this.handleError(error, callback) } else { - connection.sendCommands(command, (error, results) => { - if (error) { - this.handleError(connection, error, callback) - } else { - // context may include id of last row inserted, total changes, etc... - const context = this.processContext(results) - callback?.call(context || this, null, context ? context : results) - } - }) + // context may include id of last row inserted, total changes, etc... + const context = this.processContext(results) + callback?.call(context || this, null, context ? context : results) } }) return this @@ -243,21 +252,15 @@ export class Database extends EventEmitter { public get(sql: string, ...params: any[]): this { const { args, callback } = popCallback(params) const command: SQLiteCloudCommand = { query: sql, parameters: args } - this.getConnection((error, connection) => { - if (error || !connection) { - this.handleError(null, error as Error, callback) + this.enqueueCommand(command, (error, results) => { + if (error) { + this.handleError(error, callback) } else { - connection.sendCommands(command, (error, results) => { - if (error) { - this.handleError(connection, error, callback) - } else { - if (results && results instanceof SQLiteCloudRowset && results.length > 0) { - callback?.call(this, null, results[0]) - } else { - callback?.call(this, null) - } - } - }) + if (results && results instanceof SQLiteCloudRowset && results.length > 0) { + callback?.call(this, null, results[0]) + } else { + callback?.call(this, null) + } } }) return this @@ -281,21 +284,15 @@ export class Database extends EventEmitter { public all(sql: string, ...params: any[]): this { const { args, callback } = popCallback(params) const command: SQLiteCloudCommand = { query: sql, parameters: args } - this.getConnection((error, connection) => { - if (error || !connection) { - this.handleError(null, error as Error, callback) + this.enqueueCommand(command, (error, results) => { + if (error) { + this.handleError(error, callback) } else { - connection.sendCommands(command, (error, results) => { - if (error) { - this.handleError(connection, error, callback) - } else { - if (results && results instanceof SQLiteCloudRowset) { - callback?.call(this, null, results) - } else { - callback?.call(this, null) - } - } - }) + if (results && results instanceof SQLiteCloudRowset) { + callback?.call(this, null, results) + } else { + callback?.call(this, null) + } } }) return this @@ -322,28 +319,22 @@ export class Database extends EventEmitter { const { args, callback, complete } = popCallback(params) const command: SQLiteCloudCommand = { query: sql, parameters: args } - this.getConnection((error, connection) => { - if (error || !connection) { - this.handleError(null, error as Error, callback) + this.enqueueCommand(command, (error, rowset) => { + if (error) { + this.handleError(error, callback) } else { - connection.sendCommands(command, (error, rowset) => { - if (error) { - this.handleError(connection, error, callback) - } else { - if (rowset && rowset instanceof SQLiteCloudRowset) { - if (callback) { - for (const row of rowset) { - callback.call(this, null, row) - } - } - if (complete) { - ;(complete as RowCountCallback).call(this, null, rowset.numberOfRows) - } - } else { - callback?.call(this, new SQLiteCloudError('Invalid rowset')) + if (rowset && rowset instanceof SQLiteCloudRowset) { + if (callback) { + for (const row of rowset) { + callback.call(this, null, row) } } - }) + if (complete) { + ;(complete as RowCountCallback).call(this, null, rowset.numberOfRows) + } + } else { + callback?.call(this, new SQLiteCloudError('Invalid rowset')) + } } }) return this @@ -370,19 +361,13 @@ export class Database extends EventEmitter { * object. When no callback is provided and an error occurs, an error event * will be emitted on the database object. */ - public exec(sql: string, callback?: ErrorCallback): this { - this.getConnection((error, connection) => { - if (error || !connection) { - this.handleError(null, error as Error, callback) + public exec(sql: string, callback?: ConnectionCallback): this { + this.enqueueCommand(sql, (error, results) => { + if (error) { + this.handleError(error, callback) } else { - connection.sendCommands(sql, (error, results) => { - if (error) { - this.handleError(connection, error, callback) - } else { - const context = this.processContext(results) - callback?.call(context ? context : this, null) - } - }) + const context = this.processContext(results) + callback?.call(context ? context : this, null) } }) return this @@ -396,12 +381,10 @@ export class Database extends EventEmitter { * will be emitted on the database object. If closing succeeded, a close event with no * parameters is emitted, regardless of whether a callback was provided or not. */ - public close(callback?: ErrorCallback): void { - if (this.connections?.length > 0) { - for (const connection of this.connections) { - connection.close() - } - } + public close(callback?: ConnectionCallback): void { + this.operations.clear() + this.connection?.close() + callback?.call(this, null) this.emitEvent('close') } @@ -415,7 +398,7 @@ export class Database extends EventEmitter { * and an error occurred, an error event with the error object as the only parameter * will be emitted on the database object. */ - public loadExtension(_path: string, callback?: ErrorCallback): this { + public loadExtension(_path: string, callback?: ConnectionCallback): this { // TODO sqlitecloud-js / implement database loadExtension #17 if (callback) { callback.call(this, new Error('Database.loadExtension - Not implemented')) @@ -470,19 +453,13 @@ export class Database extends EventEmitter { } return new Promise((resolve, reject) => { - this.getConnection((error, connection) => { - if (error || !connection) { + this.enqueueCommand(commands, (error, results) => { + if (error) { reject(error) } else { - connection.sendCommands(commands, (error, results) => { - if (error) { - reject(error) - } else { - // metadata for operations like insert, update, delete? - const context = this.processContext(results) - resolve(context ? context : results) - } - }) + // metadata for operations like insert, update, delete? + const context = this.processContext(results) + resolve(context ? context : results) } }) }) @@ -492,7 +469,7 @@ export class Database extends EventEmitter { * Returns true if the database connection is open. */ public isConnected(): boolean { - return this.connections?.length > 0 && this.connections[0].connected + return this.connection != null && this.connection.connected } /** @@ -505,11 +482,17 @@ export class Database extends EventEmitter { */ public async getPubSub(): Promise { return new Promise((resolve, reject) => { - this.getConnection((error, connection) => { - if (error || !connection) { - reject(error) - } else { - resolve(new PubSub(connection)) + this.operations.enqueue(done => { + let error = null + try { + if (!this.connection) { + error = new SQLiteCloudError('Connection not established', { errorCode: 'ERR_CONNECTION_NOT_ESTABLISHED' }) + reject(error) + } else { + resolve(new PubSub(this.connection)) + } + } finally { + done(error) } }) }) diff --git a/src/drivers/pubsub.ts b/src/drivers/pubsub.ts index bc65c8c..45ae859 100644 --- a/src/drivers/pubsub.ts +++ b/src/drivers/pubsub.ts @@ -77,14 +77,14 @@ export class PubSub { * @param name Channel name */ public async removeChannel(name: string): Promise { - return this.connection.sql(`REMOVE CHANNEL ?;`, name) + return this.connection.sql('REMOVE CHANNEL ?;', name) } /** * Send a message to the channel. */ public notifyChannel(channelName: string, message: string): Promise { - return this.connection.sql`NOTIFY ${channelName} ${message};` + return this.connection.sql('NOTIFY ? ?;', channelName, message) } /** diff --git a/test/connection-tls.test.ts b/test/connection-tls.test.ts index e0183ce..8425004 100644 --- a/test/connection-tls.test.ts +++ b/test/connection-tls.test.ts @@ -53,8 +53,22 @@ describe('connect', () => { }) */ +it( + 'should connect with config object string', + done => { + const configObj = getChinookConfig() + const connection = new SQLiteCloudTlsConnection(configObj, error => { + expect(error).toBeNull() + expect(connection.connected).toBe(true) + connection.close() + done() + }) + }, + LONG_TIMEOUT +) + it( - 'should connect with config object string', + 'should connect with config object string and test command', done => { const configObj = getChinookConfig() const connection = new SQLiteCloudTlsConnection(configObj, error => { @@ -67,7 +81,6 @@ describe('connect', () => { done() }) }) - expect(connection).toBeDefined() }, LONG_TIMEOUT ) @@ -88,7 +101,6 @@ describe('connect', () => { done() }) }) - expect(connection).toBeDefined() } catch (error) { console.error(`An error occurred while connecting using api key: ${error}`) debugger @@ -127,7 +139,6 @@ describe('connect', () => { } }) }) - expect(connection).toBeDefined() }) it('should connect with insecure connection string', done => { diff --git a/test/connection-ws.test.ts b/test/connection-ws.test.ts index 77f9c0e..2772fd3 100644 --- a/test/connection-ws.test.ts +++ b/test/connection-ws.test.ts @@ -40,9 +40,9 @@ describe('connection-ws', () => { let connection: SQLiteCloudWebsocketConnection | null = null connection = new SQLiteCloudWebsocketConnection(configObj, error => { expect(error).toBeNull() + connection?.close() done() }) - connection?.close() }) it('should not connect with incorrect credentials', done => { @@ -55,8 +55,8 @@ describe('connection-ws', () => { const connection = new SQLiteCloudWebsocketConnection(configObj, error => { expect(error).toBeDefined() done() + connection?.close() }) - connection?.close() }) /* TODO RESTORE TEST it('should connect with connection string', done => { diff --git a/test/pubsub.test.ts b/test/pubsub.test.ts index 546cbca..8564954 100644 --- a/test/pubsub.test.ts +++ b/test/pubsub.test.ts @@ -191,7 +191,7 @@ describe('pubSub', () => { await pubSub.setPubSubOnly() - expect(connection.sql`SELECT 1`).rejects.toThrow('Connection not established') + expect(connection.sql`SELECT 1`).rejects.toThrow('Connection unavailable. Maybe it got disconnected?') expect(pubSub.connected()).toBeTruthy() await connection2.sql`UPDATE genres SET Name = ${newName} WHERE GenreId = 1`