/** * Provider for `better-sqlite3` Database instances or connection options. * * The driver is synchronous, but this adapter exposes the asynchronous * provider contract used by LibDashStore. */ import Database from 'better-sqlite3'; import { logSlowQuery } from '../provider.js'; import { SCHEMA_SCRIPTS } from './schema.js'; function normalizeParamValue(value) { if (value === undefined || value === null) return null; if (value instanceof Date) { return isNaN(value.getTime()) ? null : value.toISOString(); } if (typeof value === 'boolean') { return value ? 1 : 0; } return value; } function bindParams(params, sql = null) { const bound = Object.fromEntries( Object.entries(params).map(([key, value]) => [ key.replace(/^[@:$]/, ''), normalizeParamValue(value), ]), ); if (typeof sql === 'string') { const matches = sql.matchAll(/[@:$]([a-zA-Z0-9_]+)/g); for (const match of matches) { const name = match[1]; if (!(name in bound)) { bound[name] = null; } } } return bound; } function normalizeInsertId(value) { if (value === undefined || value === null) return null; if (typeof value !== 'bigint') return value; return value <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(value) : value; } import { dirname, join } from 'node:path'; import { mkdirSync, mkdtempSync, promises as fsPromises } from 'node:fs'; import { tmpdir } from 'node:os'; export function extFromMimetype(mimetype) { if (!mimetype) return ''; const type = mimetype.trim().toLowerCase().split(';')[0]; const map = { 'application/pdf': '.pdf', 'text/plain': '.txt', 'text/csv': '.csv', 'text/html': '.html', 'text/markdown': '.md', 'image/avif': '.avif', 'image/png': '.png', 'image/jpeg': '.jpg', 'image/jpg': '.jpg', 'image/webp': '.webp', 'image/gif': '.gif', 'image/svg+xml': '.svg', 'application/json': '.json', 'application/zip': '.zip', 'application/xml': '.xml', 'application/octet-stream': '.bin', }; if (map[type]) return map[type]; if (type.startsWith('image/')) return '.' + type.slice(6).replace('+xml', ''); if (type.startsWith('text/')) return '.' + type.slice(5); return ''; } async function safeWrite(filePath, buffer) { if (buffer == null) return; const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer); await fsPromises.writeFile(filePath, buf); } async function findFileByHash(dir, hash, ext = '') { if (!dir || !hash) return null; if (ext) { const candidate = join(dir, hash + ext); try { await fsPromises.access(candidate); return candidate; } catch { // fallback } } const candidateNoExt = join(dir, hash); try { await fsPromises.access(candidateNoExt); return candidateNoExt; } catch { // fallback } try { const files = await fsPromises.readdir(dir); const match = files.find((f) => f === hash || f.startsWith(hash + '.')); if (match) return join(dir, match); } catch { // ignore } return null; } async function safeReadByHash(dir, hash, ext = '') { const filePath = await findFileByHash(dir, hash, ext); if (!filePath) return null; try { return await fsPromises.readFile(filePath); } catch (err) { if (err.code === 'ENOENT') return null; throw err; } } async function safeDeleteByHash(dir, hash) { if (!dir || !hash) return; try { const files = await fsPromises.readdir(dir); const matches = files.filter((f) => f === hash || f.startsWith(hash + '.')); await Promise.all(matches.map((f) => fsPromises.unlink(join(dir, f)))); } catch (err) { if (err.code !== 'ENOENT') throw err; } } export class BetterSqlite3Executor { /** * @param {import('better-sqlite3').Database} db * @param {string|null} [storageDir=null] */ constructor(db, storageDir = null) { this.db = db; this.dialect = 'sqlite3'; this.storageDir = storageDir; } async writeFileContent(hash, buffer, mimetype = null) { if (!this.storageDir || !hash) return; const ext = extFromMimetype(mimetype); await safeWrite(join(this.storageDir, 'content', hash + ext), buffer); } async readFileContent(hash, mimetype = null) { if (!this.storageDir || !hash) return null; const ext = extFromMimetype(mimetype); return safeReadByHash(join(this.storageDir, 'content'), hash, ext); } async writeThumbAvif(hash, buffer) { if (!this.storageDir || !hash) return; await safeWrite(join(this.storageDir, 'thumbavif', hash + '.avif'), buffer); } async readThumbAvif(hash) { if (!this.storageDir || !hash) return null; return safeReadByHash(join(this.storageDir, 'thumbavif'), hash, '.avif'); } async writePicFull(hash, buffer) { if (!this.storageDir || !hash) return; await safeWrite(join(this.storageDir, 'fullbinary', hash + '.avif'), buffer); } async readPicFull(hash) { if (!this.storageDir || !hash) return null; return safeReadByHash(join(this.storageDir, 'fullbinary'), hash, '.avif'); } async writePicThumb(hash, buffer) { if (!this.storageDir || !hash) return; await safeWrite(join(this.storageDir, 'thumbbinary', hash + '.avif'), buffer); } async readPicThumb(hash) { if (!this.storageDir || !hash) return null; return safeReadByHash(join(this.storageDir, 'thumbbinary'), hash, '.avif'); } async deleteFile(hash) { if (!this.storageDir || !hash) return; await safeDeleteByHash(join(this.storageDir, 'content'), hash); await safeDeleteByHash(join(this.storageDir, 'thumbavif'), hash); } async writeVfsContent(id, buffer) { if (!this.storageDir || !id) return; await safeWrite(join(this.storageDir, 'vfs', String(id)), buffer); } async readVfsContent(id) { if (!this.storageDir || !id) return null; try { return await fsPromises.readFile(join(this.storageDir, 'vfs', String(id))); } catch (err) { if (err.code === 'ENOENT') return null; throw err; } } async deleteVfsContent(id) { if (!this.storageDir || !id) return; try { await fsPromises.unlink(join(this.storageDir, 'vfs', String(id))); } catch (err) { if (err.code !== 'ENOENT') throw err; } } async deletePic(hash) { if (!this.storageDir || !hash) return; await safeDeleteByHash(join(this.storageDir, 'fullbinary'), hash); await safeDeleteByHash(join(this.storageDir, 'thumbbinary'), hash); } async purgeFiles() { if (!this.storageDir) return; for (const sub of ['content', 'thumbavif', 'fullbinary', 'thumbbinary', 'vfs']) { const dir = join(this.storageDir, sub); try { const files = await fsPromises.readdir(dir); await Promise.all(files.map((f) => fsPromises.unlink(join(dir, f)))); } catch { // ignore } } } /** @returns {Promise} */ async query(sql, params = {}) { const start = performance.now(); try { const statement = this.db.prepare(sql); const bindings = bindParams(params, sql); if (statement.reader) { const rows = statement.all(bindings); return { rows, rowsAffected: rows.length, insertId: normalizeInsertId(rows[0]?.id), }; } const result = statement.run(bindings); return { rows: [], rowsAffected: result.changes, insertId: normalizeInsertId(result.lastInsertRowid), }; } finally { logSlowQuery(sql, performance.now() - start, params); } } } function ensureDirectory(filename) { if (typeof filename === 'string' && filename !== ':memory:' && !filename.startsWith(':memory:')) { const dir = dirname(filename); if (dir && dir !== '.') { mkdirSync(dir, { recursive: true }); } } } export class BetterSqlite3Provider extends BetterSqlite3Executor { /** * @param {import('better-sqlite3').Database | string | { filename: string, storageDir?: string, filesDir?: string, [key: string]: any }} [dbOrConfig] * An open better-sqlite3 Database, a file path (or ':memory:'), or a config object. */ constructor(dbOrConfig = ':memory:') { let db = dbOrConfig; let storageDir = null; if (typeof dbOrConfig === 'string') { ensureDirectory(dbOrConfig); db = new Database(dbOrConfig); storageDir = (dbOrConfig === ':memory:' || dbOrConfig.startsWith(':memory:')) ? mkdtempSync(join(tmpdir(), 'dash-sqlite-storage-')) : join(dirname(dbOrConfig), 'storage'); } else if ( dbOrConfig && typeof dbOrConfig === 'object' && typeof dbOrConfig.prepare !== 'function' ) { const { filename, storageDir: customStorageDir, filesDir, ...options } = dbOrConfig; if (filename) ensureDirectory(filename); db = new Database(filename || ':memory:', options); storageDir = customStorageDir || filesDir || ( (!filename || filename === ':memory:' || filename.startsWith(':memory:')) ? mkdtempSync(join(tmpdir(), 'dash-sqlite-storage-')) : join(dirname(filename), 'storage') ); } else if (db && typeof db.prepare === 'function') { storageDir = mkdtempSync(join(tmpdir(), 'dash-sqlite-storage-')); } if ( !db || typeof db.prepare !== 'function' || typeof db.exec !== 'function' || typeof db.pragma !== 'function' ) { throw new TypeError( 'BetterSqlite3Provider requires an open better-sqlite3 Database or a filename string', ); } super(db, storageDir); if (this.storageDir) { for (const sub of ['content', 'thumbavif', 'fullbinary', 'thumbbinary', 'vfs']) { mkdirSync(join(this.storageDir, sub), { recursive: true }); } } this.pool = this; db.pragma('foreign_keys = ON'); db.pragma('journal_mode = WAL'); db.pragma('busy_timeout = 5000'); } #transactionTail = Promise.resolve(); #transactionDepth = 0; /** * Runs `fn(runner)` in an immediate write transaction. Transactions are * serialized because better-sqlite3 has one synchronous connection. * Nested calls use SAVEPOINTs. * * @template T * @param {(runner: BetterSqlite3Executor) => Promise | T} fn * @returns {Promise} */ transaction(fn) { if (typeof fn !== 'function') { return Promise.reject(new TypeError('transaction requires a callback')); } const bindRunner = (executor) => { const runner = async (sql, params) => executor.query(sql, params); runner.query = runner; runner.dialect = 'sqlite3'; runner.storageDir = this.storageDir; runner.writeFileContent = executor.writeFileContent.bind(executor); runner.readFileContent = executor.readFileContent.bind(executor); runner.writeThumbAvif = executor.writeThumbAvif.bind(executor); runner.readThumbAvif = executor.readThumbAvif.bind(executor); runner.writePicFull = executor.writePicFull.bind(executor); runner.readPicFull = executor.readPicFull.bind(executor); runner.writePicThumb = executor.writePicThumb.bind(executor); runner.readPicThumb = executor.readPicThumb.bind(executor); runner.writeVfsContent = executor.writeVfsContent.bind(executor); runner.readVfsContent = executor.readVfsContent.bind(executor); runner.deleteVfsContent = executor.deleteVfsContent.bind(executor); runner.deleteFile = executor.deleteFile.bind(executor); runner.deletePic = executor.deletePic.bind(executor); runner.purgeFiles = executor.purgeFiles.bind(executor); return runner; }; if (this.#transactionDepth > 0 || this.db.inTransaction) { const savepoint = `sp_${++this.#transactionDepth}`; this.db.exec(`SAVEPOINT ${savepoint}`); const executor = new BetterSqlite3Executor(this.db, this.storageDir); const runner = bindRunner(executor); return Promise.resolve() .then(() => fn(runner, executor)) .then( (result) => { this.db.exec(`RELEASE SAVEPOINT ${savepoint}`); this.#transactionDepth--; return result; }, (err) => { try { this.db.exec(`ROLLBACK TO SAVEPOINT ${savepoint}`); this.db.exec(`RELEASE SAVEPOINT ${savepoint}`); } catch { // ignore } this.#transactionDepth--; throw err; }, ); } const run = async () => { this.#transactionDepth = 1; this.db.exec('BEGIN IMMEDIATE'); try { const executor = new BetterSqlite3Executor(this.db, this.storageDir); const runner = bindRunner(executor); const result = await fn(runner, executor); this.db.exec('COMMIT'); return result; } catch (err) { if (this.db.inTransaction) this.db.exec('ROLLBACK'); throw err; } finally { this.#transactionDepth = 0; } }; const pending = this.#transactionTail.then(run, run); this.#transactionTail = pending.catch(() => undefined); return pending; } async exec() { throw new Error('Stored procedures are not supported by better-sqlite3'); } /** * Initializes the native SQLite schema. * @returns {Promise} */ async initialize() { for (const script of SCHEMA_SCRIPTS) { if (script && script.trim()) { this.db.exec(script); } } // Ensure created_at and warehouse_id exist on ledger table if created previously try { const ledgerCols = this.db.pragma('table_info("ledger")').map((c) => c.name); if (ledgerCols.length > 0 && !ledgerCols.includes('created_at')) { this.db.exec( `ALTER TABLE ledger ADD COLUMN created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`, ); } if (ledgerCols.length > 0 && !ledgerCols.includes('warehouse_id')) { this.db.exec( `ALTER TABLE ledger ADD COLUMN warehouse_id INTEGER REFERENCES warehouse(id) ON DELETE NO ACTION`, ); } const articleCols = this.db.pragma('table_info("article")').map((c) => c.name); if (articleCols.length > 0 && !articleCols.includes('track_stock')) { this.db.exec( `ALTER TABLE article ADD COLUMN track_stock INTEGER NOT NULL DEFAULT 1`, ); } if (articleCols.length > 0 && !articleCols.includes('parentattributename_id')) { this.db.exec( `ALTER TABLE article ADD COLUMN parentattributename_id INTEGER REFERENCES attributename(id) ON DELETE NO ACTION`, ); } const warehouseCols = this.db.pragma('table_info("warehouse")').map((c) => c.name); if (warehouseCols.length > 0 && !warehouseCols.includes('sort')) { this.db.exec( `ALTER TABLE warehouse ADD COLUMN sort INTEGER NOT NULL DEFAULT 0`, ); } const posterminalCols = this.db.pragma('table_info("posterminal")').map((c) => c.name); if (posterminalCols.length > 0 && !posterminalCols.includes('paired_at')) { this.db.exec(`ALTER TABLE posterminal ADD COLUMN paired_at TEXT`); } if (posterminalCols.length > 0 && !posterminalCols.includes('clientserial')) { this.db.exec(`ALTER TABLE posterminal ADD COLUMN clientserial TEXT`); } if (posterminalCols.length > 0 && !posterminalCols.includes('maxknowntombstoneid')) { this.db.exec(`ALTER TABLE posterminal ADD COLUMN maxknowntombstoneid INTEGER`); } const propertynameCols = this.db.pragma('table_info("propertyname")').map((c) => c.name); if (propertynameCols.length > 0 && !propertynameCols.includes('exclusive')) { this.db.exec(`ALTER TABLE propertyname ADD COLUMN exclusive INTEGER NOT NULL DEFAULT 0`); } const categoryCols = this.db.pragma('table_info("category")').map((c) => c.name); if (categoryCols.length > 0 && !categoryCols.includes('updated_at')) { this.db.exec(`ALTER TABLE category ADD COLUMN updated_at TEXT`); this.db.exec(`UPDATE category SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE updated_at IS NULL`); } const customergroupCols = this.db.pragma('table_info("customergroup")').map((c) => c.name); if (customergroupCols.length > 0 && !customergroupCols.includes('updated_at')) { this.db.exec(`ALTER TABLE customergroup ADD COLUMN updated_at TEXT`); this.db.exec(`UPDATE customergroup SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE updated_at IS NULL`); } } catch { // ignore } } /** * Purges all tables, views, and triggers from the SQLite database. * @returns {Promise} */ async purge() { await this.purgeFiles(); this.db.pragma('foreign_keys = OFF'); try { const tables = this.db .prepare( "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'", ) .all() .map((r) => r.name); const views = this.db .prepare("SELECT name FROM sqlite_master WHERE type = 'view'") .all() .map((r) => r.name); const triggers = this.db .prepare("SELECT name FROM sqlite_master WHERE type = 'trigger'") .all() .map((r) => r.name); for (const trigger of triggers) { this.db.exec(`DROP TRIGGER IF EXISTS "${trigger}"`); } for (const view of views) { this.db.exec(`DROP VIEW IF EXISTS "${view}"`); } for (const table of tables) { this.db.exec(`DROP TABLE IF EXISTS "${table}"`); } } finally { this.db.pragma('foreign_keys = ON'); } } /** Explicitly closes the database. */ close() { this.db.close(); } isUniqueConstraintError(err) { return isUniqueConstraintError(err); } isForeignKeyConstraintError(err) { return isForeignKeyConstraintError(err); } } export function isUniqueConstraintError(err) { return ( err?.code === 'SQLITE_CONSTRAINT_UNIQUE' || err?.code === 'SQLITE_CONSTRAINT_PRIMARYKEY' ); } export function isForeignKeyConstraintError(err) { return err?.code === 'SQLITE_CONSTRAINT_FOREIGNKEY'; } export { BetterSqlite3Provider as SqliteProvider }; export { BetterSqlite3Executor as SqliteExecutor };