Merge branch '76-feat-sqlite-create-sqlite-storage-provider' into 'development'
Resolve "feat(SQLite): Create SQLite Storage Provider" Closes #76 See merge request GeneralProtocols/xo/stack!125
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<SQLiteOptions> = {},
|
||||
) {
|
||||
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<T>(key: string): Promise<T | undefined> {
|
||||
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<T>(key: string, value: T): Promise<void> {
|
||||
// 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<void> {
|
||||
this.db.prepare(`DELETE FROM "${this.storeName}" WHERE key = ?`).run(key);
|
||||
this.emit('delete', { key });
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.db.prepare(`DELETE FROM "${this.storeName}"`).run();
|
||||
this.emit('clear', undefined);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user