570 lines
18 KiB
JavaScript
570 lines
18 KiB
JavaScript
/**
|
|
* 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 { AsyncLocalStorage } from 'node:async_hooks';
|
|
import { logSlowQuery } from '../provider.js';
|
|
import { SCHEMA_SCRIPTS } from './schema.js';
|
|
|
|
const asyncTxStorage = new AsyncLocalStorage();
|
|
|
|
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<import('../provider.js').QueryResult>} */
|
|
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('synchronous = NORMAL');
|
|
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> | T} fn
|
|
* @returns {Promise<T>}
|
|
*/
|
|
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;
|
|
};
|
|
|
|
const activeContext = asyncTxStorage.getStore();
|
|
if (activeContext) {
|
|
const savepoint = `sp_${++activeContext.depth}`;
|
|
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) => {
|
|
try {
|
|
this.db.exec(`RELEASE SAVEPOINT ${savepoint}`);
|
|
} catch {
|
|
// ignore if outer transaction already rolled back
|
|
}
|
|
activeContext.depth--;
|
|
return result;
|
|
},
|
|
(err) => {
|
|
try {
|
|
this.db.exec(`ROLLBACK TO SAVEPOINT ${savepoint}`);
|
|
this.db.exec(`RELEASE SAVEPOINT ${savepoint}`);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
activeContext.depth--;
|
|
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 asyncTxStorage.run({ depth: 0 }, () => 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<void>}
|
|
*/
|
|
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<void>}
|
|
*/
|
|
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 }; |