u
This commit is contained in:
@@ -4,8 +4,11 @@
|
||||
* { rows, rowsAffected, insertId }
|
||||
*/
|
||||
|
||||
import { join } from 'node:path';
|
||||
import { mkdirSync, promises as fsPromises } from 'node:fs';
|
||||
import sql from 'mssql';
|
||||
import { logSlowQuery } from '../provider.js';
|
||||
import { safeWrite, safeReadByHash, safeDeleteByHash } from '../storage.js';
|
||||
|
||||
function normalizeRows(result) {
|
||||
const recordset = result?.recordset ?? [];
|
||||
@@ -16,13 +19,57 @@ function normalizeRows(result) {
|
||||
}
|
||||
|
||||
export class MssqlExecutor {
|
||||
/** @param {() => import('mssql').Request} requestFactory */
|
||||
constructor(requestFactory) {
|
||||
/**
|
||||
* @param {() => import('mssql').Request} requestFactory
|
||||
* @param {string|null} [storageDir=null]
|
||||
*/
|
||||
constructor(requestFactory, storageDir = null) {
|
||||
this.#requestFactory = requestFactory;
|
||||
this.storageDir = storageDir;
|
||||
this.dialect = 'mssql';
|
||||
}
|
||||
|
||||
#requestFactory;
|
||||
|
||||
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 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 ['fullbinary', 'thumbbinary']) {
|
||||
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 (err) {
|
||||
if (err.code !== 'ENOENT') throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Promise<import('../provider.js').QueryResult>} */
|
||||
async query(sql, params = {}) {
|
||||
const start = performance.now();
|
||||
@@ -73,8 +120,9 @@ export class MssqlProvider {
|
||||
* Either an already-connected mssql ConnectionPool, or a plain mssql
|
||||
* config object — in which case the pool is created lazily on first use
|
||||
* (and closed by {@link MssqlProvider#close}).
|
||||
* @param {{ storageDir?: string|null }} [options]
|
||||
*/
|
||||
constructor(poolOrConfig) {
|
||||
constructor(poolOrConfig, { storageDir = null } = {}) {
|
||||
const isPool =
|
||||
poolOrConfig && typeof poolOrConfig.request === 'function';
|
||||
if (!poolOrConfig || (!isPool && !poolOrConfig.server)) {
|
||||
@@ -87,6 +135,7 @@ export class MssqlProvider {
|
||||
// a different object after a lazy connection would leave them with null.
|
||||
this.pool = isPool ? poolOrConfig : new sql.ConnectionPool(poolOrConfig);
|
||||
this.#config = isPool ? null : poolOrConfig;
|
||||
this.storageDir = storageDir || poolOrConfig?.storageDir || null;
|
||||
this.dialect = 'mssql';
|
||||
}
|
||||
|
||||
@@ -163,12 +212,12 @@ export class MssqlProvider {
|
||||
}
|
||||
|
||||
makeExecutor(requestFactory = () => this.pool.request()) {
|
||||
return new MssqlExecutor(requestFactory);
|
||||
return new MssqlExecutor(requestFactory, this.storageDir);
|
||||
}
|
||||
|
||||
async #withPool(fn) {
|
||||
const pool = await this.ensurePool();
|
||||
return fn(new MssqlExecutor(() => pool.request()));
|
||||
return fn(new MssqlExecutor(() => pool.request(), this.storageDir));
|
||||
}
|
||||
|
||||
async query(sql, params = {}) {
|
||||
@@ -179,6 +228,45 @@ export class MssqlProvider {
|
||||
return this.#withPool((executor) => executor.exec(procName, options));
|
||||
}
|
||||
|
||||
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 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 ['fullbinary', 'thumbbinary']) {
|
||||
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 (err) {
|
||||
if (err.code !== 'ENOENT') throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `fn(runner)` inside a transaction. Commit on success,
|
||||
* rollback on error. `runner` has query/exec like the provider.
|
||||
@@ -197,6 +285,12 @@ export class MssqlProvider {
|
||||
runner.query = runner;
|
||||
runner.exec = (proc, opts) => executor.exec(proc, opts);
|
||||
runner.dialect = 'mssql';
|
||||
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.deletePic = executor.deletePic.bind(executor);
|
||||
runner.purgeFiles = executor.purgeFiles.bind(executor);
|
||||
const result = await fn(runner, tx);
|
||||
await tx.commit();
|
||||
return result;
|
||||
@@ -260,12 +354,21 @@ export class MssqlProvider {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (this.storageDir) {
|
||||
for (const sub of ['fullbinary', 'thumbbinary']) {
|
||||
mkdirSync(join(this.storageDir, sub), { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async purge() {
|
||||
const pool = await this.ensurePool();
|
||||
const { buildPurgeSql } = await import('./purgeSql.js');
|
||||
await pool.request().query(buildPurgeSql());
|
||||
if (this.storageDir) {
|
||||
await this.purgeFiles();
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
|
||||
@@ -191,7 +191,7 @@ SELECT
|
||||
a.id AS articleId,
|
||||
p.id AS picId,
|
||||
p.hash,
|
||||
p.fullbinary AS buffer
|
||||
NULL AS buffer
|
||||
FROM categoryarticle ca
|
||||
INNER JOIN article a ON a.id = ca.article_id
|
||||
OUTER APPLY (
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
export const CREATE = `
|
||||
INSERT INTO pic (
|
||||
w, h, size, hash, thumbw, thumbh, thumbhash,
|
||||
smallthumbw, smallthumbh, smallthumbbinary,
|
||||
source, fullbinary, thumbbinary
|
||||
smallthumbw, smallthumbh, smallthumbbinary, source
|
||||
)
|
||||
OUTPUT INSERTED.id
|
||||
VALUES (
|
||||
@w, @h, @size, @hash, @thumbw, @thumbh, @thumbhash,
|
||||
@smallthumbw, @smallthumbh, @smallthumbbinary,
|
||||
@source, @fullbinary, @thumbbinary
|
||||
@smallthumbw, @smallthumbh, @smallthumbbinary, @source
|
||||
)
|
||||
`;
|
||||
|
||||
@@ -60,13 +58,13 @@ WHERE categoryId IS NOT NULL
|
||||
`;
|
||||
|
||||
export const GET_FULL_BY_HASH = `
|
||||
SELECT w, h, size, fullbinary AS buffer
|
||||
SELECT w, h, size, hash
|
||||
FROM pic
|
||||
WHERE hash = @hash
|
||||
`;
|
||||
|
||||
export const GET_THUMB_BY_HASH = `
|
||||
SELECT thumbw AS w, thumbh AS h, thumbbinary AS buffer
|
||||
SELECT thumbw AS w, thumbh AS h, hash
|
||||
FROM pic
|
||||
WHERE hash = @hash
|
||||
`;
|
||||
|
||||
@@ -97,10 +97,8 @@ BEGIN
|
||||
thumbhash NVARCHAR(64) NOT NULL,
|
||||
smallthumbw INT NOT NULL,
|
||||
smallthumbh INT NOT NULL,
|
||||
source NVARCHAR(MAX) NOT NULL,
|
||||
fullbinary VARBINARY(MAX) NOT NULL,
|
||||
thumbbinary VARBINARY(MAX) NOT NULL,
|
||||
smallthumbbinary VARBINARY(MAX) NOT NULL,
|
||||
source NVARCHAR(MAX) NOT NULL,
|
||||
CONSTRAINT UQ_pic_hash UNIQUE (hash)
|
||||
);
|
||||
END
|
||||
|
||||
@@ -51,89 +51,15 @@ function normalizeInsertId(value) {
|
||||
import { dirname, join } from 'node:path';
|
||||
import { mkdirSync, mkdtempSync, promises as fsPromises } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import {
|
||||
extFromMimetype,
|
||||
safeWrite,
|
||||
findFileByHash,
|
||||
safeReadByHash,
|
||||
safeDeleteByHash,
|
||||
} from '../storage.js';
|
||||
|
||||
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 { extFromMimetype };
|
||||
|
||||
export class BetterSqlite3Executor {
|
||||
/**
|
||||
|
||||
89
src/providers/storage.js
Normal file
89
src/providers/storage.js
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Shared filesystem storage utilities for database providers.
|
||||
*/
|
||||
|
||||
import { join } from 'node:path';
|
||||
import { promises as fsPromises } from 'node:fs';
|
||||
|
||||
export async function safeWrite(filePath, buffer) {
|
||||
if (buffer == null) return;
|
||||
const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
||||
await fsPromises.writeFile(filePath, buf);
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
}
|
||||
|
||||
export 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 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 '';
|
||||
}
|
||||
Reference in New Issue
Block a user