This commit is contained in:
seb
2026-09-22 08:31:01 +02:00
parent cca131d83a
commit dce65db435
11 changed files with 1453 additions and 30 deletions

130
BENCHMARK_REPORT.md Normal file
View File

@@ -0,0 +1,130 @@
# Database Drivers API Timing & Benchmark Report
- **Date**: 2026-09-22T06:09:22.346Z
- **Scale**: small
- **Read Iterations on Filled DB**: 2
## SQLite (BetterSqlite3Provider) Detailed Breakdown
| API Method | Calls | Avg (ms) | Min (ms) | Max (ms) | P50 (ms) | P95 (ms) | Errors |
| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
| `article.addImage` | 4 | 35.62 | 13.96 | 44.76 | 41.95 | 44.76 | 0 |
| `article.createUnderParent` | 19 | 0.53 | 0.46 | 0.92 | 0.50 | 0.92 | 0 |
| `article.delete` | 1 | 0.64 | 0.64 | 0.64 | 0.64 | 0.64 | 0 |
| `article.deleteImage` | 1 | 0.85 | 0.85 | 0.85 | 0.85 | 0.85 | 0 |
| `article.getBySku` | 2 | 0.68 | 0.59 | 0.76 | 0.76 | 0.76 | 0 |
| `article.getHistoryBySku` | 2 | 0.32 | 0.29 | 0.35 | 0.35 | 0.35 | 0 |
| `article.importRows` | 1 | 2.45 | 2.45 | 2.45 | 2.45 | 2.45 | 0 |
| `article.listExport` | 4 | 0.85 | 0.64 | 1.27 | 0.80 | 1.27 | 0 |
| `article.listSearchIndex` | 2 | 0.54 | 0.46 | 0.61 | 0.61 | 0.61 | 0 |
| `article.orderImages` | 3 | 0.50 | 0.36 | 0.71 | 0.43 | 0.71 | 0 |
| `article.setBrandIdBySku` | 18 | 0.58 | 0.53 | 0.78 | 0.56 | 0.78 | 0 |
| `article.setCategoryIdsBySku` | 18 | 0.74 | 0.70 | 0.99 | 0.72 | 0.99 | 0 |
| `article.setEansBySku` | 18 | 0.57 | 0.50 | 0.96 | 0.53 | 0.96 | 0 |
| `article.setFatherBySku` | 13 | 0.66 | 0.59 | 1.00 | 0.63 | 1.00 | 0 |
| `article.setPricesBySku` | 18 | 0.68 | 0.63 | 1.10 | 0.65 | 1.10 | 0 |
| `article.setWeightBySku` | 18 | 0.53 | 0.49 | 0.68 | 0.51 | 0.68 | 0 |
| `article.updateBySku` | 18 | 0.82 | 0.73 | 1.61 | 0.76 | 1.61 | 0 |
| `articlesupplier.create` | 19 | 0.56 | 0.50 | 1.05 | 0.52 | 1.05 | 0 |
| `articlesupplier.delete` | 1 | 0.40 | 0.40 | 0.40 | 0.40 | 0.40 | 0 |
| `articlesupplier.listByArticleSku` | 2 | 0.25 | 0.23 | 0.26 | 0.26 | 0.26 | 0 |
| `articlesupplier.listByEntityAndSkus` | 2 | 0.44 | 0.36 | 0.52 | 0.52 | 0.52 | 0 |
| `articlesupplier.listBySupplierAndSkus` | 2 | 0.37 | 0.35 | 0.38 | 0.38 | 0.38 | 0 |
| `articlesupplier.order` | 18 | 0.29 | 0.27 | 0.49 | 0.28 | 0.49 | 0 |
| `articlesupplier.update` | 18 | 0.42 | 0.36 | 0.79 | 0.40 | 0.79 | 0 |
| `brand.delete` | 1 | 0.25 | 0.25 | 0.25 | 0.25 | 0.25 | 0 |
| `brand.list` | 1 | 0.26 | 0.26 | 0.26 | 0.26 | 0.26 | 0 |
| `brand.upsert` | 3 | 0.26 | 0.22 | 0.30 | 0.25 | 0.30 | 0 |
| `category.addImage` | 2 | 43.22 | 22.62 | 63.83 | 63.83 | 63.83 | 0 |
| `category.createUnderParent` | 10 | 0.39 | 0.30 | 0.85 | 0.33 | 0.85 | 0 |
| `category.delete` | 1 | 0.24 | 0.24 | 0.24 | 0.24 | 0.24 | 0 |
| `category.deleteImage` | 1 | 0.36 | 0.36 | 0.36 | 0.36 | 0.36 | 0 |
| `category.ensureByPath` | 1 | 0.59 | 0.59 | 0.59 | 0.59 | 0.59 | 0 |
| `category.getCategoryArticles` | 2 | 0.39 | 0.37 | 0.42 | 0.42 | 0.42 | 0 |
| `category.getTreeForCompany` | 2 | 0.70 | 0.65 | 0.76 | 0.76 | 0.76 | 0 |
| `category.move` | 1 | 0.49 | 0.49 | 0.49 | 0.49 | 0.49 | 0 |
| `category.order` | 1 | 0.40 | 0.40 | 0.40 | 0.40 | 0.40 | 0 |
| `category.rename` | 1 | 0.25 | 0.25 | 0.25 | 0.25 | 0.25 | 0 |
| `company.create` | 2 | 1.10 | 0.78 | 1.41 | 1.41 | 1.41 | 0 |
| `company.delete` | 1 | 0.23 | 0.23 | 0.23 | 0.23 | 0.23 | 0 |
| `company.list` | 1 | 0.26 | 0.26 | 0.26 | 0.26 | 0.26 | 0 |
| `company.update` | 1 | 0.50 | 0.50 | 0.50 | 0.50 | 0.50 | 0 |
| `companysetting.get` | 1 | 0.23 | 0.23 | 0.23 | 0.23 | 0.23 | 0 |
| `companysetting.list` | 3 | 0.21 | 0.16 | 0.24 | 0.22 | 0.24 | 0 |
| `companysetting.set` | 2 | 0.35 | 0.29 | 0.42 | 0.42 | 0.42 | 0 |
| `companyuser.add` | 1 | 0.12 | 0.12 | 0.12 | 0.12 | 0.12 | 0 |
| `companyuser.delete` | 1 | 0.10 | 0.10 | 0.10 | 0.10 | 0.10 | 0 |
| `companyuser.getByEmail` | 1 | 0.11 | 0.11 | 0.11 | 0.11 | 0.11 | 0 |
| `customergroup.create` | 3 | 0.30 | 0.24 | 0.38 | 0.29 | 0.38 | 0 |
| `customergroup.delete` | 1 | 0.28 | 0.28 | 0.28 | 0.28 | 0.28 | 0 |
| `customergroup.list` | 2 | 0.19 | 0.17 | 0.21 | 0.21 | 0.21 | 0 |
| `customergroup.order` | 1 | 0.41 | 0.41 | 0.41 | 0.41 | 0.41 | 0 |
| `customergroup.update` | 1 | 0.33 | 0.33 | 0.33 | 0.33 | 0.33 | 0 |
| `delivery.create` | 7 | 9.48 | 0.67 | 62.06 | 0.74 | 62.06 | 0 |
| `delivery.delete` | 1 | 0.26 | 0.26 | 0.26 | 0.26 | 0.26 | 0 |
| `delivery.getById` | 2 | 0.50 | 0.29 | 0.72 | 0.72 | 0.72 | 0 |
| `delivery.list` | 2 | 0.33 | 0.28 | 0.38 | 0.38 | 0.38 | 0 |
| `delivery.update` | 6 | 0.52 | 0.36 | 1.13 | 0.39 | 1.13 | 0 |
| `entity.create` | 7 | 0.48 | 0.39 | 0.89 | 0.42 | 0.89 | 0 |
| `entity.delete` | 1 | 0.25 | 0.25 | 0.25 | 0.25 | 0.25 | 0 |
| `entity.getById` | 2 | 0.18 | 0.15 | 0.21 | 0.21 | 0.21 | 0 |
| `entity.isNumberFree` | 6 | 0.17 | 0.15 | 0.24 | 0.16 | 0.24 | 0 |
| `entity.list` | 4 | 0.25 | 0.19 | 0.36 | 0.24 | 0.36 | 0 |
| `entity.peekNextNumber` | 6 | 0.24 | 0.20 | 0.39 | 0.22 | 0.39 | 0 |
| `entityaddress.create` | 7 | 0.33 | 0.27 | 0.59 | 0.29 | 0.59 | 0 |
| `entityaddress.delete` | 1 | 0.28 | 0.28 | 0.28 | 0.28 | 0.28 | 0 |
| `entityaddress.list` | 2 | 0.30 | 0.26 | 0.35 | 0.35 | 0.35 | 0 |
| `entityaddress.update` | 6 | 0.38 | 0.33 | 0.56 | 0.35 | 0.56 | 0 |
| `entitycontact.create` | 7 | 0.28 | 0.24 | 0.41 | 0.27 | 0.41 | 0 |
| `entitycontact.delete` | 1 | 0.26 | 0.26 | 0.26 | 0.26 | 0.26 | 0 |
| `entitycontact.list` | 2 | 0.25 | 0.20 | 0.30 | 0.30 | 0.30 | 0 |
| `entitycontact.update` | 6 | 0.33 | 0.29 | 0.46 | 0.31 | 0.46 | 0 |
| `google_auth.add` | 3 | 0.21 | 0.08 | 0.42 | 0.13 | 0.42 | 0 |
| `google_auth.getByEmail` | 2 | 0.06 | 0.04 | 0.09 | 0.09 | 0.09 | 0 |
| `google_auth.getById` | 2 | 0.09 | 0.07 | 0.10 | 0.10 | 0.10 | 0 |
| `google_auth.listAllowedEmails` | 1 | 0.14 | 0.14 | 0.14 | 0.14 | 0.14 | 0 |
| `google_auth.removeAllowedEmail` | 1 | 0.09 | 0.09 | 0.09 | 0.09 | 0.09 | 0 |
| `google_auth.setProfile` | 1 | 0.16 | 0.16 | 0.16 | 0.16 | 0.16 | 0 |
| `google_auth.setSelectedCompany` | 2 | 0.07 | 0.05 | 0.08 | 0.08 | 0.08 | 0 |
| `lang.create` | 2 | 0.07 | 0.07 | 0.07 | 0.07 | 0.07 | 0 |
| `lang.delete` | 1 | 0.12 | 0.12 | 0.12 | 0.12 | 0.12 | 0 |
| `lang.ensure` | 2 | 0.10 | 0.09 | 0.11 | 0.11 | 0.11 | 0 |
| `lang.getByIso639` | 1 | 0.05 | 0.05 | 0.05 | 0.05 | 0.05 | 0 |
| `lang.list` | 1 | 0.06 | 0.06 | 0.06 | 0.06 | 0.06 | 0 |
| `manifestattachment.add` | 3 | 35.65 | 1.22 | 55.34 | 50.41 | 55.34 | 0 |
| `manifestattachment.getById` | 2 | 0.75 | 0.65 | 0.85 | 0.85 | 0.85 | 0 |
| `manifestattachment.listByManifestId` | 3 | 0.61 | 0.51 | 0.70 | 0.61 | 0.70 | 0 |
| `manifestattachment.order` | 1 | 0.54 | 0.54 | 0.54 | 0.54 | 0.54 | 0 |
| `manifestattachment.remove` | 1 | 0.86 | 0.86 | 0.86 | 0.86 | 0.86 | 0 |
| `numbers.list` | 1 | 0.27 | 0.27 | 0.27 | 0.27 | 0.27 | 0 |
| `order.create` | 7 | 0.50 | 0.40 | 0.69 | 0.45 | 0.69 | 0 |
| `order.delete` | 1 | 0.24 | 0.24 | 0.24 | 0.24 | 0.24 | 0 |
| `order.getById` | 2 | 0.24 | 0.21 | 0.28 | 0.28 | 0.28 | 0 |
| `order.list` | 2 | 0.21 | 0.17 | 0.25 | 0.25 | 0.25 | 0 |
| `order.update` | 6 | 0.34 | 0.28 | 0.53 | 0.29 | 0.53 | 0 |
| `pic.getImageByHash` | 2 | 0.66 | 0.59 | 0.74 | 0.74 | 0.74 | 0 |
| `pic.getThumbByHash` | 2 | 0.71 | 0.66 | 0.76 | 0.76 | 0.76 | 0 |
| `posterminal.delete` | 1 | 0.21 | 0.21 | 0.21 | 0.21 | 0.21 | 0 |
| `posterminal.getCategoryPosterminalIds` | 1 | 0.11 | 0.11 | 0.11 | 0.11 | 0.11 | 0 |
| `posterminal.list` | 3 | 0.18 | 0.15 | 0.19 | 0.19 | 0.19 | 0 |
| `posterminal.setOff` | 1 | 0.38 | 0.38 | 0.38 | 0.38 | 0.38 | 0 |
| `posterminal.setOffAll` | 1 | 0.30 | 0.30 | 0.30 | 0.30 | 0.30 | 0 |
| `posterminal.setOn` | 1 | 0.41 | 0.41 | 0.41 | 0.41 | 0.41 | 0 |
| `posterminal.setOnAll` | 1 | 0.54 | 0.54 | 0.54 | 0.54 | 0.54 | 0 |
| `posterminal.upsert` | 3 | 0.27 | 0.19 | 0.35 | 0.28 | 0.35 | 0 |
| `quote.create` | 7 | 0.61 | 0.39 | 1.21 | 0.53 | 1.21 | 0 |
| `quote.delete` | 1 | 0.24 | 0.24 | 0.24 | 0.24 | 0.24 | 0 |
| `quote.getById` | 2 | 0.23 | 0.23 | 0.23 | 0.23 | 0.23 | 0 |
| `quote.list` | 2 | 0.20 | 0.17 | 0.23 | 0.23 | 0.23 | 0 |
| `quote.update` | 6 | 0.36 | 0.29 | 0.66 | 0.31 | 0.66 | 0 |
| `supplier.create` | 4 | 0.41 | 0.33 | 0.63 | 0.34 | 0.63 | 0 |
| `supplier.delete` | 1 | 0.30 | 0.30 | 0.30 | 0.30 | 0.30 | 0 |
| `supplier.list` | 3 | 0.20 | 0.16 | 0.26 | 0.16 | 0.26 | 0 |
| `supplier.update` | 3 | 0.42 | 0.30 | 0.58 | 0.39 | 0.58 | 0 |
| `tax.create` | 1 | 0.08 | 0.08 | 0.08 | 0.08 | 0.08 | 0 |
| `tax.ensure` | 2 | 0.06 | 0.06 | 0.06 | 0.06 | 0.06 | 0 |
| `tax.getByRate` | 1 | 0.05 | 0.05 | 0.05 | 0.05 | 0.05 | 0 |
| `tax.list` | 1 | 0.07 | 0.07 | 0.07 | 0.07 | 0.07 | 0 |
| `unit.delete` | 1 | 0.25 | 0.25 | 0.25 | 0.25 | 0.25 | 0 |
| `unit.list` | 1 | 0.29 | 0.29 | 0.29 | 0.29 | 0.29 | 0 |
| `unit.upsert` | 3 | 0.52 | 0.32 | 0.78 | 0.47 | 0.78 | 0 |

View File

@@ -20,7 +20,8 @@
"init-schema": "node scripts/init-schema.js",
"purge-db": "node scripts/purge-db.js",
"reset-db": "node scripts/reset-db.js",
"test:api": "node scripts/test-api.js"
"test:api": "node scripts/test-api.js",
"test:benchmark": "node scripts/benchmark.js"
},
"dependencies": {
"@clack/prompts": "^1.8.1",

197
scripts/benchmark.js Normal file
View File

@@ -0,0 +1,197 @@
#!/usr/bin/env node
import 'dotenv/config';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { performance } from 'node:perf_hooks';
import chalk from 'chalk';
import LibDashStore from '../src/index.js';
import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js';
import { MssqlProvider } from '../src/providers/mssql/index.js';
import { MetricsCollector, instrumentStore } from './benchmark/instrument.js';
import { fillDatabase, benchmarkFilledDatabase, testLifecycleDeletions } from './benchmark/workload.js';
import { BenchmarkReporter } from './benchmark/reporter.js';
// CLI argument parsing
function parseArgs() {
const args = process.argv.slice(2);
const options = {
driver: 'both',
scale: 'small',
iterations: 5,
reportFile: 'BENCHMARK_REPORT.md',
keepDb: false,
};
for (const arg of args) {
if (arg.startsWith('--driver=')) {
options.driver = arg.split('=')[1].toLowerCase();
} else if (arg.startsWith('--scale=')) {
options.scale = arg.split('=')[1].toLowerCase();
} else if (arg.startsWith('--iterations=')) {
options.iterations = parseInt(arg.split('=')[1], 10);
} else if (arg.startsWith('--report-file=')) {
options.reportFile = arg.split('=')[1];
} else if (arg === '--keep-db') {
options.keepDb = true;
}
}
return options;
}
async function runSqliteBenchmark(options) {
console.log(chalk.blue(`\n▶ Starting SQLite benchmark (scale: ${options.scale}, iterations: ${options.iterations})...`));
const tempDir = mkdtempSync(join(tmpdir(), 'libdashstore-bench-sqlite-'));
const dbFile = join(tempDir, 'benchmark.db');
const storageDir = join(tempDir, 'storage');
const provider = new BetterSqlite3Provider({
filename: dbFile,
storageDir,
});
await provider.initialize();
const rawStore = await LibDashStore.create(provider);
const metrics = new MetricsCollector();
const { store } = instrumentStore(rawStore, metrics);
const startTotal = performance.now();
try {
console.log(chalk.dim(' 1. Filling SQLite database using domain APIs...'));
const fillStart = performance.now();
const ctx = await fillDatabase(store, options);
console.log(chalk.green(` ✓ Filled SQLite DB in ${((performance.now() - fillStart) / 1000).toFixed(2)}s`));
console.log(chalk.dim(' 2. Benchmarking queries and lookups on filled database...'));
const benchStart = performance.now();
await benchmarkFilledDatabase(store, ctx, options);
console.log(chalk.green(` ✓ Read queries completed in ${((performance.now() - benchStart) / 1000).toFixed(2)}s`));
console.log(chalk.dim(' 3. Testing lifecycle deletions...'));
const delStart = performance.now();
await testLifecycleDeletions(store, ctx);
console.log(chalk.green(` ✓ Deletions completed in ${((performance.now() - delStart) / 1000).toFixed(2)}s`));
const totalElapsedMs = performance.now() - startTotal;
const stats = metrics.getAllStats();
return { stats, totalElapsedMs };
} finally {
provider.close();
if (!options.keepDb) {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// ignore
}
} else {
console.log(chalk.dim(` [i] SQLite test database kept at: ${tempDir}`));
}
}
}
async function runMssqlBenchmark(options) {
console.log(chalk.blue(`\n▶ Starting MSSQL benchmark (scale: ${options.scale}, iterations: ${options.iterations})...`));
const config = {
server: process.env.MSSQL_SERVER || '127.0.0.1',
database: process.env.MSSQL_DATABASE || 'dashback',
user: process.env.MSSQL_USER || 'dashback',
password: process.env.MSSQL_PASSWORD || 'dashback',
options: {
encrypt: false,
trustServerCertificate: true,
},
};
const provider = new MssqlProvider(config);
await provider.ensurePool();
console.log(chalk.dim(' Resetting MSSQL schema (purge & init)...'));
await provider.purge();
await provider.initialize();
const rawStore = await LibDashStore.create(provider);
const metrics = new MetricsCollector();
const { store } = instrumentStore(rawStore, metrics);
const startTotal = performance.now();
try {
console.log(chalk.dim(' 1. Filling MSSQL database using domain APIs...'));
const fillStart = performance.now();
const ctx = await fillDatabase(store, options);
console.log(chalk.green(` ✓ Filled MSSQL DB in ${((performance.now() - fillStart) / 1000).toFixed(2)}s`));
console.log(chalk.dim(' 2. Benchmarking queries and lookups on filled database...'));
const benchStart = performance.now();
await benchmarkFilledDatabase(store, ctx, options);
console.log(chalk.green(` ✓ Read queries completed in ${((performance.now() - benchStart) / 1000).toFixed(2)}s`));
console.log(chalk.dim(' 3. Testing lifecycle deletions...'));
const delStart = performance.now();
await testLifecycleDeletions(store, ctx);
console.log(chalk.green(` ✓ Deletions completed in ${((performance.now() - delStart) / 1000).toFixed(2)}s`));
const totalElapsedMs = performance.now() - startTotal;
const stats = metrics.getAllStats();
return { stats, totalElapsedMs };
} finally {
await provider.close();
}
}
async function main() {
const options = parseArgs();
console.log(chalk.bold.yellow(`========================================================================`));
console.log(chalk.bold.yellow(` LIBDASHSTORE DATABASE DRIVERS TEST & BENCHMARK SYSTEM`));
console.log(chalk.bold.yellow(`========================================================================`));
console.log(`Driver mode : ${chalk.cyan(options.driver)}`);
console.log(`Scale : ${chalk.cyan(options.scale)}`);
console.log(`Iterations : ${chalk.cyan(options.iterations)}`);
console.log(`Report File : ${chalk.cyan(options.reportFile)}`);
let sqliteResult = null;
let mssqlResult = null;
if (options.driver === 'both' || options.driver === 'sqlite') {
sqliteResult = await runSqliteBenchmark(options);
BenchmarkReporter.printDriverReport('better-sqlite3', sqliteResult.stats, sqliteResult.totalElapsedMs);
}
if (options.driver === 'both' || options.driver === 'mssql') {
mssqlResult = await runMssqlBenchmark(options);
BenchmarkReporter.printDriverReport('mssql', mssqlResult.stats, mssqlResult.totalElapsedMs);
}
if (sqliteResult && mssqlResult) {
BenchmarkReporter.printComparisonReport(
mssqlResult.stats,
sqliteResult.stats,
mssqlResult.totalElapsedMs,
sqliteResult.totalElapsedMs,
);
}
const markdown = BenchmarkReporter.generateMarkdownReport({
mssqlStats: mssqlResult?.stats,
sqliteStats: sqliteResult?.stats,
mssqlElapsedMs: mssqlResult?.totalElapsedMs,
sqliteElapsedMs: sqliteResult?.totalElapsedMs,
options,
});
if (options.reportFile) {
BenchmarkReporter.saveReportToFile(options.reportFile, markdown);
}
console.log(chalk.bold.green(`\n✓ All database driver tests & benchmarks finished successfully!`));
}
main().catch((err) => {
console.error(chalk.bold.red(`\nBenchmark failed with error:`), err);
process.exit(1);
});

View File

@@ -0,0 +1,126 @@
import { performance } from 'node:perf_hooks';
export class MetricsCollector {
constructor() {
this.entries = new Map();
}
record(name, durationMs, error = null) {
let entry = this.entries.get(name);
if (!entry) {
entry = {
name,
durations: [],
count: 0,
errors: 0,
totalMs: 0,
};
this.entries.set(name, entry);
}
entry.count += 1;
entry.totalMs += durationMs;
entry.durations.push(durationMs);
if (error) {
entry.errors += 1;
}
}
getStats(name) {
const entry = this.entries.get(name);
if (!entry || entry.count === 0) return null;
const sorted = [...entry.durations].sort((a, b) => a - b);
const count = entry.count;
const totalMs = entry.totalMs;
const avgMs = totalMs / count;
const minMs = sorted[0];
const maxMs = sorted[count - 1];
const p50Index = Math.min(Math.floor(count * 0.5), count - 1);
const p95Index = Math.min(Math.floor(count * 0.95), count - 1);
const p99Index = Math.min(Math.floor(count * 0.99), count - 1);
const p50Ms = sorted[p50Index];
const p95Ms = sorted[p95Index];
const p99Ms = sorted[p99Index];
return {
name,
count,
errors: entry.errors,
totalMs,
avgMs,
minMs,
maxMs,
p50Ms,
p95Ms,
p99Ms,
};
}
getAllStats() {
const results = [];
for (const name of this.entries.keys()) {
results.push(this.getStats(name));
}
return results.sort((a, b) => a.name.localeCompare(b.name));
}
}
/**
* Wraps a LibDashStore instance so all method calls across all namespaces
* are intercepted and measured with high-resolution performance timers.
*
* @param {import('../src/index.js').default} store
* @param {MetricsCollector} metrics
* @returns {import('../src/index.js').default} Instrumented store
*/
export function instrumentStore(store, metrics = new MetricsCollector()) {
const ignoredProperties = new Set(['provider', 'pool', 'constructor']);
for (const [namespaceKey, namespaceVal] of Object.entries(store)) {
if (ignoredProperties.has(namespaceKey)) continue;
// Direct root function like store.purge or store.initialize
if (typeof namespaceVal === 'function') {
const originalFn = namespaceVal.bind(store);
store[namespaceKey] = async (...args) => {
const start = performance.now();
let error = null;
try {
return await originalFn(...args);
} catch (err) {
error = err;
throw err;
} finally {
const duration = performance.now() - start;
metrics.record(`store.${namespaceKey}`, duration, error);
}
};
continue;
}
if (typeof namespaceVal === 'object' && namespaceVal !== null) {
for (const [methodKey, methodVal] of Object.entries(namespaceVal)) {
if (typeof methodVal === 'function') {
const originalMethod = methodVal.bind(namespaceVal);
namespaceVal[methodKey] = async (...args) => {
const start = performance.now();
let error = null;
try {
return await originalMethod(...args);
} catch (err) {
error = err;
throw err;
} finally {
const duration = performance.now() - start;
metrics.record(`${namespaceKey}.${methodKey}`, duration, error);
}
};
}
}
}
}
return { store, metrics };
}

View File

@@ -0,0 +1,186 @@
import { writeFileSync } from 'node:fs';
import chalk from 'chalk';
function fmtMs(ms) {
if (ms == null || Number.isNaN(ms)) return '-';
if (ms < 1) return `${ms.toFixed(2)} ms`;
if (ms < 10) return `${ms.toFixed(2)} ms`;
return `${ms.toFixed(1)} ms`;
}
function padRight(str, len) {
const s = String(str);
return s + ' '.repeat(Math.max(0, len - s.length));
}
function padLeft(str, len) {
const s = String(str);
return ' '.repeat(Math.max(0, len - s.length)) + s;
}
export class BenchmarkReporter {
/**
* Prints the single-driver timing summary to stdout.
*/
static printDriverReport(driverName, stats, totalElapsedMs) {
console.log();
console.log(chalk.bold.cyan(`========================================================================================`));
console.log(chalk.bold.cyan(` DATABASE DRIVER TIMING REPORT: ${driverName.toUpperCase()}`));
console.log(chalk.bold.cyan(`========================================================================================`));
const totalOps = stats.reduce((acc, s) => acc + s.count, 0);
const totalErrors = stats.reduce((acc, s) => acc + s.errors, 0);
const header = `${padRight('API Method', 38)} | ${padLeft('Calls', 6)} | ${padLeft('Avg (ms)', 10)} | ${padLeft('Min (ms)', 10)} | ${padLeft('Max (ms)', 10)} | ${padLeft('P50 (ms)', 10)} | ${padLeft('P95 (ms)', 10)}`;
console.log(chalk.gray('-'.repeat(header.length)));
console.log(chalk.bold(header));
console.log(chalk.gray('-'.repeat(header.length)));
for (const item of stats) {
const avgStr = fmtMs(item.avgMs);
const minStr = fmtMs(item.minMs);
const maxStr = fmtMs(item.maxMs);
const p50Str = fmtMs(item.p50Ms);
const p95Str = fmtMs(item.p95Ms);
let line = `${padRight(item.name, 38)} | ${padLeft(item.count, 6)} | ${padLeft(avgStr, 10)} | ${padLeft(minStr, 10)} | ${padLeft(maxStr, 10)} | ${padLeft(p50Str, 10)} | ${padLeft(p95Str, 10)}`;
if (item.errors > 0) {
line = chalk.red(line + ` [${item.errors} ERR]`);
}
console.log(line);
}
console.log(chalk.gray('-'.repeat(header.length)));
console.log(
chalk.green(
`Total Operations: ${totalOps} | Total Time: ${(totalElapsedMs / 1000).toFixed(2)}s | APIs Covered: ${stats.length} | Errors: ${totalErrors}`,
),
);
console.log();
}
/**
* Prints the side-by-side comparison between MSSQL and SQLite.
*/
static printComparisonReport(mssqlStats, sqliteStats, mssqlElapsedMs, sqliteElapsedMs) {
console.log();
console.log(chalk.bold.magenta(`========================================================================================================`));
console.log(chalk.bold.magenta(` COMPARATIVE BENCHMARK: MSSQL vs BETTER-SQLITE3 (Timings in ms)`));
console.log(chalk.bold.magenta(`========================================================================================================`));
const mssqlMap = new Map(mssqlStats.map((s) => [s.name, s]));
const sqliteMap = new Map(sqliteStats.map((s) => [s.name, s]));
const allNames = Array.from(new Set([...mssqlMap.keys(), ...sqliteMap.keys()])).sort();
const header = `${padRight('API Method', 36)} | ${padLeft('Calls', 6)} | ${padLeft('MSSQL Avg', 11)} | ${padLeft('MSSQL P95', 11)} | ${padLeft('SQLite Avg', 11)} | ${padLeft('SQLite P95', 11)} | ${padLeft('Ratio (M/S)', 12)}`;
console.log(chalk.gray('-'.repeat(header.length)));
console.log(chalk.bold(header));
console.log(chalk.gray('-'.repeat(header.length)));
for (const name of allNames) {
const m = mssqlMap.get(name);
const s = sqliteMap.get(name);
const calls = m?.count || s?.count || 0;
const mAvg = m ? fmtMs(m.avgMs) : '-';
const mP95 = m ? fmtMs(m.p95Ms) : '-';
const sAvg = s ? fmtMs(s.avgMs) : '-';
const sP95 = s ? fmtMs(s.p95Ms) : '-';
let ratioStr = '-';
if (m && s && s.avgMs > 0) {
const ratio = m.avgMs / s.avgMs;
ratioStr = `${ratio.toFixed(1)}x`;
if (ratio > 1.5) {
ratioStr = chalk.yellow(ratioStr + ' (SQLite faster)');
} else if (ratio < 0.8) {
ratioStr = chalk.cyan(ratioStr + ' (MSSQL faster)');
}
}
console.log(
`${padRight(name, 36)} | ${padLeft(calls, 6)} | ${padLeft(mAvg, 11)} | ${padLeft(mP95, 11)} | ${padLeft(sAvg, 11)} | ${padLeft(sP95, 11)} | ${padLeft(ratioStr, 12)}`,
);
}
console.log(chalk.gray('-'.repeat(header.length)));
console.log(
chalk.bold(
`Total Runtime: MSSQL = ${(mssqlElapsedMs / 1000).toFixed(2)}s vs SQLite = ${(sqliteElapsedMs / 1000).toFixed(2)}s`,
),
);
console.log();
}
/**
* Generates a GitHub-flavored Markdown report.
*/
static generateMarkdownReport({ mssqlStats, sqliteStats, mssqlElapsedMs, sqliteElapsedMs, options = {} }) {
const lines = [];
lines.push('# Database Drivers API Timing & Benchmark Report');
lines.push('');
lines.push(`- **Date**: ${new Date().toISOString()}`);
lines.push(`- **Scale**: ${options.scale || 'small'}`);
lines.push(`- **Read Iterations on Filled DB**: ${options.iterations || 5}`);
lines.push('');
if (mssqlStats && sqliteStats) {
lines.push('## Comparative Summary');
lines.push('');
lines.push(`| Metric | MSSQL | better-sqlite3 | Delta |`);
lines.push(`| :--- | :---: | :---: | :---: |`);
const mssqlTotalOps = mssqlStats.reduce((acc, s) => acc + s.count, 0);
const sqliteTotalOps = sqliteStats.reduce((acc, s) => acc + s.count, 0);
lines.push(`| Total API Calls | ${mssqlTotalOps} | ${sqliteTotalOps} | - |`);
lines.push(`| Total Runtime | ${(mssqlElapsedMs / 1000).toFixed(2)}s | ${(sqliteElapsedMs / 1000).toFixed(2)}s | ${(mssqlElapsedMs / sqliteElapsedMs).toFixed(1)}x |`);
lines.push('');
lines.push('## API Timings Comparison (in milliseconds)');
lines.push('');
lines.push('| API Method | Calls | MSSQL Avg (ms) | MSSQL P95 (ms) | SQLite Avg (ms) | SQLite P95 (ms) | Ratio (MSSQL / SQLite) |');
lines.push('| :--- | :---: | :---: | :---: | :---: | :---: | :---: |');
const mssqlMap = new Map(mssqlStats.map((s) => [s.name, s]));
const sqliteMap = new Map(sqliteStats.map((s) => [s.name, s]));
const allNames = Array.from(new Set([...mssqlMap.keys(), ...sqliteMap.keys()])).sort();
for (const name of allNames) {
const m = mssqlMap.get(name);
const s = sqliteMap.get(name);
const calls = m?.count || s?.count || 0;
const mAvg = m ? m.avgMs.toFixed(2) : '-';
const mP95 = m ? m.p95Ms.toFixed(2) : '-';
const sAvg = s ? s.avgMs.toFixed(2) : '-';
const sP95 = s ? s.p95Ms.toFixed(2) : '-';
const ratio = m && s && s.avgMs > 0 ? (m.avgMs / s.avgMs).toFixed(2) + 'x' : '-';
lines.push(`| \`${name}\` | ${calls} | ${mAvg} | ${mP95} | ${sAvg} | ${sP95} | ${ratio} |`);
}
lines.push('');
}
const drivers = [];
if (mssqlStats) drivers.push({ name: 'MSSQL (MssqlProvider)', stats: mssqlStats, elapsed: mssqlElapsedMs });
if (sqliteStats) drivers.push({ name: 'SQLite (BetterSqlite3Provider)', stats: sqliteStats, elapsed: sqliteElapsedMs });
for (const driver of drivers) {
lines.push(`## ${driver.name} Detailed Breakdown`);
lines.push('');
lines.push('| API Method | Calls | Avg (ms) | Min (ms) | Max (ms) | P50 (ms) | P95 (ms) | Errors |');
lines.push('| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: |');
for (const item of driver.stats) {
lines.push(
`| \`${item.name}\` | ${item.count} | ${item.avgMs.toFixed(2)} | ${item.minMs.toFixed(2)} | ${item.maxMs.toFixed(2)} | ${item.p50Ms.toFixed(2)} | ${item.p95Ms.toFixed(2)} | ${item.errors} |`,
);
}
lines.push('');
}
return lines.join('\n');
}
static saveReportToFile(filePath, markdownContent) {
writeFileSync(filePath, markdownContent, 'utf-8');
console.log(chalk.green(`✓ Benchmark report written to: ${filePath}`));
}
}

View File

@@ -0,0 +1,789 @@
import sharp from 'sharp';
/**
* Creates sample PNG image buffer.
*/
async function makeSampleImage(width = 200, height = 200, color = { r: 50, g: 120, b: 220 }) {
return sharp({
create: {
width,
height,
channels: 4,
background: { ...color, alpha: 1 },
},
})
.png()
.toBuffer();
}
/**
* Creates sample AVIF thumbnail buffer.
*/
async function makeSampleThumb(width = 64, height = 64) {
return sharp({
create: {
width,
height,
channels: 4,
background: { r: 200, g: 60, b: 60, alpha: 1 },
},
})
.avif()
.toBuffer();
}
/**
* Executes Phase 1: Database Population / Fill.
* Systematically calls creation and write APIs to populate the database
* with realistic entities and relationships according to the configured scale.
*/
export async function fillDatabase(store, options = {}) {
const scale = options.scale || 'small';
const counts = {
small: { categories: 6, articles: 18, entities: 6, manifests: 6 },
medium: { categories: 15, articles: 60, entities: 20, manifests: 20 },
large: { categories: 30, articles: 200, entities: 50, manifests: 60 },
}[scale] || { categories: 6, articles: 18, entities: 6, manifests: 6 };
const ctx = {
googleAuthId: null,
company: null,
secondaryGoogleAuthId: null,
secondaryCompany: null,
categories: [],
articles: [],
fatherArticles: [],
childArticles: [],
customergroups: [],
entities: [],
suppliers: [],
deliveries: [],
orders: [],
quotes: [],
terminals: [],
sampleImageHash: null,
sampleImageThumbHash: null,
attachments: [],
};
const sampleImage = await makeSampleImage();
const sampleThumb = await makeSampleThumb();
// 1. Google Auth setup
const primaryEmail = `benchmark.admin.${Date.now()}@example.com`;
const secEmail = `benchmark.secondary.${Date.now()}@example.com`;
ctx.googleAuthId = await store.google_auth.add(primaryEmail);
await store.google_auth.setProfile(ctx.googleAuthId, {
sub: `sub-${ctx.googleAuthId}`,
picture: 'https://example.com/pic.png',
});
await store.google_auth.listAllowedEmails();
ctx.secondaryGoogleAuthId = await store.google_auth.add(secEmail);
// 2. Company setup
ctx.company = await store.company.create({
name: `Bench Corp ${Date.now()}`,
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId: ctx.googleAuthId,
});
await store.google_auth.setSelectedCompany(ctx.googleAuthId, ctx.company.id);
ctx.secondaryCompany = await store.company.create({
name: `Secondary Corp ${Date.now()}`,
iso639: 'en',
taxrate: 20,
reducedtaxrate: 10,
googleAuthId: ctx.secondaryGoogleAuthId,
});
await store.google_auth.setSelectedCompany(ctx.secondaryGoogleAuthId, ctx.secondaryCompany.id);
await store.company.update(ctx.company.id, {
name: `Bench Corp Updated ${Date.now()}`,
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
});
await store.company.list(ctx.googleAuthId);
// 3. Company Users
const compUserId = await store.companyuser.add(ctx.company.id, ctx.secondaryGoogleAuthId);
ctx.compUserId = compUserId;
await store.companyuser.getByEmail(secEmail);
// 4. Lang APIs
await store.lang.ensure('en');
await store.lang.ensure('fr');
await store.lang.create('es');
await store.lang.getByIso639('en');
await store.lang.list();
// 5. Tax APIs
await store.tax.ensure(19);
await store.tax.ensure(7);
const taxIdSample = await store.tax.create(5.5);
ctx.taxSampleId = taxIdSample;
await store.tax.getByRate(19);
await store.tax.list();
// 6. Unit APIs
await store.unit.upsert(ctx.googleAuthId, {
si: 'kg',
basesi: 'g',
basesifactor: 1000,
symbol: 'kg',
name: 'Kilogramm',
});
await store.unit.upsert(ctx.googleAuthId, {
si: 'stk',
basesi: 'stk',
basesifactor: 1,
symbol: 'Stk',
name: 'Stück',
});
await store.unit.list(ctx.googleAuthId);
// 7. Company Settings
await store.companysetting.set(ctx.googleAuthId, {
key: 'ui.theme',
value: 'dark',
frontend: true,
writeonly: false,
});
await store.companysetting.set(ctx.googleAuthId, {
key: 'labelprinter.type',
value: 'zebra',
frontend: false,
writeonly: false,
});
await store.companysetting.get(ctx.googleAuthId, 'ui.theme');
await store.companysetting.list(ctx.googleAuthId);
// 8. Brand APIs
const brand1 = await store.brand.upsert(ctx.googleAuthId, {
name: 'BenchBrand Pro',
gsprpinfo: 'Manufacturer Pro GmbH',
});
const brand2 = await store.brand.upsert(ctx.googleAuthId, {
name: 'BenchBrand Ultra',
gsprpinfo: 'Manufacturer Ultra AG',
});
ctx.brands = [brand1, brand2];
await store.brand.list(ctx.googleAuthId);
// 9. Customer Groups
const cgVip = await store.customergroup.create(ctx.googleAuthId, { name: 'VIP Members' });
const cgRetail = await store.customergroup.create(ctx.googleAuthId, { name: 'Retail Customers' });
await store.customergroup.update(ctx.googleAuthId, cgVip.id, { name: 'VIP Gold Members' });
const allCgs = await store.customergroup.list(ctx.googleAuthId);
await store.customergroup.order(ctx.googleAuthId, {
customergroupIds: allCgs.map((g) => g.id).reverse(),
});
await store.customergroup.list(ctx.googleAuthId);
ctx.customergroups.push(cgRetail, cgVip);
// 10. Categories Hierarchy
const rootCatId = ctx.company.rootCategoryId;
const numCats = counts.categories;
for (let i = 1; i <= numCats; i++) {
const parentId = i > 3 && ctx.categories.length > 0 ? ctx.categories[(i % 3)].id : rootCatId;
const cat = await store.category.createUnderParent({
googleAuthId: ctx.googleAuthId,
parentId,
name: `Category ${i}`,
});
ctx.categories.push(cat);
}
// Ensure by path
const deepCatId = await store.category.ensureByPath({
googleAuthId: ctx.googleAuthId,
parentId: rootCatId,
path: 'Hardware / Components / Processors',
});
ctx.deepCatId = deepCatId;
// Category manipulation APIs
if (ctx.categories.length >= 2) {
await store.category.rename({
googleAuthId: ctx.googleAuthId,
id: ctx.categories[0].id,
name: 'Main Catalog Category',
});
const orderParent = await store.category.createUnderParent({
googleAuthId: ctx.googleAuthId,
parentId: rootCatId,
name: 'Order Test Parent',
});
const oChild1 = await store.category.createUnderParent({
googleAuthId: ctx.googleAuthId,
parentId: orderParent.id,
name: 'Order Child 1',
});
const oChild2 = await store.category.createUnderParent({
googleAuthId: ctx.googleAuthId,
parentId: orderParent.id,
name: 'Order Child 2',
});
await store.category.move({
googleAuthId: ctx.googleAuthId,
id: oChild2.id,
parentId: orderParent.id,
});
await store.category.order({
googleAuthId: ctx.googleAuthId,
parentId: orderParent.id,
categoryIds: [oChild2.id, oChild1.id],
});
}
// Category image
await store.category.addImage(ctx.googleAuthId, ctx.categories[0].id, sampleImage);
// 11. Numbers APIs
const numList = await store.numbers.list(ctx.googleAuthId);
if (numList.length > 0) {
await store.numbers.update(ctx.googleAuthId, numList[0].id, {
prefix: 'BN-',
padding: 6,
next: 1001,
});
}
// 12. POS Terminals
const term1 = await store.posterminal.upsert(ctx.googleAuthId, {
name: 'Counter Terminal 1',
pairingpin: '1234',
});
const term2 = await store.posterminal.upsert(ctx.googleAuthId, {
name: 'Mobile Terminal 2',
pairingpin: '5678',
});
ctx.terminals.push(term1, term2);
await store.posterminal.setOn(ctx.googleAuthId, term1.id, ctx.categories[0].id);
await store.posterminal.setOff(ctx.googleAuthId, term1.id, ctx.categories[0].id);
await store.posterminal.setOnAll(ctx.googleAuthId, term1.id);
await store.posterminal.setOffAll(ctx.googleAuthId, term1.id);
await store.posterminal.getCategoryPosterminalIds(ctx.googleAuthId, ctx.categories[0].id);
await store.posterminal.list(ctx.googleAuthId);
// 13. Entities (Customers)
const numEntities = counts.entities;
for (let i = 1; i <= numEntities; i++) {
const nextNum = await store.entity.peekNextNumber(ctx.googleAuthId);
await store.entity.isNumberFree(ctx.googleAuthId, nextNum);
const ent = await store.entity.create(ctx.googleAuthId, {
customergroupId: ctx.customergroups[i % ctx.customergroups.length].id,
name: `Customer Company ${i}`,
firstName: `Firstname ${i}`,
});
ctx.entities.push(ent);
// Entity Address
const addr = await store.entityaddress.create(ctx.googleAuthId, ent.id, {
addressType: 'shipping',
name: `Branch ${i}`,
street: `Main Street ${i}`,
streetNumber: `${i}`,
zip: '10115',
city: 'Berlin',
county: 'Berlin',
sort: 1,
});
await store.entityaddress.update(ctx.googleAuthId, ent.id, addr.id, {
street: `Updated Street ${i}`,
});
// Entity Contact
const contact = await store.entitycontact.create(ctx.googleAuthId, ent.id, {
contactType: 'email',
contact: `contact${i}@benchmark.test`,
sort: 1,
});
await store.entitycontact.update(ctx.googleAuthId, ent.id, contact.id, {
contact: `updated-contact${i}@benchmark.test`,
});
}
// 14. Suppliers
for (let i = 1; i <= 3; i++) {
const sup = await store.supplier.create(ctx.googleAuthId, {
name: `Supplier Express ${i}`,
});
await store.supplier.update(ctx.googleAuthId, sup.id, {
name: `Supplier Express ${i} GmbH`,
});
ctx.suppliers.push(sup);
}
await store.supplier.list(ctx.googleAuthId);
// 15. Articles
const numArticles = counts.articles;
for (let i = 1; i <= numArticles; i++) {
const targetCat = ctx.categories[i % ctx.categories.length];
const isFather = i % 4 === 1;
const art = await store.article.createUnderParent({
googleAuthId: ctx.googleAuthId,
parentId: targetCat.id,
name: `Bench Article ${i}`,
});
const sku = art.sku;
ctx.articles.push(art);
// Update article details
await store.article.updateBySku(ctx.googleAuthId, sku, {
name: `Bench Article ${i} Pro`,
unitSi: 'stk',
unitamount: 1,
});
// Prices
await store.article.setPricesBySku(ctx.googleAuthId, sku, [
{ price: 10 + i * 2.5, tax: 19, minmultiplier: 1 },
{ price: 8 + i * 2.5, tax: 19, minmultiplier: 5 },
]);
// EANs
const ean = `4012345${String(i).padStart(6, '0')}`;
await store.article.setEansBySku(ctx.googleAuthId, sku, [ean]);
// Brand
await store.article.setBrandIdBySku(ctx.googleAuthId, sku, ctx.brands[i % ctx.brands.length].id);
// Weight
await store.article.setWeightBySku(ctx.googleAuthId, sku, 0.25 * i);
// Categories
await store.article.setCategoryIdsBySku(ctx.googleAuthId, sku, [
targetCat.id,
ctx.categories[(i + 1) % ctx.categories.length].id,
]);
// V/K Father-Child hierarchy
if (isFather) {
ctx.fatherArticles.push(art);
} else if (ctx.fatherArticles.length > 0) {
const parentFather = ctx.fatherArticles[ctx.fatherArticles.length - 1];
await store.article.setFatherBySku(ctx.googleAuthId, sku, parentFather.sku);
ctx.childArticles.push(art);
}
// Add image on select articles
if (i <= 3) {
const imgRes = await store.article.addImage(ctx.googleAuthId, sku, sampleImage);
ctx.sampleImageHash = imgRes.fullHash;
ctx.sampleImageThumbHash = imgRes.thumbHash;
await store.article.orderImages(ctx.googleAuthId, sku, {
fullHashes: [imgRes.fullHash],
});
}
// Article Suppliers
if (ctx.suppliers.length > 0) {
const sup = ctx.suppliers[i % ctx.suppliers.length];
const artSup = await store.articlesupplier.create(ctx.googleAuthId, sku, {
entityId: sup.id,
sku: `SUP-SKU-${i}`,
pu: 1,
stock: 100,
sort: 1,
netPrice: 7.5 + i,
taxId: ctx.company.taxrateId,
});
await store.articlesupplier.update(ctx.googleAuthId, sku, artSup.id, {
stock: 120,
pu: 2,
});
await store.articlesupplier.order(ctx.googleAuthId, sku, {
ids: [artSup.id],
});
}
}
// 16. Article Import Rows API
const importRows = [
{
sku: `IMP-${Date.now()}-1`,
name: 'Imported Keyboard Model X',
vk: 'V',
parentCategory: 'Hardware / Components / Processors',
brand: 'BenchBrand Pro',
net: '45,50',
tax: 19,
eans: [`4262557${String(Date.now()).slice(-6)}`],
},
{
sku: `IMP-${Date.now()}-2`,
name: 'Imported Keyboard Variant Black',
vk: 'K',
parentCategory: 'Hardware / Components / Processors',
brand: 'BenchBrand Pro',
net: '45,50',
tax: 19,
fatherSku: `IMP-${Date.now()}-1`,
},
];
await store.article.importRows({
googleAuthId: ctx.googleAuthId,
rows: importRows,
});
// 17. Manifests: Quotes, Orders, Deliveries
const numManifests = counts.manifests;
for (let i = 1; i <= numManifests; i++) {
const ent = ctx.entities[i % ctx.entities.length];
const sup = ctx.suppliers[i % ctx.suppliers.length];
const art = ctx.articles[i % ctx.articles.length];
// Quote
const quote = await store.quote.create(ctx.googleAuthId, {
number: `QUO-${Date.now()}-${i}`,
date: new Date().toISOString(),
entityId: ent.id,
externalNumber: `EXT-Q-${i}`,
lines: [
{
sku: art.sku,
name: `Item ${art.sku}`,
amount: 2,
net: 25.0,
pu: 1,
rebate: 0,
taxrate: 19,
comment: 'Standard quote item',
},
],
});
await store.quote.update(ctx.googleAuthId, quote.id, {
externalNumber: `EXT-Q-${i}-UPDATED`,
});
ctx.quotes.push(quote);
// Order
const order = await store.order.create(ctx.googleAuthId, {
number: `ORD-${Date.now()}-${i}`,
date: new Date().toISOString(),
entityId: ent.id,
externalNumber: `EXT-O-${i}`,
lines: [
{
sku: art.sku,
name: `Item ${art.sku}`,
amount: 5,
net: 25.0,
pu: 1,
rebate: 5,
taxrate: 19,
comment: 'Expedited delivery order',
},
],
});
await store.order.update(ctx.googleAuthId, order.id, {
externalNumber: `EXT-O-${i}-CONFIRMED`,
});
ctx.orders.push(order);
// Delivery with attached files
const delivery = await store.delivery.create(ctx.googleAuthId, {
supplierId: sup.id,
externalNumber: `DEL-${Date.now()}-${i}`,
date: new Date().toISOString(),
lines: [
{
sku: art.sku,
name: `Delivery Item ${art.sku}`,
amount: 50,
net: 15.0,
pu: 1,
rebate: 0,
taxrate: 19,
comment: 'Warehouse receipt',
},
],
files: i === 1 ? [
{
name: 'packing-slip.txt',
mimetype: 'text/plain',
content: Buffer.from(`Packing slip details for delivery ${i}`),
},
] : [],
});
await store.delivery.update(ctx.googleAuthId, delivery.id, {
externalNumber: `DEL-${Date.now()}-${i}-STOCKED`,
});
ctx.deliveries.push(delivery);
}
// 18. Manifest Attachments (file storage API)
if (ctx.deliveries.length > 0) {
const primaryManifest = ctx.deliveries[0];
const attach1 = await store.manifestattachment.add(ctx.googleAuthId, primaryManifest.id, {
name: 'invoice.pdf',
mimetype: 'application/pdf',
content: Buffer.from('%PDF-1.4 ... benchmark dummy pdf content ...'),
thumbavif: sampleThumb,
thumbw: 64,
thumbh: 64,
});
const attach2 = await store.manifestattachment.add(ctx.googleAuthId, primaryManifest.id, {
name: 'quality-report.txt',
mimetype: 'text/plain',
content: Buffer.from('Quality inspection passed successfully.'),
});
const currentList = await store.manifestattachment.listByManifestId(
ctx.googleAuthId,
primaryManifest.id,
);
await store.manifestattachment.order(ctx.googleAuthId, primaryManifest.id, {
ids: currentList.map((a) => a.id).reverse(),
});
ctx.attachments.push({ manifestId: primaryManifest.id, ...attach1 });
}
return ctx;
}
/**
* Executes Phase 2: Filled Database Performance Benchmark.
* Runs read, query, search, and lookup APIs against the populated database
* over multiple iterations to measure realistic data access speeds.
*/
export async function benchmarkFilledDatabase(store, ctx, options = {}) {
const iterations = options.iterations || 5;
for (let iter = 0; iter < iterations; iter++) {
// 1. Category Tree & Articles
await store.category.getTreeForCompany(ctx.googleAuthId);
if (ctx.categories.length > 0) {
await store.category.getCategoryArticles(ctx.googleAuthId, ctx.categories[0].id);
}
// 2. Article Catalog Read APIs
await store.article.listSearchIndex(ctx.googleAuthId);
await store.article.listExport(ctx.googleAuthId);
if (ctx.categories.length > 0) {
await store.article.listExport(ctx.googleAuthId, ctx.categories[0].id);
}
// Article SKU lookups & history
if (ctx.articles.length > 0) {
const sampleSku = ctx.articles[iter % ctx.articles.length].sku;
await store.article.getBySku(ctx.googleAuthId, sampleSku);
await store.article.getHistoryBySku(ctx.googleAuthId, sampleSku);
}
// 3. Article Supplier Lookups
if (ctx.articles.length > 0) {
const sampleSku = ctx.articles[iter % ctx.articles.length].sku;
await store.articlesupplier.listByArticleSku(ctx.googleAuthId, sampleSku);
if (ctx.suppliers.length > 0) {
await store.articlesupplier.listByEntityAndSkus(ctx.googleAuthId, {
supplierId: ctx.suppliers[0].id,
skus: [sampleSku],
});
await store.articlesupplier.listBySupplierAndSkus(ctx.googleAuthId, {
supplierId: ctx.suppliers[0].id,
skus: [sampleSku],
});
}
}
// 4. Pic & Thumbnail Reads
if (ctx.sampleImageHash) {
await store.pic.getImageByHash(ctx.googleAuthId, ctx.sampleImageHash);
await store.pic.getThumbByHash(ctx.googleAuthId, ctx.sampleImageHash);
}
// 5. Entities (Customers) Lookups
await store.entity.list(ctx.googleAuthId);
if (ctx.customergroups.length > 0) {
await store.entity.list(ctx.googleAuthId, { customergroupId: ctx.customergroups[0].id });
}
if (ctx.entities.length > 0) {
const sampleEnt = ctx.entities[iter % ctx.entities.length];
await store.entity.getById(ctx.googleAuthId, sampleEnt.id);
await store.entityaddress.list(ctx.googleAuthId, sampleEnt.id);
await store.entitycontact.list(ctx.googleAuthId, sampleEnt.id);
}
// 6. Suppliers Lookups
await store.supplier.list(ctx.googleAuthId);
// 7. Manifests Lookups (Delivery, Order, Quote)
await store.delivery.list(ctx.googleAuthId);
if (ctx.deliveries.length > 0) {
const sampleDel = ctx.deliveries[iter % ctx.deliveries.length];
await store.delivery.getById(ctx.googleAuthId, sampleDel.id);
}
await store.order.list(ctx.googleAuthId);
if (ctx.orders.length > 0) {
const sampleOrd = ctx.orders[iter % ctx.orders.length];
await store.order.getById(ctx.googleAuthId, sampleOrd.id);
}
await store.quote.list(ctx.googleAuthId);
if (ctx.quotes.length > 0) {
const sampleQuo = ctx.quotes[iter % ctx.quotes.length];
await store.quote.getById(ctx.googleAuthId, sampleQuo.id);
}
// 8. Manifest Attachments
if (ctx.attachments.length > 0) {
const sampleAttach = ctx.attachments[0];
await store.manifestattachment.listByManifestId(ctx.googleAuthId, sampleAttach.manifestId);
await store.manifestattachment.getById(ctx.googleAuthId, sampleAttach.manifestId, sampleAttach.id);
}
// 9. Auth & Admin Queries
await store.google_auth.getById(ctx.googleAuthId);
await store.google_auth.getByEmail(`benchmark.admin.${ctx.googleAuthId}`); // may return undefined or auth object
await store.companysetting.list(ctx.googleAuthId);
await store.posterminal.list(ctx.googleAuthId);
}
}
/**
* Executes Phase 3: Lifecycle & Deletion APIs.
* Uses dedicated throwaway test items to exercise all delete and removal APIs
* without destroying the core benchmarked dataset.
*/
export async function testLifecycleDeletions(store, ctx) {
const sampleImage = await makeSampleImage(80, 80, { r: 180, g: 80, b: 80 });
// 1. Temporary Category with Image & Delete
const tempCat = await store.category.createUnderParent({
googleAuthId: ctx.googleAuthId,
parentId: ctx.company.rootCategoryId,
name: 'Temporary Cat for Delete',
});
await store.category.addImage(ctx.googleAuthId, tempCat.id, sampleImage);
await store.category.deleteImage(ctx.googleAuthId, tempCat.id);
await store.category.delete({ googleAuthId: ctx.googleAuthId, id: tempCat.id });
// 2. Temporary Article with Image & Delete
const tempArt = await store.article.createUnderParent({
googleAuthId: ctx.googleAuthId,
parentId: ctx.categories[0].id,
name: 'Temporary Article for Delete',
});
const artImg = await store.article.addImage(ctx.googleAuthId, tempArt.sku, sampleImage);
await store.article.deleteImage(ctx.googleAuthId, tempArt.sku, artImg.fullHash);
// Article Supplier Delete
if (ctx.suppliers.length > 0) {
const tempArtSup = await store.articlesupplier.create(ctx.googleAuthId, tempArt.sku, {
entityId: ctx.suppliers[0].id,
sku: 'TEMP-SUP-SKU',
});
await store.articlesupplier.delete(ctx.googleAuthId, tempArt.sku, tempArtSup.id);
}
await store.article.delete(ctx.googleAuthId, tempArt.sku);
// 3. Manifest Attachment Remove
if (ctx.deliveries.length > 0) {
const tempAttach = await store.manifestattachment.add(ctx.googleAuthId, ctx.deliveries[0].id, {
name: 'delete-me.txt',
mimetype: 'text/plain',
content: Buffer.from('temp file to remove'),
});
await store.manifestattachment.remove(ctx.googleAuthId, ctx.deliveries[0].id, tempAttach.id);
}
// 4. Temporary Delivery, Order, Quote Delete
if (ctx.suppliers.length > 0) {
const tempDel = await store.delivery.create(ctx.googleAuthId, {
supplierId: ctx.suppliers[0].id,
date: new Date().toISOString(),
lines: [{ name: 'Temp item', amount: 1, net: 10, taxrate: 19 }],
});
await store.delivery.delete(ctx.googleAuthId, tempDel.id);
}
const tempOrd = await store.order.create(ctx.googleAuthId, {
number: `TEMP-ORD-${Date.now()}`,
date: new Date().toISOString(),
lines: [{ name: 'Temp item', amount: 1, net: 10, taxrate: 19 }],
});
await store.order.delete(ctx.googleAuthId, tempOrd.id);
const tempQuo = await store.quote.create(ctx.googleAuthId, {
number: `TEMP-QUO-${Date.now()}`,
date: new Date().toISOString(),
lines: [{ name: 'Temp item', amount: 1, net: 10, taxrate: 19 }],
});
await store.quote.delete(ctx.googleAuthId, tempQuo.id);
// 5. Temporary Entity with Contact & Address Delete
const tempEnt = await store.entity.create(ctx.googleAuthId, {
customergroupId: ctx.customergroups[0].id,
name: 'Temporary Delete Entity',
});
const tempAddr = await store.entityaddress.create(ctx.googleAuthId, tempEnt.id, {
addressType: 'billing',
street: 'Temp St',
});
await store.entityaddress.delete(ctx.googleAuthId, tempEnt.id, tempAddr.id);
const tempContact = await store.entitycontact.create(ctx.googleAuthId, tempEnt.id, {
contactType: 'phone',
contact: '+4912345678',
});
await store.entitycontact.delete(ctx.googleAuthId, tempEnt.id, tempContact.id);
await store.entity.delete(ctx.googleAuthId, tempEnt.id);
// 6. Temporary Supplier Delete
const tempSup = await store.supplier.create(ctx.googleAuthId, { name: 'Temp Sup for Delete' });
await store.supplier.delete(ctx.googleAuthId, tempSup.id);
// 7. Temporary Customergroup Delete
const tempCg = await store.customergroup.create(ctx.googleAuthId, { name: 'Temp CG Delete' });
await store.customergroup.delete(ctx.googleAuthId, tempCg.id);
// 8. Temporary Brand Delete
const tempBrand = await store.brand.upsert(ctx.googleAuthId, { name: `TempBrand-${Date.now()}` });
await store.brand.delete(ctx.googleAuthId, tempBrand.id);
// 9. Temporary Unit Delete
const tempUnitSi = `tunit-${Date.now()}`.slice(0, 15);
await store.unit.upsert(ctx.googleAuthId, {
si: tempUnitSi,
basesi: tempUnitSi,
basesifactor: 1,
symbol: 'tu',
name: 'Temp Unit',
});
await store.unit.delete(ctx.googleAuthId, tempUnitSi);
// 10. Temporary Terminal Delete
const tempTerm = await store.posterminal.upsert(ctx.googleAuthId, {
name: 'Temp Terminal Delete',
pairingpin: '9999',
});
await store.posterminal.delete(ctx.googleAuthId, tempTerm.id);
// 11. Company User Delete
if (ctx.compUserId) {
await store.companyuser.delete(ctx.compUserId);
}
// 12. Allowed Email Remove
const tempEmail = `bench.remove.${Date.now()}@example.com`;
await store.google_auth.add(tempEmail);
await store.google_auth.removeAllowedEmail(tempEmail);
// 13. Lang Delete
const tempLangId = await store.lang.create('nl');
await store.lang.delete(tempLangId);
// 14. Secondary Company Delete
if (ctx.secondaryCompany) {
await store.company.delete(ctx.secondaryCompany.id);
}
}

View File

@@ -1,5 +1,7 @@
/** Drop order = reverse of schema create dependency order. */
export const TABLES = [
'manifestattachment',
'file',
'companyuser',
'syncpos',
'companysetting',
@@ -48,10 +50,12 @@ export const PROCEDURES = [
export const VIEWS = ['v_entity_company_number'];
/** Drop before tables — breaks company ↔ customergroup cycle for purge. */
/** Drop before tables — drops all foreign keys so all tables can be purged safely. */
export const CONSTRAINTS = [
`IF EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_company_poscustomergroup')
ALTER TABLE company DROP CONSTRAINT FK_company_poscustomergroup;`,
`DECLARE @dropFkSql NVARCHAR(MAX) = N'';
SELECT @dropFkSql += N'ALTER TABLE ' + QUOTENAME(OBJECT_SCHEMA_NAME(parent_object_id)) + N'.' + QUOTENAME(OBJECT_NAME(parent_object_id)) + N' DROP CONSTRAINT ' + QUOTENAME(name) + N';'
FROM sys.foreign_keys;
IF @dropFkSql <> N'' EXEC sp_executesql @dropFkSql;`,
];
export function buildPurgeSql() {

View File

@@ -76,7 +76,8 @@ WHERE ean = @ean
/** Link an article to its father article and mark the father. */
export const UPDATE_FATHER_BY_SKU = `
UPDATE article
SET fatherarticle_id = @fatherArticleId
SET fatherarticle_id = @fatherArticleId,
fathersort = COALESCE(@fathersort, 0)
WHERE sku = @sku
`;

View File

@@ -53,6 +53,6 @@ WHERE id = @googleAuthId
export const SET_PROFILE = `
UPDATE google_auth
SET picture = @picture, picturebinary = @pictureBinary, sub = @sub
SET picture = @picture, picturebinary = CONVERT(VARBINARY(MAX), @pictureBinary), sub = @sub
WHERE id = @googleAuthId
`;

View File

@@ -283,22 +283,17 @@ export default function importRows(provider) {
const father = await runner.query(articleQueries.GET_ID_BY_SKU, { sku: fatherSku });
const fatherId = father.rows[0]?.articleId;
if (fatherId && fatherId !== childId) {
await runner.query(articleQueries.UPDATE_FATHER_BY_SKU, {
sku,
fatherArticleId: fatherId,
});
await runner.query(articleQueries.SET_IS_FATHER, {
articleId: fatherId,
});
const childCount = await runner.query(getChildCountSql, {
articleId: fatherId,
});
const count = childCount.rows[0].count;
// The child's sort is its 0-based position among the father's children.
await runner.query(articleQueries.SET_FATHER_SORT, {
articleId: childId,
await runner.query(articleQueries.UPDATE_FATHER_BY_SKU, {
sku,
fatherArticleId: fatherId,
fathersort: count - 1,
fathersort: count,
});
await runner.query(articleQueries.SET_IS_FATHER, {
articleId: fatherId,
});
}
}

View File

@@ -53,25 +53,19 @@ export default function setFatherBySku(provider) {
throw new Error('Invalid father');
}
await runner.query(queries.UPDATE_FATHER_BY_SKU, {
sku,
fatherArticleId: fatherId,
});
await runner.query(queries.SET_IS_FATHER, {
articleId: fatherId,
});
const childCount = await runner.query(getChildCountSql, {
articleId: fatherId,
});
const count = childCount.rows[0].count;
// The child's sort is its 0-based position among the father's children.
await runner.query(queries.SET_FATHER_SORT, {
articleId,
await runner.query(queries.UPDATE_FATHER_BY_SKU, {
sku,
fatherArticleId: fatherId,
fathersort: count - 1,
fathersort: count,
});
await runner.query(queries.SET_IS_FATHER, {
articleId: fatherId,
});
const article = await load(articleId, languageId, runner);