diff --git a/README_Refactor.md b/README_Refactor.md new file mode 100644 index 0000000..dd37810 --- /dev/null +++ b/README_Refactor.md @@ -0,0 +1,62 @@ +Refactor 12.23.24 +- Added SQLiteCloudClient class and createClient function +- Extracted PubSub from Database to SQLiteCloudClient +- Added fetch, customFetch support and fetchWithAuth +- Added Weblite methods for upload, download, delete, and listDatabases +- Refactored PubSub to be more intuitive and easier to use +- Added FileClient class and methods for file upload and download +- Added SQLiteCloudVectorClient class and methods for upsert and query + +Refactor Summary +- The Plan: + - Improve the usability of the SQLite Cloud platform by consolidating various features + under a single client with one consistent design and interface +The Objective: + - Provide a streamlined and consistent api for discovering, learning and using features on SQLite Cloud + - Improve the visibility of various features on the SQLite Cloud platform by providing explicit namespaces and methods for: + - functions + - file storage + - Pub/Sub + - Vector search + - Weblite (platform-level database management) + - database (core database connection) + - Increase adoption of SQLite Cloud's JS SDK by expanding our documentation. + - Provide a solid architecture for future SDK development. + - Goals: + - Streamline the onboarding and implementation process for building JS apps on SQLite Cloud + +Guidelines: + - Use consistent and scalable designs to improve readability, usability and maintainability. + +Scope of work: +- refactor new code to improve code smells and readability + - Recap progress. + - packages + - functions: + - Purpose: used to interact with edge functions deployed on the SQLite Cloud platform + - Value: removes need for custom client + - Objective: simplify the onboarding and use of edge functions to increase adoption + - storage: + - Purpose: used to store files, with an emphasis on images and photos + - Value: unifies development experience of handling transactional and non-transactional data + - Objective: simplify the development process + - pubsub: + - Purpose: used to interact with the SQLite Cloud pubsub platform + - Value: removes need for custom client + - Objective: simplify the onboarding and use of pubsub to increase adoption + - write tests for each new class + - Idenitfy protential issues + - Plan refactor with psuedo code + - Implement refactor + - Test refactor + +TODO: +- add error handling and logging +- add tests +- add comments +- add documentation + + +Out of scope: +- Auth module (awaiting auth.js merge) +- Vector search module \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index b73ae3f..7b7ce89 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/demo.ts b/demo.ts new file mode 100644 index 0000000..8c27f1d --- /dev/null +++ b/demo.ts @@ -0,0 +1,112 @@ + +/** + * Developer experience - current + * + */ + +import { Database } from '@sqlitecloud/drivers' +import { PUBSUB_ENTITY_TYPE } from '@sqlitecloud/drivers/lib/drivers/pubsub' // forces user to import pubsub constants from hard to remember location + +const db = new Database('connection-string') +const pubSub = await db.getPubSub() // couples database to pubsub + +/* Database methods */ +await db.sql`SELECT * FROM users` +db.exec('command') +db.run('command') +db.all('command') +db.each('command') +db.close() + +/* PubSub usage */ +/** Listen to a table */ +pubSub.listen(PUBSUB_ENTITY_TYPE.TABLE, 'users', (error, results, data) => { // note extraneous "data" + console.log(error, results, data) +}, ['extra data']) + +/** Listen to a channel */ +pubSub.listen(PUBSUB_ENTITY_TYPE.CHANNEL, 'messages', (error, results, data) => { + console.log(error, results, data) +}, ['extra data']) + +/** Create a channel */ +pubSub.createChannel('messages') + +/** Unlisten to a table */ +pubSub.unlisten(PUBSUB_ENTITY_TYPE.TABLE, 'users') + +/** Remove a channel (not currently exposed) */ +// @ts-ignore +pubSub.removeChannel('messages') + +/** Notify a channel */ +pubSub.notifyChannel('messages', 'my message') + + +/** + * Developer experience - refactored + * In the refactor, Database still exists and works as before. + */ + +import { createClient } from './src/packages/SQLiteCloudClient' + +const client = createClient('connection-string/chinook.db') + +// Promise sql query +const { data, error } = await client.sql`SELECT * FROM albums`; + +client.defaultDb = 'users'; // helper to set default database for SQL queries + +const { data: sessions, error: sessionsError } = await client.sql`SELECT * FROM sessions`; +// or +const result = client.db.exec('SELECT * FROM sessions') + +// Weblite +// upload database +const uploadDatabaseResponse = await client.weblite.upload('new_chinook.db', new File([''], 'new_chinook.db'), { replace: false }); + +// download database +const downloadDatabaseResponse = await client.weblite.download('new_chinook.db'); + +// delete database +const deleteDatabaseResponse = await client.weblite.delete('new_chinook.db'); + +// list databases +const listDatabasesResponse = await client.weblite.listDatabases(); + +// create database +const createDatabaseResponse = await client.weblite.create('new_chinook.db'); + +// SQLiteCloudFileClient +const createBucketResponse = await client.files.createBucket('myBucket'); +const getBucketResponse = await client.files.getBucket('myBucket'); +const deleteBucketResponse = await client.files.deleteBucket('myBucket'); +const listBucketsResponse = await client.files.listBuckets(); + +// upload file +const uploadFileResponse = await client.files.upload('myBucket', 'myPath', new File([''], 'myFile.txt'), { contentType: 'text/plain' }); + +// download file +const downloadFileResponse = await client.files.download('myBucket', 'myPath'); + +// remove file +const removeFileResponse = await client.files.remove('myBucket', 'myPath'); + + +// SQLiteCloudPubSubClient Refactor +await client.pubSub.create('messages') +await client.pubSub.notify('messages', 'my message') +await client.pubSub.subscribe('messages', (error, results) => { + console.log(error, results) +}) +client.pubSub.unsubscribe('messages') +await client.pubSub.delete('messages') + +await client.pubSub.listen({ tableName: 'users' }, (error, results) => { + console.log(error, results) +}) + +await client.pubSub.listen({ tableName: 'users', dbName: 'chinook.sqlite' }, (error, results) => { // note optional dbName + console.log(error, results) +}) + diff --git a/package-lock.json b/package-lock.json index 97d6b4e..71ce59f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@sqlitecloud/drivers", - "version": "1.0.354", + "version": "1.0.360", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@sqlitecloud/drivers", - "version": "1.0.354", + "version": "1.0.360", "license": "MIT", "dependencies": { "@craftzdog/react-native-buffer": "^6.0.5", diff --git a/package.json b/package.json index d398d99..fa0e813 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sqlitecloud/drivers", - "version": "1.0.354", + "version": "1.0.361", "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/constants.ts b/src/drivers/constants.ts new file mode 100644 index 0000000..94c096d --- /dev/null +++ b/src/drivers/constants.ts @@ -0,0 +1,22 @@ +const version = '0.0.1' +let JS_ENV = '' +// @ts-ignore +if (typeof Deno !== 'undefined') { + JS_ENV = 'deno' +} else if (typeof document !== 'undefined') { + JS_ENV = 'web' +} else if (typeof navigator !== 'undefined' && navigator.product === 'ReactNative') { + JS_ENV = 'react-native' +} else { + JS_ENV = 'node' +} + +export const DEFAULT_HEADERS = { + 'X-Client-Info': `sqlitecloud-js-${JS_ENV}/${version}`, +} +export const DEFAULT_GLOBAL_OPTIONS = { + headers: DEFAULT_HEADERS +} + +export const DEFAULT_API_VERSION = 'v2' +export const DEFAULT_API_PORT = 8090 diff --git a/src/drivers/database.ts b/src/drivers/database.ts index 85a80de..fb04c0d 100644 --- a/src/drivers/database.ts +++ b/src/drivers/database.ts @@ -14,8 +14,8 @@ import { popCallback } from './utilities' import { ErrorCallback, ResultsCallback, RowCallback, RowsCallback } from './types' import EventEmitter from 'eventemitter3' import { isBrowser } from './utilities' -import { PubSub } from './pubsub' import { Statement } from './statement' +import { PubSub } from './pubsub' // Uses eventemitter3 instead of node events for browser compatibility // https://github.com/primus/eventemitter3 @@ -472,31 +472,32 @@ export class Database extends EventEmitter { } else { // metadata for operations like insert, update, delete? const context = this.processContext(results) - resolve(context ? context : results) + const result = { data: context ? context : results, error: null } + resolve(result) } }) } }) }) } - - /** + /** * PubSub class provides a Pub/Sub real-time updates and notifications system to * allow multiple applications to communicate with each other asynchronously. * It allows applications to subscribe to tables and receive notifications whenever * data changes in the database table. It also enables sending messages to anyone * subscribed to a specific channel. * @returns {PubSub} A PubSub object + * DEPRECATED: use PubSubClient instead */ - public async getPubSub(): Promise { - return new Promise((resolve, reject) => { - this.getConnection((error, connection) => { - if (error || !connection) { - reject(error) - } else { - resolve(new PubSub(connection)) - } + public async getPubSub(): Promise { + return new Promise((resolve, reject) => { + this.getConnection((error, connection) => { + if (error || !connection) { + reject(error) + } else { + resolve(new PubSub(connection)) + } + }) }) - }) - } + } } diff --git a/src/drivers/pubsub.ts b/src/drivers/pubsub.ts index bc65c8c..0877a6d 100644 --- a/src/drivers/pubsub.ts +++ b/src/drivers/pubsub.ts @@ -27,7 +27,8 @@ export class PubSub { * @param data Extra data to be passed to the callback */ public async listen(entityType: PUBSUB_ENTITY_TYPE, entityName: string, callback: PubSubCallback, data?: any): Promise { - const entity = entityType === 'TABLE' ? 'TABLE ' : '' + // should not force user to import and pass in entity type + const entity = entityType === 'TABLE' ? 'TABLE ' : '' // should use PUBSUB_ENTITY_TYPE for check const authCommand: string = await this.connection.sql(`LISTEN ${entity}${entityName};`) diff --git a/src/drivers/types.ts b/src/drivers/types.ts index 6a1c7a3..7d8cdb4 100644 --- a/src/drivers/types.ts +++ b/src/drivers/types.ts @@ -135,7 +135,7 @@ export type ResultsCallback = (error: Error | null, results?: T) => voi export type RowsCallback> = (error: Error | null, rows?: T[]) => void export type RowCallback> = (error: Error | null, row?: T) => void export type RowCountCallback = (error: Error | null, rowCount?: number) => void -export type PubSubCallback = (error: Error | null, results?: T, extraData?: T) => void +export type PubSubCallback = (error: Error | null, results?: T, data?: any) => void /** * Certain responses include arrays with various types of metadata. @@ -160,3 +160,8 @@ export enum SQLiteCloudArrayType { ARRAY_TYPE_SQLITE_STATUS = 50 // used in sqlite_status } + +export type UploadOptions = { + replace?: boolean + headers?: Record +} diff --git a/src/packages/SQLiteCloudClient.ts b/src/packages/SQLiteCloudClient.ts new file mode 100644 index 0000000..08d2427 --- /dev/null +++ b/src/packages/SQLiteCloudClient.ts @@ -0,0 +1,96 @@ +import { Database } from '../drivers/database' +import { Fetch, fetchWithAuth } from './utils/fetch' +import { PubSubClient } from './_pubsub/PubSubClient' +import { WebliteClient } from './weblite/WebliteClient' +import { SQLiteCloudDataTypes, SQLiteCloudError } from '../drivers/types' +import { cleanConnectionString } from './utils' +import { SQLiteCloudClientConfig } from './types' +import { DEFAULT_HEADERS } from '../drivers/constants' + +const validateConfig = (config: SQLiteCloudClientConfig | string) => { + if (!(config)) throw new SQLiteCloudError('No configuration provided') + if (typeof config === 'string') { + if (!config.includes('sqlitecloud://')) throw new SQLiteCloudError('Invalid connection string') + } + + if (typeof config === 'object') { + if (!config.connectionString) throw new SQLiteCloudError('No connection string provided') + if (!config.connectionString.includes('sqlitecloud://')) throw new SQLiteCloudError('Invalid connection string') + } +} + +export class SQLiteCloudClient { + protected connectionString: string + protected fetch: Fetch + protected _globalHeaders: Record + protected _db: Database | null + protected _pubSub: PubSubClient | null + protected _weblite: WebliteClient | null + + constructor(config: SQLiteCloudClientConfig | string) { + try { + validateConfig(config) + let connectionString: string + let customFetch: Fetch | undefined + let globalHeaders: Record = {} + + if (typeof config === 'string') { + connectionString = cleanConnectionString(config) + globalHeaders = DEFAULT_HEADERS + } else { + connectionString = config.connectionString + customFetch = config.global?.fetch + globalHeaders = config.global?.headers ? { ...DEFAULT_HEADERS, ...config.global.headers } : DEFAULT_HEADERS + } + + this.connectionString = connectionString + this.fetch = fetchWithAuth(this.connectionString, customFetch) + this._globalHeaders = globalHeaders + this._db = null + this._pubSub = null + this._weblite = null + + } catch (error) { + throw new SQLiteCloudError('failed to initialize SQLiteCloudClient') + } + } + // Defaults to HTTP API + async sql(sql: TemplateStringsArray | string, ...values: SQLiteCloudDataTypes[]) { + return await this.weblite.sql(sql, ...values) + } + + get db() { + if (!this._db) { + this._db = new Database(this.connectionString) + } + return this._db + } + + get weblite() { + if (!this._weblite) { + this._weblite = new WebliteClient(this.connectionString, { + fetch: this.fetch, + headers: this._globalHeaders + }) + } + return this._weblite + } + + get pubSub() { + if (!this._pubSub) { + this._pubSub = new PubSubClient( + this.db + ) + } + return this._pubSub + } + + close() { + if (this._db) this._db.close() + if (this._pubSub) this._pubSub.close() + } +} + +export function createClient(config: SQLiteCloudClientConfig | string): SQLiteCloudClient { + return new SQLiteCloudClient(config) +} diff --git a/src/packages/_functions/FunctionsClient.ts b/src/packages/_functions/FunctionsClient.ts new file mode 100644 index 0000000..e341953 --- /dev/null +++ b/src/packages/_functions/FunctionsClient.ts @@ -0,0 +1,89 @@ +import { DEFAULT_HEADERS } from '../../drivers/constants' +import { SQLiteCloudError } from '../../drivers/types' +import { FUNCTIONS_ROOT_PATH } from '../constants' +import { getAPIUrl } from '../utils' +import { Fetch, resolveFetch } from '../utils/fetch' + +/** + * FunctionInvokeOptions + * @param args - The arguments to pass to the function. + * @param headers - The headers to pass to the function. + */ +interface FunctionInvokeOptions { + params: Record + headers?: Record + apiKey?: string +} + +/** + * FunctionsClient + * @param invoke - Invoke a function. + * @param setAuth - Set the authentication token. + */ +export class FunctionsClient { + protected url: string + protected fetch: Fetch + protected headers: Record + + constructor( + connectionString: string, + options: { + fetch?: Fetch, + headers?: Record + } = { + headers: {} + } + ) { + this.url = getAPIUrl(connectionString, FUNCTIONS_ROOT_PATH) + this.fetch = resolveFetch(options.fetch) + this.headers = { ...DEFAULT_HEADERS, ...options.headers } + } + // TODO: check authorization and api key setup in Gateway + setAuth(token: string) { + this.headers.Authorization = `Bearer ${token}` + } + + async invoke(functionId: string, options: FunctionInvokeOptions) { + let body; + let _headers: Record = {} + if (options.params && + ((options.headers && !Object.prototype.hasOwnProperty.call(options.headers, 'Content-Type')) || !options.headers) + ) { + if ( + (typeof Blob !== 'undefined' && options.params instanceof Blob) || + options.params instanceof ArrayBuffer + ) { + // will work for File as File inherits Blob + // also works for ArrayBuffer as it is the same underlying structure as a Blob + _headers['Content-Type'] = 'application/octet-stream' + body = options.params + } else if (typeof options.params === 'string') { + // plain string + _headers['Content-Type'] = 'text/plain' + body = options.params + } else if (typeof FormData !== 'undefined' && options.params instanceof FormData) { + _headers['Content-Type'] = 'multipart/form-data' + body = options.params + } else { + // default, assume this is JSON + _headers['Content-Type'] = 'application/json' + body = JSON.stringify(options.params) + } + } + + try { + const response = await this.fetch(`${this.url}/${functionId}`, { + method: 'POST', + body, + headers: { ..._headers, ...this.headers, ...options.headers } + }) + + if (!response.ok) { + throw new SQLiteCloudError(`Failed to invoke function: ${response.statusText}`) + } + return { error: null, ...(await response.json()) } + } catch (error) { + return { data: null, error } + } + } +} diff --git a/src/packages/_pubsub/PubSubClient.ts b/src/packages/_pubsub/PubSubClient.ts new file mode 100644 index 0000000..6d6512d --- /dev/null +++ b/src/packages/_pubsub/PubSubClient.ts @@ -0,0 +1,171 @@ +import { SQLiteCloudConnection } from '../../drivers/connection' +import SQLiteCloudTlsConnection from '../../drivers/connection-tls' +import { Database } from '../../drivers/database' +import { SQLiteCloudConfig } from '../../drivers/types' +import { getDefaultDatabase } from '../utils' + +/** + * PubSubCallback + * @param error - The error that occurred. + * @param results - The results of the operation. + */ +export type PubSubCallback = (error: Error | null, results?: T) => void + +/** + * ListenOptions + * @param tableName - The name of the table to listen to. + * @param dbName - The name of the database to listen to. + */ +export interface ListenOptions { + tableName: string + dbName?: string +} + +/** + * PubSub + * @param listen - Listen to a channel and start to receive messages to the provided callback. + * @param unlisten - Stop receive messages from a table or channel. + * @param subscribe - Subscribe to a channel. + * @param unsubscribe - Unsubscribe from a channel. + * @param create - Create a channel. + * @param delete - Delete a channel. + * @param notify - Send a message to a channel. + * @param setPubSubOnly - Set the connection to Pub/Sub only. + * @param connected - Check if the connection is open. + * @param close - Close the connection. + */ +export interface PubSub { + listen(options: ListenOptions, callback: PubSubCallback): Promise + unlisten(options: ListenOptions): void + subscribe(channelName: string, callback: PubSubCallback): Promise + unsubscribe(channelName: string): void + create(channelName: string, failIfExists: boolean): Promise + delete(channelName: string): Promise + notify(channelName: string, message: string): Promise + connected(): boolean + close(): void +} + +/** + * Pub/Sub class to receive changes on database tables or to send messages to channels. + */ +export class PubSubClient implements PubSub { + protected _pubSubConnection: SQLiteCloudConnection | null + protected _queryConnection: Database + protected defaultDatabase: string + protected config: SQLiteCloudConfig + + constructor(conn: Database) { + this.config = conn.getConfiguration() + this.defaultDatabase = this.config.database ?? '' + this._queryConnection = conn + this._pubSubConnection = null + } + + /** + * Listen to a channel and start to receive messages to the provided callback. + * @param options Options for the listen operation. If tablename and channelName are provided, channelName is used. + * If no options are provided, the default database name is used. + * @param callback Callback to be called when a message is received + */ + + private get pubSubConnection(): SQLiteCloudConnection { + if (!this._pubSubConnection) { + this._pubSubConnection = new SQLiteCloudTlsConnection(this.config) + } + return this._pubSubConnection + } + + async listen(options: ListenOptions, callback: PubSubCallback): Promise { + const _dbName = options.dbName ? options.dbName : this.defaultDatabase; + const authCommand: string = await this._queryConnection.sql`LISTEN TABLE ${options.tableName} DATABASE ${_dbName};` + + return new Promise((resolve, reject) => { + this.pubSubConnection.sendCommands(authCommand, (error, results) => { + if (error) { + callback.call(this, error, null) + reject(error) + } else { + // skip results from pubSub auth command + if (results !== 'OK') { + console.log(results) + callback.call(this, null, results) + } + resolve(results) + } + }) + }) + } + + /** + * Unlisten to a table. + * @param options Options for the unlisten operation. + */ + public async unlisten(options: ListenOptions): Promise { + return this._queryConnection.sql`UNLISTEN ${options.tableName} DATABASE ${options.dbName};` + } + + /** + * Subscribe (listen) to a channel. + * @param channelName The name of the channel to subscribe to. + * @param callback Callback to be called when a message is received. + */ + public async subscribe(channelName: string, callback: PubSubCallback): Promise { + const authCommand: string = await this._queryConnection.sql`LISTEN ${channelName};` + + return new Promise((resolve, reject) => { + this.pubSubConnection.sendCommands(authCommand, (error, results) => { + if (error) { + callback.call(this, error, null) + reject(error) + } else { + resolve(results) + } + }) + }) + } + + /** + * Unsubscribe (unlisten) from a channel. + * @param channelName The name of the channel to unsubscribe from. + */ + public async unsubscribe(channelName: string): Promise { + return this._queryConnection.sql`UNLISTEN ${channelName};` + } + + /** + * Create a channel to send messages to. + * @param name Channel name + * @param failIfExists Raise an error if the channel already exists + */ + public async create(channelName: string, failIfExists: boolean = true): Promise { + return this._queryConnection.sql( + `CREATE CHANNEL ?${failIfExists ? '' : ' IF NOT EXISTS'};`, channelName + ) + } + + /** + * Deletes a Pub/Sub channel. + * @param name Channel name + */ + public async delete(channelName: string): Promise { + return this._queryConnection.sql`REMOVE CHANNEL ${channelName};` + } + + /** + * Send a message to the channel. + */ + public async notify(channelName: string, message: string) { + return await this._queryConnection.sql`NOTIFY ${channelName} ${message};` + } + + /** True if Pub/Sub connection is open. */ + public connected(): boolean { + return this._pubSubConnection?.connected ?? false + } + + /** Close Pub/Sub connection. */ + public close(): void { + this._pubSubConnection?.close() + } +} diff --git a/src/packages/_storage/StorageClient.ts b/src/packages/_storage/StorageClient.ts new file mode 100644 index 0000000..b7c6846 --- /dev/null +++ b/src/packages/_storage/StorageClient.ts @@ -0,0 +1,178 @@ +import { DEFAULT_HEADERS } from "../../drivers/constants" +import { SQLiteCloudError } from "../../drivers/types" +import { getAPIUrl } from "../utils" +import { Fetch, fetchWithAuth } from "../utils/fetch" +import { Storage } from "../types" + +// TODO: add consistent return types + +export class StorageClient implements Storage { + protected filesUrl: string + protected webliteSQLUrl: string + protected headers: Record + protected fetch: Fetch + + constructor( + connectionString: string, + options: { + customFetch?: Fetch, + headers?: Record + } = { + headers: {} + }) { + this.filesUrl = getAPIUrl(connectionString, 'files') + this.webliteSQLUrl = getAPIUrl(connectionString, 'weblite/sql') + this.fetch = options.customFetch || fetchWithAuth(connectionString) + this.headers = { ...options.headers } + } + + async createBucket(bucket: string) { + try { + const response = await this.fetch(this.webliteSQLUrl, { + method: 'POST', + body: JSON.stringify({ + database: 'files.sqlite', + sql: `INSERT INTO files (Bucket, Pathname, Data) VALUES ('${bucket}', '/', '' );` } + ), + headers: this.headers, + }) + + if (!response.ok) { + throw new SQLiteCloudError(`Failed to create bucket: ${response.statusText}`) + } + + return await response.json(); + } catch (error) { + return { error, data: null, metadata: null } + } + } + + async getBucket(bucket: string) { + const url = `${this.filesUrl}/${bucket}` + const response = await this.fetch(url, { method: 'GET', headers: this.headers }) + if (!response.ok) { + throw new SQLiteCloudError(`Failed to get bucket: ${response.statusText}`) + } + + return { data: await response.json(), error: null } + } + + async deleteBucket(bucket: string) { + const url = `${this.filesUrl}/${bucket}` + try { + const response = await this.fetch(url, { method: 'DELETE', headers: this.headers }) + if (!response.ok) { + throw new SQLiteCloudError(`Failed to delete bucket: ${response.statusText}`) + } + return { data: await response.json(), error: null } + } catch (error) { + return { data: null, error } + } + } + + async listBuckets() { + const sql = `USE DATABASE files.sqlite; SELECT * FROM files;` + try { + const response = await this.fetch(this.webliteSQLUrl, { + method: 'POST', + body: JSON.stringify({ sql }), + headers: this.headers + }) + if (!response.ok) { + throw new SQLiteCloudError(`Failed to list buckets: ${response.statusText}`) + } + return { data: await response.json(), error: null } + } catch (error) { + return { + data: null, + error + } + } + } + + async upload(bucket: string, pathname: string, file: File | Buffer | Blob | string, options: { headers?: Record }) { + const url = `${this.filesUrl}/${bucket}/${pathname}`; + let _headers: Record = {} + if (file instanceof File) { + _headers['Content-Type'] = file.type + } else if (file instanceof Blob) { + _headers['Content-Type'] = file.type + } else if (file instanceof Buffer) { + _headers['Content-Type'] = 'application/octet-stream' + } else if (typeof file === 'string') { + _headers['Content-Type'] = 'text/plain' + } else { + _headers['Content-Type'] = 'application/json' + } + const headers = { + ..._headers, + ...options.headers, + ...this.headers + } + try { + const response = await this.fetch(url, { method: 'POST', body: file, headers }) + if (!response.ok) { + throw new SQLiteCloudError(`Failed to upload file: ${response.statusText}`) + } + return { data: await response.json(), error: null } + } catch (error) { + return { data: null, error } + } + } + + async download(bucket: string, pathname: string) { + const url = `${this.filesUrl}/${bucket}/${pathname}`; + try { + const response = await this.fetch(url, { method: 'GET' }) + if (!response.ok) { + throw new SQLiteCloudError(`Failed to download file: ${response.statusText}`) + } + let responseType = (response.headers.get('Content-Type') ?? 'text/plain').split(';')[0].trim() + let data: any + // TODO: add appropriate headers based on response type in Gateway + if (responseType === 'application/json') { + data = await response.json() + } else if (responseType === 'application/octet-stream') { + data = await response.blob() + } else if (responseType === 'multipart/form-data') { + data = await response.formData() + } else { + // default to text + data = await response.text() + } + return { data, error: null } + } catch (error) { + return { data: null, error } + } + } + + async remove(bucket: string, pathName: string) { + const url = `${this.filesUrl}/${bucket}/${pathName}` + try { + const response = await this.fetch(url, { method: 'DELETE' }) + if (!response.ok) { + throw new SQLiteCloudError(`Failed to remove file: ${response.statusText}`) + } + return { data: response.json(), error: null } + } catch (error) { + return { data: null, error } + } + } + + async listBucketContents(bucket: string) { + const sql = `USE DATABASE files.sqlite; SELECT * FROM files WHERE bucket = '${bucket}'` + try { + const response = await this.fetch(this.webliteSQLUrl, { + method: 'POST', + body: JSON.stringify({ sql }), + headers: this.headers + }) + if (!response.ok) { + throw new SQLiteCloudError(`Failed to list files: ${response.statusText}`) + } + return { data: await response.json(), error: null } + } catch (error) { + return { data: null, error } + } + } +} diff --git a/src/packages/_vector/VectorClient.ts b/src/packages/_vector/VectorClient.ts new file mode 100644 index 0000000..b77ad40 --- /dev/null +++ b/src/packages/_vector/VectorClient.ts @@ -0,0 +1,95 @@ +// import { Database } from "../../drivers/database"; + +// interface Column { +// name: string; +// type: string; +// partitionKey?: boolean; +// primaryKey?: boolean; +// } + +// interface IndexOptions { +// tableName: string; +// dimensions: number; +// columns: Column[]; +// binaryQuantization?: boolean; +// dbName?: string; +// } + +// type UpsertData = [Record & { id: string | number }][] + +// interface QueryOptions { +// topK: number, +// where?: string[] +// } + +// interface Vector { +// init(options: IndexOptions): Promise +// upsert(data: UpsertData): Promise +// query(queryEmbedding: number[], options: QueryOptions): Promise +// } + +// const DEFAULT_EMBEDDING_COLUMN_NAME = 'embedding' + +// const buildEmbeddingType = (dimensions: number, binaryQuantization: boolean) => { +// return `${binaryQuantization ? 'BIT' : 'FLOAT'}[${dimensions}]` +// } + +// const formatInitColumns = (opts: IndexOptions) => { +// const { columns, dimensions, binaryQuantization } = opts +// return columns.reduce((acc, column) => { +// let _type = column.type.toLowerCase(); +// const { name, primaryKey, partitionKey } = column +// if (_type === 'embedding') { +// _type = buildEmbeddingType(dimensions, !!binaryQuantization) +// } +// const formattedColumn = `${name} ${_type} ${primaryKey ? 'PRIMARY KEY' : ''}${partitionKey ? 'PARTITION KEY' : ''}` +// return `${acc}, ${formattedColumn}` +// }, '') +// } + +// function formatUpsertCommand(data: UpsertData): [any, any] { +// throw new Error("Function not implemented."); +// } + + +// export class VectorClient implements Vector { +// private _db: Database +// private _tableName: string +// private _columns: Column[] +// private _formattedColumns: string + +// constructor(_db: Database) { +// this._db = _db +// this._tableName = '' +// this._columns = [] +// this._formattedColumns = '' +// } + +// async init(options: IndexOptions) { +// const formattedColumns = formatInitColumns(options) +// this._tableName = options.tableName +// this._columns = options?.columns || [] +// this._formattedColumns = formattedColumns +// const useDbCommand = options?.dbName ? `USE DATABASE ${options.dbName}; ` : '' +// const hasTable = await this._db.sql`${useDbCommand}SELECT 1 FROM ${options.tableName} LIMIT 1;` + +// if (hasTable.length === 0) { // TODO - VERIFY CHECK HAS TABLE +// const query = `CREATE VIRTUAL TABLE ${options.tableName} USING vec0(${formattedColumns})` +// await this._db.sql(query) +// } +// return this +// } + +// async upsert(data: UpsertData) { +// const [formattedColumns, formattedValues] = formatUpsertCommand(data) +// const query = `INSERT INTO ${this._tableName}(${formattedColumns}) VALUES (${formattedValues})` +// return await this._db.sql(query) +// } + +// async query(queryEmbedding: number[], options: QueryOptions) { +// const query = `SELECT * FROM ${this._tableName} WHERE ${DEFAULT_EMBEDDING_COLUMN_NAME} match ${JSON.stringify(queryEmbedding)} and k = ${options.topK} and ${(options?.where?.join(' and ') || '')}` +// const result = await this._db.sql(query) +// return { data: result, error: null } +// } + +// } diff --git a/src/packages/constants/index.ts b/src/packages/constants/index.ts new file mode 100644 index 0000000..402e127 --- /dev/null +++ b/src/packages/constants/index.ts @@ -0,0 +1,2 @@ +export const FILES_DATABASE = 'files.sqlite' +export const FUNCTIONS_ROOT_PATH = 'functions' \ No newline at end of file diff --git a/src/packages/test/client.test.ts b/src/packages/test/client.test.ts new file mode 100644 index 0000000..d6c0298 --- /dev/null +++ b/src/packages/test/client.test.ts @@ -0,0 +1,35 @@ +import { CHINOOK_DATABASE_URL } from '../../../test/shared' +import { SQLiteCloudClient } from '../SQLiteCloudClient' + +const DEFAULT_TABLE_NAME = 'albums'; + + + +describe('SQLiteCloudClient test suite', () => { + it('should be able to create a client', () => { + const client = new SQLiteCloudClient(CHINOOK_DATABASE_URL) + expect(client).toBeDefined() + expect(client).toBeInstanceOf(SQLiteCloudClient) + }) + + it('should throw errors if no valid params are provided', () => { + expect(() => new SQLiteCloudClient('')).toThrow() + expect(() => new SQLiteCloudClient({ connectionString: '' })).toThrow() + expect(() => new SQLiteCloudClient({ connectionString: 'invalid' })).toThrow() + }) + + it('should be able to query the database via HTTP', async () => { + const client = new SQLiteCloudClient(CHINOOK_DATABASE_URL) + const { data, error } = await client.sql`SELECT * FROM ${DEFAULT_TABLE_NAME}`; + expect(data).toBeDefined() + expect(error).toBeNull() + }) + + it('should be able to query via database connection', async () => { + const client = new SQLiteCloudClient(CHINOOK_DATABASE_URL) + const { data, error } = await client.db.sql('SELECT * FROM albums') + expect(data).toBeDefined() + expect(error).toBeNull() + client.close() + }) +}) \ No newline at end of file diff --git a/src/packages/test/functions.test.ts b/src/packages/test/functions.test.ts new file mode 100644 index 0000000..4c400da --- /dev/null +++ b/src/packages/test/functions.test.ts @@ -0,0 +1,44 @@ + +// Test functions client +// invoke + +import { CHINOOK_API_KEY, CHINOOK_DATABASE_URL } from "../../../test/shared" +import { FunctionsClient } from "../_functions/FunctionsClient" + +const TEST_SQL_FUNCTION_ID = 'test-1-sql' +const TEST_JS_FUNCTION_ID = 'test-1-js' + +const TEST_FUNCTION_ARG = { + filter: 'a', + limit: 10 +} + +const functions = new FunctionsClient(CHINOOK_DATABASE_URL) + +describe('FunctionsClient', () => { + it('should invoke a JS function', async () => { + + const { data, error } = await functions.invoke(TEST_JS_FUNCTION_ID, { + params: TEST_FUNCTION_ARG, + headers: { + 'Authorization': `Bearer ${CHINOOK_API_KEY}` + } + }) + expect(data.message).toBeDefined() + expect(data.result).toBeDefined() + expect(error).toBeNull() + }) + + it('should invoke a SQL function', async () => { + const { data, error } = await functions.invoke(TEST_SQL_FUNCTION_ID, { + params: TEST_FUNCTION_ARG, + headers: { + 'Authorization': `Bearer ${CHINOOK_API_KEY}` + } + }) + expect(data).toBeDefined() + expect(data.length > 0).toBeTruthy() + expect(error).toBeNull() + }) +}) + diff --git a/src/packages/test/pubsub.test.ts b/src/packages/test/pubsub.test.ts new file mode 100644 index 0000000..878433a --- /dev/null +++ b/src/packages/test/pubsub.test.ts @@ -0,0 +1,210 @@ + +import { PubSubClient } from '../_pubsub/PubSubClient' +import { CHINOOK_DATABASE_URL, getChinookDatabase, LONG_TIMEOUT } from '../../../test/shared' +import { Database } from '../../drivers/database' + +const TABLE_NAME = 'albums'; + +describe('pubSub', () => { + it( + 'should listen, notify and receive pubSub messages on channel', + async () => { + const connection = getChinookDatabase() + const pubSub = new PubSubClient(connection) + + try { + const channelName = 'test-channel-' + crypto.randomUUID() + let callbackCalled = false + const message = 'Message in a bottle ' + Math.floor(Math.random() * 999) + + await pubSub.create(channelName, false) + + await pubSub.subscribe( + channelName, + (error, results) => { + expect(error).toBeNull() + expect(results).not.toBeNull() + expect(results['channel']).toEqual(channelName) + expect(results['payload']).toEqual(message) + callbackCalled = true + }, + ) + + await pubSub.notify(channelName, message) + + while (!callbackCalled) { + await new Promise(resolve => setTimeout(resolve, 1000)) + } + + expect(callbackCalled).toBeTruthy() + pubSub.delete(channelName) + } finally { + connection.close() + pubSub.close() + } + }, + ) + // it('should unlisten on channel', async () => { + // const connection = getChinookDatabase() + // const pubSub = await connection.getPubSub() + + // try { + // const channelName = 'test-channel-' + Math.floor(Math.random() * 999) + + // await pubSub.createChannel(channelName, false) + + // await pubSub.listen(PUBSUB_ENTITY_TYPE.CHANNEL, channelName, (error, results, data) => { + // expect(true).toBeFalsy() + // }) + + // let connections = await connection.sql`LIST PUBSUB CONNECTIONS;` + // let connectionExists = connections.find((row: SQLiteCloudRow) => row['chname'] === channelName) + // expect(connectionExists).toBeDefined() + + // await pubSub.unlisten(PUBSUB_ENTITY_TYPE.CHANNEL, channelName) + + // connections = await connection.sql`LIST PUBSUB CONNECTIONS;` + // connectionExists = connections.find((row: SQLiteCloudRow) => row['chname'] === channelName) + // expect(connectionExists).toBeUndefined() + // } finally { + // connection.close() + // pubSub.close() + // } + // }), + // it('should unlisten on table', async () => { + // const connection = getChinookDatabase() + // const pubSub = await connection.getPubSub() + + // try { + // let callbackCalled = false + + // const tableName = 'genres' + // await pubSub.listen(PUBSUB_ENTITY_TYPE.TABLE, tableName, (error, results, data) => { + // expect(true).toBeFalsy() + // callbackCalled = true + // }) + + // let connections = await connection.sql`LIST PUBSUB CONNECTIONS;` + // let connectionExists = connections.find((row: SQLiteCloudRow) => row['chname'] === tableName) + // expect(connectionExists).toBeDefined() + + // await pubSub.unlisten(PUBSUB_ENTITY_TYPE.TABLE, tableName) + + // await connection.sql`UPDATE genres SET Name = 'Rock' WHERE GenreId = 1` + + // // wait a moment to see if the callback is called + // await new Promise(resolve => setTimeout(resolve, 2000)) + + // expect(callbackCalled).toBeFalsy() + // } finally { + // connection.close() + // pubSub.close() + // } + // }), + // it('should fail to create a channel that already exists', async () => { + // const connection = getChinookDatabase() + // const pubSub = await connection.getPubSub() + + // try { + // const channelName = 'test-channel-' + crypto.randomUUID() + + // await pubSub.createChannel(channelName) + + // await expect(pubSub.createChannel(channelName, true)).rejects.toThrow(`Cannot create channel ${channelName} because it already exists.`) + // } finally { + // connection.close() + // pubSub.close() + // } + // }), + // it( + // 'should listen and receive pubSub messages on table', + // async () => { + // const connection = getChinookDatabase() + // const pubSub = await connection.getPubSub() + + // try { + // let callbackCalled = false + // const newName = 'Rock' + Math.floor(Math.random() * 999) + + // await pubSub.listen( + // PUBSUB_ENTITY_TYPE.TABLE, + // 'genres', + // (error, results, data) => { + // expect(error).toBeNull() + + // expect(results).not.toBeNull() + // expect(results['payload'][0]['type']).toEqual('UPDATE') + // expect(results['payload'][0]['Name']).toEqual(newName) + // expect(data).toEqual({ pippo: 'pluto' }) + // callbackCalled = true + // }, + // { pippo: 'pluto' } + // ) + + // await connection.sql`UPDATE genres SET Name = ${newName} WHERE GenreId = 1` + + // while (!callbackCalled) { + // await new Promise(resolve => setTimeout(resolve, 1000)) + // } + + // expect(callbackCalled).toBeTruthy() + // } finally { + // connection.close() + // pubSub.close() + // } + // }, + // LONG_TIMEOUT + // ), + // it('should be connected', async () => { + // const connection = getChinookDatabase() + // const pubSub = await connection.getPubSub() + + // try { + // expect(pubSub.connected()).toBeTruthy() + + // pubSub.close() + + // expect(pubSub.connected()).toBeFalsy() + // } finally { + // connection.close() + // pubSub.close() + // } + // }), + // it( + // 'should keep pubSub only connection', + // async () => { + // const connection = getChinookDatabase() + // const connection2 = getChinookDatabase() + // const pubSub = await connection.getPubSub() + + // try { + // let callbackCalled = false + // const newName = 'Rock' + Math.floor(Math.random() * 999) + + // await pubSub.listen(PUBSUB_ENTITY_TYPE.TABLE, 'genres', (error, results, data) => { + // expect(error).toBeNull() + // expect(results).not.toBeNull() + // callbackCalled = true + // }) + + // await pubSub.setPubSubOnly() + + // expect(connection.sql`SELECT 1`).rejects.toThrow('Connection not established') + // expect(pubSub.connected()).toBeTruthy() + + // await connection2.sql`UPDATE genres SET Name = ${newName} WHERE GenreId = 1` + + // while (!callbackCalled) { + // await new Promise(resolve => setTimeout(resolve, 1000)) + // } + + // expect(callbackCalled).toBeTruthy() + // } finally { + // connection.close() + // pubSub.close() + // connection2.close() + // } + // }, + // LONG_TIMEOUT + // ) +}) \ No newline at end of file diff --git a/src/packages/test/weblite.test.ts b/src/packages/test/weblite.test.ts new file mode 100644 index 0000000..da10645 --- /dev/null +++ b/src/packages/test/weblite.test.ts @@ -0,0 +1,45 @@ +import { CHINOOK_DATABASE_URL } from "../../../test/shared"; +import { WebliteClient } from "../weblite/WebliteClient"; + +const client = new WebliteClient(CHINOOK_DATABASE_URL) + +describe('WebliteClient test suite', () => { + const DATABASE_NAME = `${Date.now()}.sqlite` + + it('should be able to create a client', () => { + expect(client).toBeDefined() + expect(client).toBeInstanceOf(WebliteClient) + }) + + it('should be able to create a database', async () => { + const { data, error } = await client.createDatabase(DATABASE_NAME) + expect(data).toBeDefined() + expect(error).toBeNull() + }) + + it('should be able to list databases', async () => { + const { data, error } = await client.listDatabases() + expect(data).toBeDefined() + expect(data.length).toBeGreaterThan(0) + expect(error).toBeNull() + }) + + it('should be able to download and upload a database', async () => { + const { data, error } = await client.downloadDatabase(DATABASE_NAME) + expect(data).toBeDefined() + expect(error).toBeNull() + expect(data).toBeInstanceOf(ArrayBuffer) + const response = await client.uploadDatabase(DATABASE_NAME + '_upload', Buffer.from(data as ArrayBuffer)) + expect(response.data).toBeTruthy() + expect(response.error).toBeNull() + }) + + it('should be able to delete a database', async () => { + const deleteResponse = await client.deleteDatabase(DATABASE_NAME) + const deleteCopyResponse = await client.deleteDatabase(DATABASE_NAME + '_upload') + expect(deleteResponse.data).toBeDefined() + expect(deleteResponse.error).toBeNull() + expect(deleteCopyResponse.data).toBeDefined() + expect(deleteCopyResponse.error).toBeNull() + }) +}) diff --git a/src/packages/types/index.d.ts b/src/packages/types/index.d.ts new file mode 100644 index 0000000..3bc59ff --- /dev/null +++ b/src/packages/types/index.d.ts @@ -0,0 +1,54 @@ +import { Fetch } from '../utils/fetch' + + +export interface SQLiteCloudClientConfig { + connectionString: string + global?: { + fetch?: Fetch + headers?: Record + } +} + +export interface WebliteResponse { + data: any, // TODO: type this + error: SQLiteCloudError | null +} +export interface Weblite { + upload(dbName: string, file: File | Buffer | Blob | string, opts: UploadOptions): Promise + download(dbName: string): Promise + delete(dbName: string): Promise + listDatabases(): Promise + create(dbName: string): Promise +} + + +/** + * StorageResponse + * @param data - The data returned from the operation. + * @param error - The error that occurred. + */ +interface StorageResponse { + data: any + error: any +} + +/** + * Storage + * @param createBucket - Create a bucket. + * @param getBucket - Get a bucket. + * @param deleteBucket - Delete a bucket. + * @param listBuckets - List all buckets. + * @param upload - Upload a file. + * @param download - Download a file. + * @param remove - Remove a file. + * @param list - List all files in a bucket. + */ +interface Storage { + getBucket(bucket: string): Promise + deleteBucket(bucket: string): Promise + listBuckets(): Promise + upload(bucket: string, pathname: string, file: File | Buffer | Blob | string, options: { headers?: Record }): Promise + download(bucket: string, pathname: string): Promise + remove(bucket: string, pathName: string): Promise + listBucketContents(bucket: string): Promise +} \ No newline at end of file diff --git a/src/packages/utils/fetch.ts b/src/packages/utils/fetch.ts new file mode 100644 index 0000000..9901f28 --- /dev/null +++ b/src/packages/utils/fetch.ts @@ -0,0 +1,38 @@ +import nodeFetch, { Headers as NodeFetchHeaders } from 'node-fetch' + +export type Fetch = typeof fetch + +export const resolveFetch = (customFetch?: Fetch): Fetch => { + let _fetch: Fetch + if (customFetch) { + _fetch = customFetch + } else if (typeof fetch !== 'undefined') { + _fetch = fetch + } else { + _fetch = nodeFetch as unknown as Fetch + } + return _fetch +} + +export const resolveHeadersConstructor = () => { + if (typeof Headers === 'undefined') { + return NodeFetchHeaders + } + + return Headers +} + +// authorization is the connection string +export const fetchWithAuth = (authorization: string, customFetch?: Fetch): Fetch => { + const fetch = resolveFetch(customFetch) + const HeadersConstructor = resolveHeadersConstructor() + + return (input, init) => { + const headers = new HeadersConstructor(init?.headers) + if (!headers.has('Authorization')) { + headers.set('Authorization', `Bearer ${authorization}`) + } + // @ts-ignore + return fetch(input, { ...init, headers }) + } +} diff --git a/src/packages/utils/index.ts b/src/packages/utils/index.ts new file mode 100644 index 0000000..4d3e45f --- /dev/null +++ b/src/packages/utils/index.ts @@ -0,0 +1,54 @@ +import { DEFAULT_API_PORT, DEFAULT_API_VERSION } from "../../drivers/constants" +import { SQLiteCloudConfig } from "../../drivers/types" + +export const parseConnectionString = (connectionString: string) => { + const url = new URL(connectionString) + return { + host: url.hostname, + port: url.port, + database: url.pathname.slice(1), + apiKey: url.searchParams.get('apikey') + } +} + +export const getAPIUrl = (connectionString: string, path: string) => { + const { host } = parseConnectionString(connectionString) + return `https://${host}:${DEFAULT_API_PORT}/${DEFAULT_API_VERSION}/${path}` +} + +export const getDefaultDatabase = (connectionString: string) => { + const { database } = parseConnectionString(connectionString) + return database +} + +export const getDbFromConfig = (config: SQLiteCloudConfig) => new URL(config.connectionstring ?? '')?.pathname.split('/').pop() ?? '' +export const formatCommand = (arr: string[]) => arr.reduce((acc, curr) => curr.length > 0 ? (acc + ' ' + curr) : acc, '') + ';' + +/** + * Cleans and validates the SQLite Cloud connection string + * @param connectionString - The connection string to clean + * @returns The cleaned connection string + * @throws Error if connection string is invalid + * + * @example + * ```ts + * // Valid connection string + * cleanConnectionString('sqlitecloud://username:password@host:port/database') + * + * // Removes trailing slash + * cleanConnectionString('sqlitecloud://username:password@host:port/database/') + * + * // Throws error + * cleanConnectionString('invalid-connection-string') + * ``` + */ + +export const cleanConnectionString = (connectionString: string) => { + if (!connectionString.includes('sqlitecloud://')) { + throw new Error('Invalid connection string') + } + if (connectionString.endsWith('/')) { + return connectionString.slice(0, -1) + } + return connectionString +} \ No newline at end of file diff --git a/src/packages/weblite/WebliteClient.ts b/src/packages/weblite/WebliteClient.ts new file mode 100644 index 0000000..20b30dc --- /dev/null +++ b/src/packages/weblite/WebliteClient.ts @@ -0,0 +1,169 @@ +import { SQLiteCloudDataTypes, SQLiteCloudError, UploadOptions } from '../../drivers/types' +import { Fetch, fetchWithAuth } from '../utils/fetch' +import { DEFAULT_HEADERS } from '../../drivers/constants' +import { getAPIUrl, getDefaultDatabase } from '../utils' + +// Weblite Client - interact with SQLite Cloud via HTTP +export class WebliteClient { + protected baseUrl?: string // /weblite url + protected headers: Record + protected fetch: Fetch + protected _defaultDatabase?: string + + constructor( + connectionString: string, // sqlitecloud://xxx.xxx.xxx:port/database?apikey=xxx + options: { + fetch?: Fetch, + headers?: Record + } = { + headers: {} + } + ) { + this.baseUrl = getAPIUrl(connectionString, 'weblite') + this.fetch = options?.fetch || fetchWithAuth(connectionString) + this.headers = { ...DEFAULT_HEADERS, ...options.headers } + this._defaultDatabase = getDefaultDatabase(connectionString) + } + + async sql(sql: TemplateStringsArray | string, ...values: SQLiteCloudDataTypes[]) { + const url = `${this.baseUrl}/sql` + + try { + let _sql = this._defaultDatabase ? `USE DATABASE ${this._defaultDatabase}; ` : ''; + + if (Array.isArray(sql) && 'raw' in sql) { // check raw property? + sql.forEach((string, i) => { + // TemplateStringsArray splits the string before each variable + // used in the template. Add the question mark + // to the end of the string for the number of used variables. + _sql += string + (i < values.length ? '?' : '') + }) + } else if (typeof sql === 'string') { + _sql = _sql + sql + } else { + throw new SQLiteCloudError('Invalid sql') + } + + const response = await this.fetch(url, { + method: 'POST', + body: JSON.stringify({ sql: _sql, bind: values }), + headers: { ...this.headers, 'Content-Type': 'application/json' } + }) + + if (!response.ok) { + throw new SQLiteCloudError(`Failed to execute sql: ${response.statusText}`) + } + return { error: null, ...(await response.json()) } + } catch (error) { + return { data: null, error } + } + } + + get defaultDatabase() { + return this._defaultDatabase + } + // Set default database for .sql() calls + useDatabase(name: string) { + this._defaultDatabase = name + return this + } + + + async createDatabase(filename: string) { + return await this.sql`CREATE DATABASE ${filename}`; + } + + async uploadDatabase( + filename: string, + database: File | Buffer | Blob | string, + opts: UploadOptions = {} + ) { + const filenamePath = encodeURIComponent(filename) + const url = `${this.baseUrl}/${filenamePath}` + + let body: File | Buffer | Blob | string + if (database instanceof File) { + body = database + } else if (database instanceof Buffer) { + body = database + } else if (database instanceof Blob) { + body = database + } else { + // string + body = new Blob([database]) + } + + const headers = { + 'Content-Type': 'application/octet-stream', + ...(opts.headers ?? {}), + ...this.headers, + ...DEFAULT_HEADERS, + } + + const method = opts.replace ? 'PATCH' : 'POST' + + try { + const response = await this.fetch(url, { method, body, headers }) + if (!response.ok) { + throw new SQLiteCloudError(`Failed to upload database: ${response.statusText}`) + } + + return { error: null, ...(await response.json()) } + } catch (error) { + return { data: null, error } + } + } + + async downloadDatabase( + filename: string, + ) { + const filenamePath = encodeURIComponent(filename) + const url = `${this.baseUrl}/${filenamePath}` + try { + const response = await this.fetch(url, { method: 'GET', headers: this.headers }) + if (!response.ok) { + throw new SQLiteCloudError(`Failed to download database: ${response.statusText}`) + } + + const isNode = typeof window === 'undefined' + const data = isNode ? await response.arrayBuffer() : await response.blob() + return { error: null, data } + } catch (error) { + return { data: null, error } + } + } + + async deleteDatabase(filename: string) { + const filenamePath = encodeURIComponent(filename) + const url = `${this.baseUrl}/${filenamePath}` + try { + const response = await this.fetch( + url, + { + method: 'DELETE', + headers: this.headers + } + ) + if (!response.ok) { + throw new SQLiteCloudError(`Failed to delete database: ${response.statusText}`) + } + return { error: null, ...(await response.json()) } + } catch (error) { + return { data: null, error } + } + } + + async listDatabases() { + const url = `${this.baseUrl}/databases` + try { + const response = await this.fetch(url, { method: 'GET', headers: this.headers }) + if (!response.ok) { + throw new SQLiteCloudError(`Failed to list databases: ${response.statusText}`) + } + return { error: null, ...(await response.json()) } + } catch (error) { + return { data: null, error } + } + } +} + diff --git a/test/database.test.ts b/test/database.test.ts index a22bc90..fc719ef 100644 --- a/test/database.test.ts +++ b/test/database.test.ts @@ -10,11 +10,9 @@ import { removeDatabase, removeDatabaseAsync, LONG_TIMEOUT, - getChinookWebsocketConnection } from './shared' import { RowCountCallback } from '../src/drivers/types' import { expect, describe, it } from '@jest/globals' -import { Database } from 'sqlite3' // // utility methods to setup and destroy temporary test databases