From dce65db4355cf40faa8c98d9cfb0eca505afcb37 Mon Sep 17 00:00:00 2001 From: seb Date: Tue, 22 Sep 2026 08:31:01 +0200 Subject: [PATCH] bench --- BENCHMARK_REPORT.md | 130 ++++ package.json | 3 +- scripts/benchmark.js | 197 +++++ scripts/benchmark/instrument.js | 126 ++++ scripts/benchmark/reporter.js | 186 +++++ scripts/benchmark/workload.js | 789 +++++++++++++++++++++ src/providers/mssql/purgeSql.js | 10 +- src/providers/mssql/queries/article.js | 3 +- src/providers/mssql/queries/google_auth.js | 2 +- src/store/article/importRows.js | 17 +- src/store/article/setFatherBySku.js | 20 +- 11 files changed, 1453 insertions(+), 30 deletions(-) create mode 100644 BENCHMARK_REPORT.md create mode 100644 scripts/benchmark.js create mode 100644 scripts/benchmark/instrument.js create mode 100644 scripts/benchmark/reporter.js create mode 100644 scripts/benchmark/workload.js diff --git a/BENCHMARK_REPORT.md b/BENCHMARK_REPORT.md new file mode 100644 index 0000000..16be36b --- /dev/null +++ b/BENCHMARK_REPORT.md @@ -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 | diff --git a/package.json b/package.json index 4e089fc..a66d0ba 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/benchmark.js b/scripts/benchmark.js new file mode 100644 index 0000000..6a7c205 --- /dev/null +++ b/scripts/benchmark.js @@ -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); +}); diff --git a/scripts/benchmark/instrument.js b/scripts/benchmark/instrument.js new file mode 100644 index 0000000..76fa46b --- /dev/null +++ b/scripts/benchmark/instrument.js @@ -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 }; +} diff --git a/scripts/benchmark/reporter.js b/scripts/benchmark/reporter.js new file mode 100644 index 0000000..51bb537 --- /dev/null +++ b/scripts/benchmark/reporter.js @@ -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}`)); + } +} diff --git a/scripts/benchmark/workload.js b/scripts/benchmark/workload.js new file mode 100644 index 0000000..6f5355a --- /dev/null +++ b/scripts/benchmark/workload.js @@ -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); + } +} diff --git a/src/providers/mssql/purgeSql.js b/src/providers/mssql/purgeSql.js index e236af5..973c214 100644 --- a/src/providers/mssql/purgeSql.js +++ b/src/providers/mssql/purgeSql.js @@ -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() { diff --git a/src/providers/mssql/queries/article.js b/src/providers/mssql/queries/article.js index 2447175..5a91d8e 100644 --- a/src/providers/mssql/queries/article.js +++ b/src/providers/mssql/queries/article.js @@ -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 `; diff --git a/src/providers/mssql/queries/google_auth.js b/src/providers/mssql/queries/google_auth.js index 9fc7319..6543e7b 100644 --- a/src/providers/mssql/queries/google_auth.js +++ b/src/providers/mssql/queries/google_auth.js @@ -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 `; diff --git a/src/store/article/importRows.js b/src/store/article/importRows.js index 43ee66b..8975f58 100644 --- a/src/store/article/importRows.js +++ b/src/store/article/importRows.js @@ -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, }); } } diff --git a/src/store/article/setFatherBySku.js b/src/store/article/setFatherBySku.js index 7fcd011..3bc5a48 100644 --- a/src/store/article/setFatherBySku.js +++ b/src/store/article/setFatherBySku.js @@ -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);