From aea2c03e3396ca095909a96c69fda0b2223a5efd Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Tue, 19 Aug 2025 01:19:58 +0000 Subject: [PATCH] Resolve "feat(SQLite): Create SQLite Storage Provider" --- packages/stack/package.json | 2 +- packages/stack/src/index.node.ts | 2 +- packages/stack/src/storage/store-sqlite.ts | 161 +++++++++++++++++++++ 3 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 packages/stack/src/storage/store-sqlite.ts diff --git a/packages/stack/package.json b/packages/stack/package.json index 0e544e1..74cf06a 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -6,7 +6,7 @@ "types": "dist/index.types.d.ts", "exports": { ".": { - "types": "./dist/index.browser.d.ts", + "types": "./dist/index.types.d.ts", "browser": { "types": "./dist/index.browser.d.ts", "default": "./dist/index.browser.js" diff --git a/packages/stack/src/index.node.ts b/packages/stack/src/index.node.ts index 645fe86..329ba78 100644 --- a/packages/stack/src/index.node.ts +++ b/packages/stack/src/index.node.ts @@ -3,4 +3,4 @@ export * from './index.js'; // These exports will only work in a NodeJS (backend) environment. // TODO: FIx me! -// export * from './storage/store-sqlite.js'; +export * from './storage/store-sqlite.js'; diff --git a/packages/stack/src/storage/store-sqlite.ts b/packages/stack/src/storage/store-sqlite.ts new file mode 100644 index 0000000..64f906b --- /dev/null +++ b/packages/stack/src/storage/store-sqlite.ts @@ -0,0 +1,161 @@ +import { type StorageOpts, BaseStorage, BaseStore } from './base-store.js'; +import sqlite3, { type Database } from 'better-sqlite3'; +import { pack, unpack } from 'msgpackr'; +import { StoreInMemorySynced } from './store-inmemory-synced.js'; + +export interface SQLiteOptions { + wal: boolean; +} + +/** + * Storage Adapter that uses SQLite as the storage backend. Everything is stored as a BLOB, to avoid type issues. + * + * @remarks This implementation is single threaded. It WILL block execution of the main thread. + */ +export class StorageSQLite extends BaseStorage { + /** + * Create or open a SQLite database. + * @param filepath - The path to the SQLite database file. + * @param options - The options for the SQLite database. + * @returns A new instance of StorageSQLite. + */ + static async createOrOpen( + filepath: string, + options: Partial = {}, + ) { + const db = sqlite3(filepath); + + const opts: SQLiteOptions = { + wal: true, + ...options, + }; + + if (opts.wal) { + db.pragma('journal_mode = WAL'); + } + + return new StorageSQLite(db); + } + + constructor(public db: Database) { + super(); + } + + async listStores() { + const result = this.db + .prepare(`SELECT * FROM sqlite_master WHERE type='table'`) + .all() as { name: string }[]; + return result.map((row) => row.name); + } + + async createStore(storeName: string, opts: StorageOpts = {}) { + // Create table with proper schema for key-value storage + this.db + .prepare( + ` + CREATE TABLE IF NOT EXISTS "${storeName}" ( + key TEXT PRIMARY KEY, + value BLOB + ) + `, + ) + .run(); + + const store = new StoreSQLite(this.db, storeName, opts); + return opts.syncInMemory ? await StoreInMemorySynced.create(store) : store; + } + + async getStore(storeName: string, opts: StorageOpts) { + // Check if table exists + const tableExists = this.db + .prepare( + ` + SELECT name FROM sqlite_master + WHERE type='table' AND name=? + `, + ) + .get(storeName); + + if (!tableExists) { + return null; + } + + const store = new StoreSQLite(this.db, storeName, opts); + return opts.syncInMemory ? await StoreInMemorySynced.create(store) : store; + } + + async createOrGetStore(storeName: string, opts: StorageOpts) { + return await this.createStore(storeName, opts); + } + + async deleteStore(storeName: string) { + this.db.prepare(`DROP TABLE IF EXISTS "${storeName}"`).run(); + } + + async deleteDatabase() { + this.db.close(); + } +} + +export class StoreSQLite extends BaseStore { + constructor( + protected db: Database, + protected storeName: string, + protected opts: StorageOpts = {}, + ) { + super(); + } + + async keys() { + const result = this.db + .prepare(`SELECT key FROM "${this.storeName}"`) + .all() as { key: string }[]; + return result.map((row) => row.key); + } + + async get(key: string): Promise { + const result = this.db + .prepare(`SELECT value FROM "${this.storeName}" WHERE key = ?`) + .get(key) as { value: Buffer } | undefined; + + if (!result) { + return undefined; + } + + // Handle decryption if encryption is enabled + const decryptedValue = this.opts.encryption + ? await this.opts.encryption.decrypt(new Uint8Array(result.value)) + : new Uint8Array(result.value); + + // Deserialize using msgpackr for consistency with other implementations + return unpack(decryptedValue) as T; + } + + async set(key: string, value: T): Promise { + // Serialize using msgpackr for consistency with other implementations + const serializedValue = pack(value); + + // Handle encryption if encryption is enabled + const finalValue = this.opts.encryption + ? await this.opts.encryption.encrypt(serializedValue) + : serializedValue; + + this.db + .prepare( + `INSERT OR REPLACE INTO "${this.storeName}" (key, value) VALUES (?, ?)`, + ) + .run(key, Buffer.from(finalValue)); + + this.emit('set', { key, value }); + } + + async delete(key: string): Promise { + this.db.prepare(`DELETE FROM "${this.storeName}" WHERE key = ?`).run(key); + this.emit('delete', { key }); + } + + async clear(): Promise { + this.db.prepare(`DELETE FROM "${this.storeName}"`).run(); + this.emit('clear', undefined); + } +}